test(android): verify real FCM delivery

This commit is contained in:
SimpleTest 2026-07-24 15:20:20 +03:00
parent 6c3d3ec16d
commit a35b7cb8ba
6 changed files with 191 additions and 23 deletions

View File

@ -69,7 +69,7 @@ FROM android-base AS emulator
USER root USER root
SHELL ["/bin/bash", "-o", "pipefail", "-c"] 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 \ RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \
"emulator" \ "emulator" \

View File

@ -218,7 +218,7 @@ in either outcome.
## Emulator verification ## 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 image. Manual verification against the local Compose application requires KVM
and host networking. Pass the same `.env` value as a build argument, then and host networking. Pass the same `.env` value as a build argument, then
expose the Compose proxy to Android with `adb reverse`: expose the Compose proxy to Android with `adb reverse`:

View File

@ -10,10 +10,13 @@ import static androidx.test.espresso.web.webdriver.DriverAtoms.webKeys;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
@ -44,6 +47,8 @@ public final class CrossClientStagingInstrumentedTest {
String androidMessage = requiredArgument("android_message"); String androidMessage = requiredArgument("android_message");
String browserReply = requiredArgument("browser_reply"); String browserReply = requiredArgument("browser_reply");
context.getSystemService(NotificationManager.class).cancelAll();
try (ActivityScenario<MainActivity> scenario = launch(loginPath)) { try (ActivityScenario<MainActivity> scenario = launch(loginPath)) {
waitForElement("remember-login-button"); waitForElement("remember-login-button");
click("remember-login-button"); click("remember-login-button");
@ -53,6 +58,15 @@ public final class CrossClientStagingInstrumentedTest {
); );
} }
try (ActivityScenario<MainActivity> 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<MainActivity> scenario = launch(requestPath)) { try (ActivityScenario<MainActivity> scenario = launch(requestPath)) {
waitForSelector(".phx-connected"); waitForSelector(".phx-connected");
waitForElementText("messages", "No messages yet."); waitForElementText("messages", "No messages yet.");
@ -62,6 +76,10 @@ public final class CrossClientStagingInstrumentedTest {
click("share-location-button"); click("share-location-button");
waitForServiceState(true); waitForServiceState(true);
waitForElementText("messages", browserReply); waitForElementText("messages", browserReply);
waitForNotification(
"New message",
"Open Who Need Help to read the conversation."
);
click("stop-location-button"); click("stop-location-button");
waitForServiceState(false); waitForServiceState(false);
} }
@ -97,6 +115,13 @@ public final class CrossClientStagingInstrumentedTest {
.perform(webClick()); .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) private static void replaceText(String id, String value)
throws InterruptedException { throws InterruptedException {
waitForElement(id); waitForElement(id);
@ -111,6 +136,11 @@ public final class CrossClientStagingInstrumentedTest {
} }
private static void waitForSelector(String selector) private static void waitForSelector(String selector)
throws InterruptedException {
waitForSelectorText(selector, "");
}
private static void waitForSelectorText(String selector, String expected)
throws InterruptedException { throws InterruptedException {
long deadline = long deadline =
System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS); System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS);
@ -120,7 +150,7 @@ public final class CrossClientStagingInstrumentedTest {
try { try {
onWebView() onWebView()
.withElement(findElement(Locator.CSS_SELECTOR, selector)) .withElement(findElement(Locator.CSS_SELECTOR, selector))
.check(webMatches(getText(), containsString(""))); .check(webMatches(getText(), containsString(expected)));
return; return;
} catch (AssertionError | RuntimeException failure) { } catch (AssertionError | RuntimeException failure) {
lastFailure = failure; lastFailure = failure;
@ -129,7 +159,10 @@ public final class CrossClientStagingInstrumentedTest {
} }
AssertionError timeout = new AssertionError( AssertionError timeout = new AssertionError(
"WebView selector did not appear: " + selector "WebView selector did not contain expected text: "
+ selector
+ " => "
+ expected
); );
if (lastFailure != null) { if (lastFailure != null) {
@ -139,6 +172,38 @@ public final class CrossClientStagingInstrumentedTest {
throw timeout; 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) private static void waitForElementText(String id, String expected)
throws InterruptedException { throws InterruptedException {
long deadline = long deadline =

View File

@ -8,6 +8,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
alias WhoNeedHelp.Catalog.Category alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Notifications.{Notification, PushDevice}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession} alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket} alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket}
@ -195,7 +196,26 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
validate_interactions!(assignment_id, user_ids) 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)) scope_hashes = Enum.map(user_ids, &:crypto.hash(:sha256, &1))
{:ok, deleted} = {:ok, deleted} =
@ -247,21 +267,32 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
validate_fixture_links!(request_id, assignment_id, requester_id, helper_id) validate_fixture_links!(request_id, assignment_id, requester_id, helper_id)
validate_interactions!(assignment_id, [requester_id, helper_id]) validate_interactions!(assignment_id, [requester_id, helper_id])
messages = message_records =
Message Message
|> where([message], message.assignment_id == ^assignment_id) |> 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() |> Repo.all()
messages =
message_records
|> Enum.map(fn {_id, sender_id, body} -> {sender_id, body} end)
|> Enum.sort() |> Enum.sort()
expected_messages = expected_messages = Enum.sort([{helper_id, android_message}, {requester_id, browser_reply}])
[{helper_id, android_message}, {requester_id, browser_reply}]
|> Enum.sort()
unless messages == expected_messages do unless messages == expected_messages do
Mix.raise("expected the exact Android/browser messages, observed #{inspect(messages)}") Mix.raise("expected the exact Android/browser messages, observed #{inspect(messages)}")
end 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 = sessions =
TrackingSession TrackingSession
|> where( |> where(
@ -310,6 +341,10 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
"request_id" => request_id, "request_id" => request_id,
"assignment_id" => assignment_id, "assignment_id" => assignment_id,
"message_count" => length(messages), "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_session_count" => length(sessions),
"tracking_sample_count" => hd(sessions).sample_count, "tracking_sample_count" => hd(sessions).sample_count,
"active_tracking_sessions" => 0, "active_tracking_sessions" => 0,
@ -403,18 +438,82 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
end end
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}") recipients = Enum.map(user_ids, &"user:#{&1}")
notification_ids = MapSet.new(notification_ids)
device_ids = MapSet.new(device_ids)
Job Job
|> where([job], job.worker == "WhoNeedHelp.Push.DeliveryWorker")
|> where( |> where(
[job], [job],
fragment("?->>'recipient' = ANY(?)", job.args, ^recipients) or job.worker in [
fragment("?->>'assignment_id' = ?", job.args, ^assignment_id) "WhoNeedHelp.Push.DeliveryWorker",
"WhoNeedHelp.Push.NotificationDispatchWorker",
"WhoNeedHelp.Push.DeviceDeliveryWorker",
"WhoNeedHelp.Push.NotificationEmailWorker"
]
) )
|> select([job], job.id) |> select([job], {job.id, job.args})
|> Repo.all() |> 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 end
defp assert_prefix_unused!(context) do defp assert_prefix_unused!(context) do

View File

@ -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" android_image="who-need-help-android:$variant-cross-client-$run_id"
container="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" avd_volume="who-need-help-android-avd-$variant-cross-client-$run_id"
# Android's Automated Test Device image removes applications and background # Firebase Messaging 25.1.1 requires Google Play services 26.12 or newer.
# services that are unrelated to app-level automation. Keep the API 37/16 KB # The stable Android 37.1 Google APIs image supplies that version while keeping
# compatibility coverage in the instrumentation matrix and public smoke test; # the 16 KB page-size coverage used by the public smoke workflow.
# use the Google ATD image for this resource-intensive two-client workflow. android_api=37
android_api=35 android_system_image=system-images/android-37.1/google_apis_ps16k/x86_64
android_system_image=system-images/android-35/google_atd/x86_64
service_class=org.whoneedhelp.mobile.TrackingService service_class=org.whoneedhelp.mobile.TrackingService
fixture_password="$(openssl rand -hex 24)" fixture_password="$(openssl rand -hex 24)"
android_message="android-$run_id" 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 'help_requests', count(*) FROM help_requests
UNION ALL SELECT 'messages', count(*) FROM messages UNION ALL SELECT 'messages', count(*) FROM messages
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments 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 'reports', count(*) FROM reports
UNION ALL SELECT 'reviews', count(*) FROM reviews UNION ALL SELECT 'reviews', count(*) FROM reviews
UNION ALL SELECT 'audit_events', count(*) FROM audit_events UNION ALL SELECT 'audit_events', count(*) FROM audit_events
@ -824,6 +826,8 @@ fi
printf 'magic_link_login=true\n' printf 'magic_link_login=true\n'
printf 'android_chat_message_visible_in_browser=true\n' printf 'android_chat_message_visible_in_browser=true\n'
printf 'browser_chat_reply_visible_in_android=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_foreground_tracking_visible_in_browser=true\n'
printf 'android_tracking_stop_visible_in_browser=true\n' printf 'android_tracking_stop_visible_in_browser=true\n'
printf 'retained_raw_position_after_stop=0\n' printf 'retained_raw_position_after_stop=0\n'

View File

@ -145,7 +145,7 @@ docker build \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --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 "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
--build-arg \ --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 \ --target emulator \
--tag "$image" \ --tag "$image" \
"$ROOT/android" >"$output/image-build.txt" "$ROOT/android" >"$output/image-build.txt"
@ -380,7 +380,7 @@ fi
{ {
printf 'run_id=%s\n' "$run_id" 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 'package=%s\n' "$package"
printf 'public_origin=%s\n' "$WNH_BASE_URL" printf 'public_origin=%s\n' "$WNH_BASE_URL"
printf 'home_loaded=true\n' printf 'home_loaded=true\n'