test(android): verify real FCM delivery
This commit is contained in:
parent
6c3d3ec16d
commit
a35b7cb8ba
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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<MainActivity> scenario = launch(loginPath)) {
|
||||
waitForElement("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)) {
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user