From a35b7cb8baeefbf38d8b6c3ca53afe2bca63d173 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Fri, 24 Jul 2026 15:20:20 +0300 Subject: [PATCH] test(android): verify real FCM delivery --- android/Dockerfile | 2 +- android/README.md | 2 +- .../CrossClientStagingInstrumentedTest.java | 69 +++++++++- lib/mix/tasks/wnh.staging_android_e2e.ex | 121 ++++++++++++++++-- scripts/android-browser-staging-e2e.sh | 16 ++- scripts/android-staging-smoke.sh | 4 +- 6 files changed, 191 insertions(+), 23 deletions(-) diff --git a/android/Dockerfile b/android/Dockerfile index 631766d..7e8790f 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -69,7 +69,7 @@ FROM android-base AS emulator USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] -ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.0/google_apis_ps16k/x86_64 +ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.1/google_apis_ps16k/x86_64 RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \ "emulator" \ diff --git a/android/README.md b/android/README.md index b07c261..aa7d00b 100644 --- a/android/README.md +++ b/android/README.md @@ -218,7 +218,7 @@ in either outcome. ## Emulator verification -The optional `emulator` target contains the API 37.0 Google APIs x86_64 system +The optional `emulator` target contains the API 37.1 Google APIs x86_64 system image. Manual verification against the local Compose application requires KVM and host networking. Pass the same `.env` value as a build argument, then expose the Compose proxy to Android with `adb reverse`: diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java index 683266b..b4d75da 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java @@ -10,10 +10,13 @@ import static androidx.test.espresso.web.webdriver.DriverAtoms.webKeys; import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertTrue; +import android.app.Notification; +import android.app.NotificationManager; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.service.notification.StatusBarNotification; import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ApplicationProvider; @@ -44,6 +47,8 @@ public final class CrossClientStagingInstrumentedTest { String androidMessage = requiredArgument("android_message"); String browserReply = requiredArgument("browser_reply"); + context.getSystemService(NotificationManager.class).cancelAll(); + try (ActivityScenario scenario = launch(loginPath)) { waitForElement("remember-login-button"); click("remember-login-button"); @@ -53,6 +58,15 @@ public final class CrossClientStagingInstrumentedTest { ); } + try (ActivityScenario scenario = launch("/notifications")) { + waitForSelector(".phx-connected"); + clickSelector("[data-enable-push]:not([disabled])"); + waitForSelectorText( + "[data-push-status]", + "Push notifications are enabled on this Android device." + ); + } + try (ActivityScenario scenario = launch(requestPath)) { waitForSelector(".phx-connected"); waitForElementText("messages", "No messages yet."); @@ -62,6 +76,10 @@ public final class CrossClientStagingInstrumentedTest { click("share-location-button"); waitForServiceState(true); waitForElementText("messages", browserReply); + waitForNotification( + "New message", + "Open Who Need Help to read the conversation." + ); click("stop-location-button"); waitForServiceState(false); } @@ -97,6 +115,13 @@ public final class CrossClientStagingInstrumentedTest { .perform(webClick()); } + private static void clickSelector(String selector) throws InterruptedException { + waitForSelector(selector); + onWebView() + .withElement(findElement(Locator.CSS_SELECTOR, selector)) + .perform(webClick()); + } + private static void replaceText(String id, String value) throws InterruptedException { waitForElement(id); @@ -111,6 +136,11 @@ public final class CrossClientStagingInstrumentedTest { } private static void waitForSelector(String selector) + throws InterruptedException { + waitForSelectorText(selector, ""); + } + + private static void waitForSelectorText(String selector, String expected) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS); @@ -120,7 +150,7 @@ public final class CrossClientStagingInstrumentedTest { try { onWebView() .withElement(findElement(Locator.CSS_SELECTOR, selector)) - .check(webMatches(getText(), containsString(""))); + .check(webMatches(getText(), containsString(expected))); return; } catch (AssertionError | RuntimeException failure) { lastFailure = failure; @@ -129,7 +159,10 @@ public final class CrossClientStagingInstrumentedTest { } AssertionError timeout = new AssertionError( - "WebView selector did not appear: " + selector + "WebView selector did not contain expected text: " + + selector + + " => " + + expected ); if (lastFailure != null) { @@ -139,6 +172,38 @@ public final class CrossClientStagingInstrumentedTest { throw timeout; } + private void waitForNotification(String expectedTitle, String expectedBody) + throws InterruptedException { + NotificationManager manager = + context.getSystemService(NotificationManager.class); + long deadline = + System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS); + + while (System.nanoTime() < deadline) { + for (StatusBarNotification active : manager.getActiveNotifications()) { + Notification notification = active.getNotification(); + CharSequence title = + notification.extras.getCharSequence(Notification.EXTRA_TITLE); + CharSequence body = + notification.extras.getCharSequence(Notification.EXTRA_TEXT); + + if ( + expectedTitle.contentEquals(title == null ? "" : title) + && expectedBody.contentEquals(body == null ? "" : body) + ) { + return; + } + } + + Thread.sleep(250); + } + + throw new AssertionError( + "Android did not receive the expected FCM notification: " + + expectedTitle + ); + } + private static void waitForElementText(String id, String expected) throws InterruptedException { long deadline = diff --git a/lib/mix/tasks/wnh.staging_android_e2e.ex b/lib/mix/tasks/wnh.staging_android_e2e.ex index ae4b18d..7bf8805 100644 --- a/lib/mix/tasks/wnh.staging_android_e2e.ex +++ b/lib/mix/tasks/wnh.staging_android_e2e.ex @@ -8,6 +8,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do alias WhoNeedHelp.Catalog.Category alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Messaging.Message + alias WhoNeedHelp.Notifications.{Notification, PushDevice} alias WhoNeedHelp.Repo alias WhoNeedHelp.Tracking.{Position, TrackingSession} alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket} @@ -195,7 +196,26 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do validate_interactions!(assignment_id, user_ids) - job_ids = fixture_push_job_ids(user_ids, assignment_id) + notification_ids = + Notification + |> where([notification], notification.user_id in ^user_ids) + |> select([notification], notification.id) + |> Repo.all() + + device_ids = + PushDevice + |> where([device], device.user_id in ^user_ids) + |> select([device], device.id) + |> Repo.all() + + job_ids = + fixture_push_job_ids( + user_ids, + assignment_id, + notification_ids, + device_ids + ) + scope_hashes = Enum.map(user_ids, &:crypto.hash(:sha256, &1)) {:ok, deleted} = @@ -247,21 +267,32 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do validate_fixture_links!(request_id, assignment_id, requester_id, helper_id) validate_interactions!(assignment_id, [requester_id, helper_id]) - messages = + message_records = Message |> where([message], message.assignment_id == ^assignment_id) - |> select([message], {message.sender_id, message.body}) + |> select([message], {message.id, message.sender_id, message.body}) |> Repo.all() + + messages = + message_records + |> Enum.map(fn {_id, sender_id, body} -> {sender_id, body} end) |> Enum.sort() - expected_messages = - [{helper_id, android_message}, {requester_id, browser_reply}] - |> Enum.sort() + expected_messages = Enum.sort([{helper_id, android_message}, {requester_id, browser_reply}]) unless messages == expected_messages do Mix.raise("expected the exact Android/browser messages, observed #{inspect(messages)}") end + browser_message_id = + Enum.find_value(message_records, fn + {message_id, ^requester_id, ^browser_reply} -> message_id + _other -> nil + end) + + {push_device, notification, delivery_job} = + verified_fcm_delivery!(helper_id, assignment_id, browser_message_id) + sessions = TrackingSession |> where( @@ -310,6 +341,10 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do "request_id" => request_id, "assignment_id" => assignment_id, "message_count" => length(messages), + "android_push_device_id" => push_device.id, + "fcm_notification_id" => notification.id, + "fcm_delivery_job_id" => delivery_job.id, + "fcm_delivery_state" => to_string(delivery_job.state), "tracking_session_count" => length(sessions), "tracking_sample_count" => hd(sessions).sample_count, "active_tracking_sessions" => 0, @@ -403,18 +438,82 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do end end - defp fixture_push_job_ids(user_ids, assignment_id) do + defp fixture_push_job_ids(user_ids, assignment_id, notification_ids, device_ids) do recipients = Enum.map(user_ids, &"user:#{&1}") + notification_ids = MapSet.new(notification_ids) + device_ids = MapSet.new(device_ids) Job - |> where([job], job.worker == "WhoNeedHelp.Push.DeliveryWorker") |> where( [job], - fragment("?->>'recipient' = ANY(?)", job.args, ^recipients) or - fragment("?->>'assignment_id' = ?", job.args, ^assignment_id) + job.worker in [ + "WhoNeedHelp.Push.DeliveryWorker", + "WhoNeedHelp.Push.NotificationDispatchWorker", + "WhoNeedHelp.Push.DeviceDeliveryWorker", + "WhoNeedHelp.Push.NotificationEmailWorker" + ] ) - |> select([job], job.id) + |> select([job], {job.id, job.args}) |> Repo.all() + |> Enum.filter(fn {_id, args} -> + args["recipient"] in recipients or + args["assignment_id"] == assignment_id or + MapSet.member?(notification_ids, args["notification_id"]) or + MapSet.member?(device_ids, args["device_id"]) + end) + |> Enum.map(&elem(&1, 0)) + end + + defp verified_fcm_delivery!(helper_id, assignment_id, browser_message_id) + when is_binary(browser_message_id) do + push_device = + PushDevice + |> where( + [device], + device.user_id == ^helper_id and device.platform == :android and + device.provider == :fcm and is_nil(device.disabled_at) + ) + |> Repo.one() + + notification = + Repo.get_by( + Notification, + user_id: helper_id, + idempotency_key: "message-created:#{browser_message_id}:#{helper_id}" + ) + + delivery_job = + if push_device && notification do + Job + |> where([job], job.worker == "WhoNeedHelp.Push.DeviceDeliveryWorker") + |> where( + [job], + fragment("?->>'notification_id' = ?", job.args, ^notification.id) and + fragment("?->>'device_id' = ?", job.args, ^push_device.id) + ) + |> Repo.one() + end + + valid_notification? = + match?( + %Notification{ + kind: :message_created, + title: "New message", + body: "Open Who Need Help to read the conversation." + }, + notification + ) and notification.data["assignment_id"] == assignment_id + + unless match?(%PushDevice{}, push_device) and valid_notification? and + match?(%Job{state: "completed"}, delivery_job) do + Mix.raise("expected one completed Android FCM delivery for the browser reply") + end + + {push_device, notification, delivery_job} + end + + defp verified_fcm_delivery!(_helper_id, _assignment_id, _browser_message_id) do + Mix.raise("could not identify the browser reply used for Android FCM delivery") end defp assert_prefix_unused!(context) do diff --git a/scripts/android-browser-staging-e2e.sh b/scripts/android-browser-staging-e2e.sh index 0f570c8..22857cc 100755 --- a/scripts/android-browser-staging-e2e.sh +++ b/scripts/android-browser-staging-e2e.sh @@ -41,12 +41,11 @@ browser_image="who-need-help-e2e-tests:android-$run_id" android_image="who-need-help-android:$variant-cross-client-$run_id" container="who-need-help-android-$variant-cross-client-$run_id" avd_volume="who-need-help-android-avd-$variant-cross-client-$run_id" -# Android's Automated Test Device image removes applications and background -# services that are unrelated to app-level automation. Keep the API 37/16 KB -# compatibility coverage in the instrumentation matrix and public smoke test; -# use the Google ATD image for this resource-intensive two-client workflow. -android_api=35 -android_system_image=system-images/android-35/google_atd/x86_64 +# Firebase Messaging 25.1.1 requires Google Play services 26.12 or newer. +# The stable Android 37.1 Google APIs image supplies that version while keeping +# the 16 KB page-size coverage used by the public smoke workflow. +android_api=37 +android_system_image=system-images/android-37.1/google_apis_ps16k/x86_64 service_class=org.whoneedhelp.mobile.TrackingService fixture_password="$(openssl rand -hex 24)" android_message="android-$run_id" @@ -311,6 +310,9 @@ UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens UNION ALL SELECT 'help_requests', count(*) FROM help_requests UNION ALL SELECT 'messages', count(*) FROM messages UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments +UNION ALL SELECT 'notifications', count(*) FROM notifications +UNION ALL SELECT 'notification_preferences', count(*) FROM notification_preferences +UNION ALL SELECT 'push_devices', count(*) FROM push_devices UNION ALL SELECT 'reports', count(*) FROM reports UNION ALL SELECT 'reviews', count(*) FROM reviews UNION ALL SELECT 'audit_events', count(*) FROM audit_events @@ -824,6 +826,8 @@ fi printf 'magic_link_login=true\n' printf 'android_chat_message_visible_in_browser=true\n' printf 'browser_chat_reply_visible_in_android=true\n' + printf 'android_fcm_device_registered=true\n' + printf 'android_fcm_notification_received=true\n' printf 'android_foreground_tracking_visible_in_browser=true\n' printf 'android_tracking_stop_visible_in_browser=true\n' printf 'retained_raw_position_after_stop=0\n' diff --git a/scripts/android-staging-smoke.sh b/scripts/android-staging-smoke.sh index f32210e..683aab5 100755 --- a/scripts/android-staging-smoke.sh +++ b/scripts/android-staging-smoke.sh @@ -145,7 +145,7 @@ docker build \ --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ --build-arg \ - "ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.0/google_apis_ps16k/x86_64" \ + "ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.1/google_apis_ps16k/x86_64" \ --target emulator \ --tag "$image" \ "$ROOT/android" >"$output/image-build.txt" @@ -380,7 +380,7 @@ fi { printf 'run_id=%s\n' "$run_id" - printf 'android_api=37.0\n' + printf 'android_api=37.1\n' printf 'package=%s\n' "$package" printf 'public_origin=%s\n' "$WNH_BASE_URL" printf 'home_loaded=true\n'