From b8ddbb1398db96c96243444b651bc04daa54b38b Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 19 Aug 2026 12:47:57 -0700 Subject: [PATCH] =?UTF-8?q?feat(admin):=20userNotificationSend=20=E2=80=94?= =?UTF-8?q?=20push=20notification=20to=20a=20single=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support needs to reach one specific user in the app — e.g. ask a user on an old build to update. The existing admin sendNotification mutation is FCM topic broadcast only; the per-user plumbing existed but was wired for cashout notifications alone. userNotificationSend(input: { username: "jaceth2009" # or accountId — exactly one required title: "...", body: "..." }) { errors { message code } success } - app: account -> kratos user -> device tokens -> push, honoring the recipient's notification settings like every other user-facing push - resolver: exactly-one-of validation, trim + length caps, an explicit success:false on every error branch, and distinct error codes so the caller can tell 'user opted out' (normal) from 'push infrastructure is down' (escalate) without matching on English prose - the send reports observed delivery instead of inferring it: Firebase reports stale tokens as an error even when live devices got the push, and a batch where every token failed for a non-stale reason (expired APNs key) previously returned success. Stale tokens are pruned like every other caller does, and success now means Firebase said at least one device accepted it - operator attribution and per-outcome logging: the admin server never populates gqlContext, so nothing else records who sent what Tests: 2191 pass (3 new suites covering the app layer, the resolver and the push service). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR --- src/app/admin/index.ts | 1 + src/app/admin/send-user-notification.ts | 131 ++++++ src/domain/notifications/errors.ts | 46 +- src/graphql/admin/mutations.ts | 2 + .../root/mutation/user-notification-send.ts | 184 ++++++++ src/graphql/admin/schema.graphql | 18 + src/graphql/error-map.ts | 3 + .../notifications/push-notifications.ts | 38 +- .../app/admin/send-user-notification.spec.ts | 411 ++++++++++++++++++ .../send-referral-notifications.spec.ts | 5 +- .../admin/user-notification-send.spec.ts | 377 ++++++++++++++++ .../notifications/push-notifications.spec.ts | 174 ++++++++ 12 files changed, 1384 insertions(+), 6 deletions(-) create mode 100644 src/app/admin/send-user-notification.ts create mode 100644 src/graphql/admin/root/mutation/user-notification-send.ts create mode 100644 test/flash/unit/app/admin/send-user-notification.spec.ts create mode 100644 test/flash/unit/graphql/admin/user-notification-send.spec.ts create mode 100644 test/flash/unit/services/notifications/push-notifications.spec.ts diff --git a/src/app/admin/index.ts b/src/app/admin/index.ts index a8c42d4e7..b01b57848 100644 --- a/src/app/admin/index.ts +++ b/src/app/admin/index.ts @@ -2,6 +2,7 @@ export * from "./update-user-phone" // export * from "./send-admin-push-notification" // export * from "./send-broadcast-notification" export * from "./send-cashout-notification" +export * from "./send-user-notification" export * from "./invite" // Re-export query functions from invite module for admin GraphQL compatibility diff --git a/src/app/admin/send-user-notification.ts b/src/app/admin/send-user-notification.ts new file mode 100644 index 000000000..77548d51e --- /dev/null +++ b/src/app/admin/send-user-notification.ts @@ -0,0 +1,131 @@ +import { removeDeviceTokens } from "@app/users/remove-device-tokens" +import { + AllDeviceTokensStaleNotificationsServiceError, + DeviceTokensNotRegisteredNotificationsServiceError, + FlashNotificationCategories, + InvalidDeviceNotificationsServiceError, + NoDeviceAcceptedPushNotificationsServiceError, + RecipientDisabledNotificationsServiceError, +} from "@domain/notifications" +import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +export const sendUserNotification = async ({ + accountId, + title, + body, + sentBy, +}: { + accountId: AccountUuid + title: string + body: string + // The admin/support user who triggered this send. Required — see the audit + // log below for why it cannot be optional. + sentBy: UserId +}): Promise => { + const account = await AccountsRepository().findByUuid(accountId) + if (account instanceof Error) return account + + const user = await UsersRepository().findById(account.kratosUserId) + if (user instanceof Error) return user + + // Audit trail. `sentBy` is the only operator attribution this send gets — the + // admin server builds no gqlContext for PinoHttp to log. `body` is on the line + // because it is what lands on the user's lock screen, and it is + // operator-authored text, not user PII. Emitted before the send so the record + // survives a failure mid-delivery. + baseLogger.info( + { accountId, sentBy, title, body, deviceTokenCount: user.deviceTokens.length }, + "admin user notification requested", + ) + + // The "requested" line counts attempts; every terminal path below logs an + // outcome, so a grep cannot read a refused or failed send as a delivery. + const logOutcome = (outcome: string, extra?: Record) => + baseLogger.info( + { accountId, sentBy, outcome, ...extra }, + "admin user notification result", + ) + + // Filtered, not raw: every other user-facing push in the codebase honors the + // recipient's notification settings, and the domain already models this + // category. A user who turned push off must not receive one anyway. + const result = await PushNotificationsService().sendFilteredNotification({ + deviceTokens: user.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.AdminPushNotification, + notificationSettings: account.notificationSettings, + }) + + // Firebase reports stale (uninstalled/reinstalled) tokens as an error even + // when delivery to the user's live tokens succeeded. Prune them like every + // other push caller does, and only report success when Firebase says at + // least one device actually accepted the push. + if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { + const pruned = await removeDeviceTokens({ + userId: account.kratosUserId, + deviceTokens: result.tokens, + }) + if (pruned instanceof Error) { + baseLogger.warn({ accountId, err: pruned }, "failed to prune stale device tokens") + } + + if (result.successCount > 0) { + logOutcome("delivered-with-stale-tokens-pruned") + return true + } + + // "All stale" has to actually mean all stale: if only some tokens were + // stale, the rest failed for another reason, and "ask them to reopen the + // app" sends the operator to the user for a problem the user cannot fix. + if (result.tokens.length === user.deviceTokens.length) { + logOutcome("all-device-tokens-stale") + return new AllDeviceTokensStaleNotificationsServiceError() + } + + logOutcome("no-device-accepted", { failureCodes: result.failureCodes }) + return new NoDeviceAcceptedPushNotificationsServiceError( + "no device accepted the push", + result.failureCodes, + ) + } + + // Every token failed for a non-stale reason. Logged as its own outcome rather + // than folded into "send-failed" so the escalation carries the FCM codes. + if (result instanceof NoDeviceAcceptedPushNotificationsServiceError) { + logOutcome("no-device-accepted", { failureCodes: result.failureCodes }) + return result + } + + // The user has no registered device tokens at all — nothing was handed to + // Firebase. This is the most common terminal state on a support-initiated + // send and it is routine, not a fault. Folding it into "send-failed" would + // put it under the same label as "Firebase module not loaded" and a Google + // 5xx, so an alert on the one outcome that reads as "something broke" would + // fire on every attempt at a user who never enabled push, get muted, and + // then hide a real outage. The API already reports these distinctly + // (PUSH_NO_DEVICE_TOKENS vs UNEXPECTED_CLIENT_ERROR); the log must too. + if (result instanceof InvalidDeviceNotificationsServiceError) { + logOutcome("no-device-tokens") + return result + } + + if (result instanceof Error) { + logOutcome("send-failed") + return result + } + + if (result.status === SendFilteredPushNotificationStatus.Filtered) { + logOutcome("filtered-recipient-opted-out") + return new RecipientDisabledNotificationsServiceError() + } + + logOutcome("delivered") + return true +} diff --git a/src/domain/notifications/errors.ts b/src/domain/notifications/errors.ts index f5a2f195f..ecb923bea 100644 --- a/src/domain/notifications/errors.ts +++ b/src/domain/notifications/errors.ts @@ -7,11 +7,55 @@ export class NotificationsServiceError extends NotificationsError {} export class InvalidDeviceNotificationsServiceError extends NotificationsServiceError {} export class DeviceTokensNotRegisteredNotificationsServiceError extends NotificationsServiceError { tokens: DeviceToken[] - constructor(tokens: DeviceToken[], message?: string | unknown | Error) { + // How many devices actually accepted the push. Callers must use this rather + // than inferring delivery from `tokens.length` — a token can fail for reasons + // other than being unregistered, so "not stale" does not mean "delivered". + successCount: number + // Distinct FCM error codes across every token that failed in this batch, so a + // caller that ends up reporting a delivery failure can say why. + failureCodes: string[] + constructor( + tokens: DeviceToken[], + // Defaults to 0 — "nothing is known to have been delivered". The push + // service always passes the real count; the default only applies to + // callers that construct this directly, where claiming a delivery we + // cannot evidence is the dangerous direction to be wrong in. + successCount = 0, + failureCodes: string[] = [], + message?: string | unknown | Error, + ) { super(message) this.tokens = tokens + this.successCount = successCount + this.failureCodes = failureCodes } } +// Every registered device token was stale and has just been pruned. Distinct +// from InvalidDeviceNotificationsServiceError, which means the user never had +// a device token to begin with. +export class AllDeviceTokensStaleNotificationsServiceError extends NotificationsServiceError {} +// Nothing was delivered, but at least one of the user's tokens was *not* stale. +// The undelivered tokens failed for some other reason (expired APNs auth key, +// sender-id mismatch, quota), so this is a push-infrastructure problem — the +// user cannot fix it by reopening the app, and telling them to is a wasted +// support cycle. Distinct from AllDeviceTokensStaleNotificationsServiceError, +// whose name asserts something stronger than "nothing got through". +export class NoDeviceAcceptedPushNotificationsServiceError extends NotificationsServiceError { + // The distinct FCM codes Firebase refused with — "messaging/third-party-auth-error" + // (expired APNs auth key), "messaging/mismatched-credential" (sender-id + // mismatch), "messaging/quota-exceeded", and so on. This error routes the + // operator to engineering, so carry the diagnosis with it rather than making + // eng correlate the per-token warn lines in push-notifications by timestamp. + failureCodes: string[] + constructor(message?: string | unknown | Error, failureCodes: string[] = []) { + super(message) + this.failureCodes = failureCodes + } +} +// The recipient turned push off (or disabled this notification category) in the +// app, so the notification was filtered out and never handed to Firebase. +// Nothing was sent — callers must not report this as a successful send. +export class RecipientDisabledNotificationsServiceError extends NotificationsServiceError {} export class NotificationsServiceUnreachableServerError extends NotificationsServiceError { level = ErrorLevel.Critical } diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 493701d4d..e20b3a4c8 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -10,6 +10,7 @@ import UserUpdatePhoneMutation from "./root/mutation/user-update-phone" import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map" import SendNotificationMutation from "./root/mutation/send-notification" import sendCashoutSettledNotification from "./root/mutation/cashout-notification-send" +import UserNotificationSendMutation from "./root/mutation/user-notification-send" import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" @@ -26,6 +27,7 @@ export const mutationFields = { businessDeleteMapInfo: BusinessDeleteMapInfoMutation, sendNotification: SendNotificationMutation, cashoutNotificationSend: sendCashoutSettledNotification, + userNotificationSend: UserNotificationSendMutation, cashWalletCutoverUpdate: CashWalletCutoverUpdateMutation, cashWalletCutoverRollback: CashWalletCutoverRollbackMutation, }, diff --git a/src/graphql/admin/root/mutation/user-notification-send.ts b/src/graphql/admin/root/mutation/user-notification-send.ts new file mode 100644 index 000000000..70454f7f2 --- /dev/null +++ b/src/graphql/admin/root/mutation/user-notification-send.ts @@ -0,0 +1,184 @@ +import { Admin } from "@app/index" +import { checkedToAccountUuid } from "@domain/accounts" +import { + AllDeviceTokensStaleNotificationsServiceError, + InvalidDeviceNotificationsServiceError, + NoDeviceAcceptedPushNotificationsServiceError, + RecipientDisabledNotificationsServiceError, +} from "@domain/notifications" +import { ValidationError } from "@domain/shared" +import { InputValidationError, PushNotificationError } from "@graphql/error" +import { apolloErrorResponse, mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { GT } from "@graphql/index" +import SuccessPayload, { + SUCCESS_RESPONSE, +} from "@graphql/shared/types/payload/success-payload" + +const MAX_TITLE_LENGTH = 256 +const MAX_BODY_LENGTH = 1024 + +// PushNotificationError defaults to code "FIREBASE_ERROR", which would collapse +// four distinct terminal states into one. Callers (the ERPNext support panel, +// alerting) must be able to tell "the user opted out" — normal, ignore — from +// "push infrastructure is down" — page engineering — without regexing English +// prose that a copy edit silently breaks. +const PushErrorCode = { + AllTokensStale: "PUSH_ALL_TOKENS_STALE", + NoDeviceAccepted: "PUSH_NO_DEVICE_ACCEPTED", + RecipientDisabled: "PUSH_RECIPIENT_DISABLED", + NoDeviceTokens: "PUSH_NO_DEVICE_TOKENS", +} as const + +// apolloErrorResponse omits the `success` key (it would read as null); +// make every error branch of this mutation report an explicit success: false +const failureResponse = (e: Parameters[0]) => ({ + ...apolloErrorResponse(e), + success: false, +}) + +const UserNotificationSendInput = GT.Input({ + name: "UserNotificationSendInput", + description: + "Sends a push notification to one user's registered devices. " + + "Honors the recipient's notification settings: a user who has disabled " + + "push, or the AdminPushNotification category, is not sent to and the " + + "mutation returns success: false with 'User has disabled admin " + + "notifications' — a normal terminal state, not a system failure. " + + "Other reachable terminal states: the user has no registered devices, " + + "every registered device token is stale (app uninstalled/reinstalled), " + + "and no device accepted the push (push infrastructure problem — escalate " + + "to engineering rather than to the user).", + fields: () => ({ + accountId: { + type: GT.String, + description: "Account uuid of the recipient. Provide this or username.", + }, + username: { + type: GT.String, + description: "Username of the recipient. Provide this or accountId.", + }, + title: { + type: GT.NonNull(GT.String), + description: `Notification title. Trimmed; 1 to ${MAX_TITLE_LENGTH} characters.`, + }, + body: { + type: GT.NonNull(GT.String), + description: `Notification body. Trimmed; 1 to ${MAX_BODY_LENGTH} characters.`, + }, + }), +}) + +const UserNotificationSendMutation = GT.Field< + null, + GraphQLAdminContext, + { + input: { + accountId?: string + username?: string + title: string + body: string + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(SuccessPayload), + args: { + input: { type: GT.NonNull(UserNotificationSendInput) }, + }, + resolve: async (_, args, ctx) => { + const { accountId, username, title, body } = args.input + // Threaded into the app layer, which logs it: this is the only record of + // the operator behind an arbitrary push to a named user. + const sentBy = ctx.user.id + + if (!accountId === !username) + return failureResponse( + new InputValidationError({ + message: "Exactly one of accountId or username is required", + }), + ) + + const trimmedTitle = title.trim() + if (trimmedTitle.length === 0 || trimmedTitle.length > MAX_TITLE_LENGTH) + return failureResponse( + new InputValidationError({ + message: `Title must be between 1 and ${MAX_TITLE_LENGTH} characters`, + }), + ) + + const trimmedBody = body.trim() + if (trimmedBody.length === 0 || trimmedBody.length > MAX_BODY_LENGTH) + return failureResponse( + new InputValidationError({ + message: `Body must be between 1 and ${MAX_BODY_LENGTH} characters`, + }), + ) + + let checkedAccountId: AccountUuid + if (accountId) { + const checked = checkedToAccountUuid(accountId) + if (checked instanceof Error) + return failureResponse(new InputValidationError({ message: "Invalid accountId" })) + checkedAccountId = checked + } else { + // getAccountByUsername validates the username format itself, but + // UsernameRegex rejects surrounding whitespace — trim so a username + // pasted out of a support ticket resolves instead of reading as invalid. + const account = await Admin.getAccountByUsername((username as string).trim()) + if (account instanceof ValidationError) + return failureResponse(new InputValidationError({ message: "Invalid username" })) + if (account instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(account)], success: false } + } + checkedAccountId = account.uuid + } + + const result = await Admin.sendUserNotification({ + accountId: checkedAccountId, + title: trimmedTitle, + body: trimmedBody, + sentBy, + }) + if (result instanceof AllDeviceTokensStaleNotificationsServiceError) + return failureResponse( + new PushNotificationError({ + code: PushErrorCode.AllTokensStale, + message: + "All of the user's registered devices are stale (app uninstalled or reinstalled). Tokens were cleared; ask them to reopen the app.", + }), + ) + if (result instanceof NoDeviceAcceptedPushNotificationsServiceError) + return failureResponse( + new PushNotificationError({ + code: PushErrorCode.NoDeviceAccepted, + message: + "Delivery failed for reasons other than stale tokens — check push infrastructure, not the user. Escalate to engineering rather than retrying.", + }), + ) + if (result instanceof RecipientDisabledNotificationsServiceError) + return failureResponse( + new PushNotificationError({ + code: PushErrorCode.RecipientDisabled, + message: + "User has disabled admin notifications. Nothing was sent — they must re-enable notifications in the app before this will reach them.", + }), + ) + if (result instanceof InvalidDeviceNotificationsServiceError) + return failureResponse( + new PushNotificationError({ + code: PushErrorCode.NoDeviceTokens, + message: + "User has no registered device tokens. They may not have logged in on a device with notifications enabled.", + }), + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)], success: false } + } + + return SUCCESS_RESPONSE + }, +}) + +export default UserNotificationSendMutation diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 67985f35f..c4579b2b2 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -439,6 +439,7 @@ type Mutation { merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! sendNotification(input: SendNotificationInput!): SendNotificationPayload! + userNotificationSend(input: UserNotificationSendInput!): SuccessPayload! userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload! } @@ -786,6 +787,23 @@ type UsdtWallet implements Wallet { walletCurrency: WalletCurrency! } +""" +Sends a push notification to one user's registered devices. Honors the recipient's notification settings: a user who has disabled push, or the AdminPushNotification category, is not sent to and the mutation returns success: false with 'User has disabled admin notifications' — a normal terminal state, not a system failure. Other reachable terminal states: the user has no registered devices, every registered device token is stale (app uninstalled/reinstalled), and no device accepted the push (push infrastructure problem — escalate to engineering rather than to the user). +""" +input UserNotificationSendInput { + """Account uuid of the recipient. Provide this or username.""" + accountId: String + + """Notification body. Trimmed; 1 to 1024 characters.""" + body: String! + + """Notification title. Trimmed; 1 to 256 characters.""" + title: String! + + """Username of the recipient. Provide this or accountId.""" + username: String +} + input UserUpdatePhoneInput { accountUuid: ID! phone: Phone! diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 3becce15a..8c291ef1e 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -811,6 +811,9 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "NotificationsServiceError": case "InvalidDeviceNotificationsServiceError": case "DeviceTokensNotRegisteredNotificationsServiceError": + case "AllDeviceTokensStaleNotificationsServiceError": + case "NoDeviceAcceptedPushNotificationsServiceError": + case "RecipientDisabledNotificationsServiceError": case "AccountError": case "IpFetcherError": case "IpFetcherServiceError": diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 8cb25b592..0de0c0ffd 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -4,6 +4,7 @@ import { DeviceTokensNotRegisteredNotificationsServiceError, FirebaseMessageError, InvalidDeviceNotificationsServiceError, + NoDeviceAcceptedPushNotificationsServiceError, NotificationChannel, NotificationsServiceError, NotificationsServiceUnreachableServerError, @@ -34,15 +35,19 @@ const sendToDevice = async ( try { if (!messaging) { baseLogger.error("Firebase messaging module not loaded") - // FIXME: should return an error? - return true + return new NotificationsServiceError("Firebase messaging module not loaded") } const batchResp = await messaging.sendEachForMulticast({ tokens, ...message }, false) const invalidTokens: DeviceToken[] = [] + // Deduped so a 200-device fleet failing on one bad APNs key reports one + // code, not two hundred. Carried on the returned error: the per-token warn + // lines below are keyed by token only, with no account or operator on them. + const failureCodes = new Set() batchResp.responses.forEach((r, idx) => { if (!r.success) { + failureCodes.add(r.error?.code ?? "unknown") logger.warn( { error: r.error, token: tokens[idx] }, "Error sending notification to device", @@ -61,7 +66,11 @@ const sendToDevice = async ( }) logger.info( - { successCount: batchResp.successCount, failureCount: batchResp.failureCount }, + { + successCount: batchResp.successCount, + failureCount: batchResp.failureCount, + failureCodes: [...failureCodes], + }, "Notification batch response", ) @@ -71,7 +80,28 @@ const sendToDevice = async ( // }) if (invalidTokens.length > 0) { - return new DeviceTokensNotRegisteredNotificationsServiceError(invalidTokens) + return new DeviceTokensNotRegisteredNotificationsServiceError( + invalidTokens, + batchResp.successCount, + [...failureCodes], + ) + } + + // Tokens can fail for reasons other than being unregistered (expired APNs + // auth key, sender-id mismatch, quota). Those leave `invalidTokens` empty, + // so without this check a send where every device failed would report + // success and nothing would have been delivered. + if (batchResp.successCount === 0) { + // Typed, not a bare NotificationsServiceError: it subclasses one, so every + // existing caller (log-and-continue, bestEffort wrappers) behaves + // identically, while the admin resolver can tell the operator that the + // push infrastructure is broken instead of "Unexpected error occurred". + // This is the expired-APNs-auth-key case: no token is unregistered, so + // invalidTokens is empty and nothing above catches it. + return new NoDeviceAcceptedPushNotificationsServiceError( + "no device accepted the push", + [...failureCodes], + ) } return true diff --git a/test/flash/unit/app/admin/send-user-notification.spec.ts b/test/flash/unit/app/admin/send-user-notification.spec.ts new file mode 100644 index 000000000..a27752d9d --- /dev/null +++ b/test/flash/unit/app/admin/send-user-notification.spec.ts @@ -0,0 +1,411 @@ +import { sendUserNotification } from "@app/admin/send-user-notification" +import { removeDeviceTokens } from "@app/users/remove-device-tokens" +import { + AllDeviceTokensStaleNotificationsServiceError, + DeviceTokensNotRegisteredNotificationsServiceError, + FlashNotificationCategories, + InvalidDeviceNotificationsServiceError, + NoDeviceAcceptedPushNotificationsServiceError, + NotificationsServiceError, + RecipientDisabledNotificationsServiceError, +} from "@domain/notifications" +import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +jest.mock("@app/users/remove-device-tokens", () => ({ + removeDeviceTokens: jest.fn(), +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/users", () => ({ + UsersRepository: jest.fn(), +})) + +jest.mock("@services/notifications/push-notifications", () => ({ + PushNotificationsService: jest.fn(), + SendFilteredPushNotificationStatus: { Sent: "Sent", Filtered: "Filtered" }, +})) + +describe("sendUserNotification", () => { + const accountId = "account-id" as AccountUuid + const title = "Please update your app" + const body = "A new version of Flash is available." + const sentBy = "support-user-id" as UserId + + const notificationSettings = { + push: { enabled: true, disabledCategories: [] }, + } as unknown as NotificationSettings + + const mockAccount = { + uuid: accountId, + kratosUserId: "user-id", + notificationSettings, + } + + const mockUser = { + deviceTokens: ["token-1", "token-2"], + } + + const sendFilteredNotification = jest + .fn() + .mockResolvedValue({ status: SendFilteredPushNotificationStatus.Sent }) + // Exposed on the mocked service so a regression back to the unfiltered path + // shows up as a failed assertion rather than a TypeError. + const sendNotification = jest.fn().mockResolvedValue(true) + + beforeEach(() => { + jest.clearAllMocks() + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Sent, + }) + sendNotification.mockResolvedValue(true) + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByUuid: jest.fn().mockResolvedValue(mockAccount), + }) + ;(UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + ;(PushNotificationsService as jest.Mock).mockReturnValue({ + sendFilteredNotification, + sendNotification, + }) + }) + + it("sends the notification to the user's device tokens", async () => { + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith({ + deviceTokens: mockUser.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.AdminPushNotification, + notificationSettings, + }) + }) + + it("honors the recipient's notification settings rather than bypassing them", async () => { + // The raw sendNotification path ignores the user's in-app toggles. This + // must go through the filtered path like every other user-facing push, or + // an opted-out user gets a push they explicitly refused. + await sendUserNotification({ accountId, title, body, sentBy }) + + expect(sendFilteredNotification).toHaveBeenCalledTimes(1) + expect(sendNotification).not.toHaveBeenCalled() + }) + + it("fails when the recipient has admin notifications disabled", async () => { + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + // Nothing was handed to Firebase, so this must never read as a success. + expect(result).toBeInstanceOf(RecipientDisabledNotificationsServiceError) + }) + + it("records the operator who triggered the send", async () => { + // The admin server never assigns req.gqlContext, so PinoHttp logs + // "gqlContext.user": undefined — this line is the only attribution. + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + + await sendUserNotification({ accountId, title, body, sentBy }) + + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ accountId, sentBy, title }), + // "requested", not "sent": it is emitted before a send that can still be + // refused or fail, so grepping it counts attempts, not deliveries. + expect.stringMatching(/admin user notification requested/i), + ) + + info.mockRestore() + }) + + it("records the body alongside the operator, not just the title", async () => { + // "Flash told me to call this number" has to be traceable to an operator + // from one log line. push-notifications logs the body with no accountId and + // no sentBy, so without this the two can only be joined by device token and + // timestamp. + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + + await sendUserNotification({ accountId, title, body, sentBy }) + + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ accountId, sentBy, title, body }), + expect.stringMatching(/admin user notification requested/i), + ) + + info.mockRestore() + }) + + it("records the outcome, not just the attempt", async () => { + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + + await sendUserNotification({ accountId, title, body, sentBy }) + + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ accountId, sentBy, outcome: "delivered" }), + expect.stringMatching(/admin user notification result/i), + ) + + info.mockRestore() + }) + + it("records a filtered send as filtered, not as delivered", async () => { + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + + await sendUserNotification({ accountId, title, body, sentBy }) + + // Nothing reached Firebase; the outcome line is what corrects the record + // the "requested" line would otherwise leave looking like a delivery. + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "filtered-recipient-opted-out" }), + expect.stringMatching(/admin user notification result/i), + ) + + info.mockRestore() + }) + + it("looks the user up via the account's kratos user id", async () => { + const findById = jest.fn().mockResolvedValue(mockUser) + ;(UsersRepository as jest.Mock).mockReturnValue({ findById }) + + await sendUserNotification({ accountId, title, body, sentBy }) + + expect(findById).toHaveBeenCalledWith(mockAccount.kratosUserId) + }) + + it("returns error if account is not found", async () => { + const error = new Error("Account not found") + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByUuid: jest.fn().mockResolvedValue(error), + }) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBe(error) + expect(sendFilteredNotification).not.toHaveBeenCalled() + }) + + it("returns error if user is not found", async () => { + const error = new Error("User not found") + ;(UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(error), + }) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBe(error) + expect(sendFilteredNotification).not.toHaveBeenCalled() + }) + + it("returns the notifications service error if the push send fails", async () => { + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + const error = new NotificationsServiceError("firebase down") + sendFilteredNotification.mockResolvedValue(error) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBe(error) + expect(removeDeviceTokens).not.toHaveBeenCalled() + // "send-failed" is the only outcome that means something is broken, so it + // has to keep meaning that — see the no-device-tokens case below. + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ accountId, sentBy, outcome: "send-failed" }), + expect.stringMatching(/admin user notification result/i), + ) + + info.mockRestore() + }) + + it("logs a user with no device tokens as its own outcome, not as send-failed", async () => { + // Zero registered tokens is the most common terminal state on a support + // send and it is routine — the user never enabled push. Labelling it + // "send-failed" puts it under the same outcome as a Firebase outage, so an + // alert on the one label that means "something broke" fires on every + // routine attempt, gets muted, and then hides the real outage. The API + // already separates these (PUSH_NO_DEVICE_TOKENS vs UNEXPECTED_CLIENT_ERROR). + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + ;(UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ deviceTokens: [] }), + }) + const error = new InvalidDeviceNotificationsServiceError() + sendFilteredNotification.mockResolvedValue(error) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBe(error) + expect(removeDeviceTokens).not.toHaveBeenCalled() + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ accountId, sentBy, outcome: "no-device-tokens" }), + expect.stringMatching(/admin user notification result/i), + ) + expect(info).not.toHaveBeenCalledWith( + expect.objectContaining({ outcome: "send-failed" }), + expect.anything(), + ) + + info.mockRestore() + }) + + it("prunes stale tokens and succeeds when a live device accepted the push", async () => { + // Firebase reports the stale token as an error even though delivery to + // the live token succeeded — the send must count as a success. + sendFilteredNotification.mockResolvedValue( + new DeviceTokensNotRegisteredNotificationsServiceError( + ["token-1"] as DeviceToken[], + 1, + ), + ) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBe(true) + expect(removeDeviceTokens).toHaveBeenCalledWith({ + userId: mockAccount.kratosUserId, + deviceTokens: ["token-1"], + }) + }) + + it("does not claim all tokens were stale when only some were", async () => { + // Mixed fleet: token-1 is a dead Android install, token-2 is live iOS that + // failed on broken APNs auth. Nothing was delivered, but token-2 was never + // stale and was not cleared — so "all devices are stale, ask them to + // reopen the app" would send the operator to the user instead of to eng. + sendFilteredNotification.mockResolvedValue( + new DeviceTokensNotRegisteredNotificationsServiceError( + ["token-1"] as DeviceToken[], + 0, + [ + "messaging/registration-token-not-registered", + "messaging/third-party-auth-error", + ], + ), + ) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBeInstanceOf(NoDeviceAcceptedPushNotificationsServiceError) + expect(result).not.toBeInstanceOf(AllDeviceTokensStaleNotificationsServiceError) + expect(removeDeviceTokens).toHaveBeenCalledWith({ + userId: mockAccount.kratosUserId, + deviceTokens: ["token-1"], + }) + }) + + it("carries the FCM failure codes on the error and into the outcome log", async () => { + // This branch tells the operator to escalate to engineering. Eng needs the + // codes to tell an expired APNs auth key from a sender-id mismatch or a + // quota trip; the per-token warn lines are keyed by token alone, with no + // accountId and no sentBy on them. + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + sendFilteredNotification.mockResolvedValue( + new DeviceTokensNotRegisteredNotificationsServiceError( + ["token-1"] as DeviceToken[], + 0, + [ + "messaging/registration-token-not-registered", + "messaging/third-party-auth-error", + ], + ), + ) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect( + (result as NoDeviceAcceptedPushNotificationsServiceError).failureCodes, + ).toEqual([ + "messaging/registration-token-not-registered", + "messaging/third-party-auth-error", + ]) + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "no-device-accepted", + failureCodes: [ + "messaging/registration-token-not-registered", + "messaging/third-party-auth-error", + ], + }), + expect.stringMatching(/admin user notification result/i), + ) + + info.mockRestore() + }) + + it("logs no-device-accepted with codes when every token failed non-stale", async () => { + // Nothing was unregistered, so the service returns the typed error directly + // rather than the stale-token one. Folding this into "send-failed" would + // drop the only diagnosis the escalation has. + const info = jest.spyOn(baseLogger, "info").mockImplementation(jest.fn() as never) + sendFilteredNotification.mockResolvedValue( + new NoDeviceAcceptedPushNotificationsServiceError("no device accepted the push", [ + "messaging/third-party-auth-error", + ]), + ) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBeInstanceOf(NoDeviceAcceptedPushNotificationsServiceError) + expect(removeDeviceTokens).not.toHaveBeenCalled() + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "no-device-accepted", + failureCodes: ["messaging/third-party-auth-error"], + }), + expect.stringMatching(/admin user notification result/i), + ) + + info.mockRestore() + }) + + it("prunes and fails when every token is stale", async () => { + sendFilteredNotification.mockResolvedValue( + new DeviceTokensNotRegisteredNotificationsServiceError( + ["token-1", "token-2"] as DeviceToken[], + 0, + ), + ) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBeInstanceOf(AllDeviceTokensStaleNotificationsServiceError) + expect(removeDeviceTokens).toHaveBeenCalledWith({ + userId: mockAccount.kratosUserId, + deviceTokens: ["token-1", "token-2"], + }) + }) + + it("logs a warning when pruning the stale tokens fails", async () => { + const warn = jest.spyOn(baseLogger, "warn").mockImplementation(jest.fn() as never) + const pruneError = new Error("mongo write failed") + ;(removeDeviceTokens as jest.Mock).mockResolvedValue(pruneError) + sendFilteredNotification.mockResolvedValue( + new DeviceTokensNotRegisteredNotificationsServiceError( + ["token-1", "token-2"] as DeviceToken[], + 0, + ), + ) + + const result = await sendUserNotification({ accountId, title, body, sentBy }) + + expect(result).toBeInstanceOf(AllDeviceTokensStaleNotificationsServiceError) + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ accountId, err: pruneError }), + expect.stringMatching(/failed to prune stale device tokens/i), + ) + + warn.mockRestore() + }) +}) diff --git a/test/flash/unit/app/invite/send-referral-notifications.spec.ts b/test/flash/unit/app/invite/send-referral-notifications.spec.ts index 6d4291cc4..421fdbaab 100644 --- a/test/flash/unit/app/invite/send-referral-notifications.spec.ts +++ b/test/flash/unit/app/invite/send-referral-notifications.spec.ts @@ -130,7 +130,10 @@ describe("referral push notifications", () => { it("prunes dead device tokens", async () => { mockSendFiltered.mockResolvedValue( - new DeviceTokensNotRegisteredNotificationsServiceError(["dead-1" as DeviceToken]), + new DeviceTokensNotRegisteredNotificationsServiceError( + ["dead-1" as DeviceToken], + 1, + ), ) await sendInviteAcceptedNotificationBestEffort({ inviterAccountId: ACCOUNT }) expect(mockRemoveTokens).toHaveBeenCalledWith( diff --git a/test/flash/unit/graphql/admin/user-notification-send.spec.ts b/test/flash/unit/graphql/admin/user-notification-send.spec.ts new file mode 100644 index 000000000..1453f7bf5 --- /dev/null +++ b/test/flash/unit/graphql/admin/user-notification-send.spec.ts @@ -0,0 +1,377 @@ +const mockGetAccountByUsername = jest.fn() +const mockSendUserNotification = jest.fn() + +jest.mock("@app/index", () => ({ + Admin: { + getAccountByUsername: (...args: unknown[]) => mockGetAccountByUsername(...args), + sendUserNotification: (...args: unknown[]) => mockSendUserNotification(...args), + }, +})) + +import { InvalidUsername, CouldNotFindAccountFromUsernameError } from "@domain/errors" +import { + AllDeviceTokensStaleNotificationsServiceError, + InvalidDeviceNotificationsServiceError, + NoDeviceAcceptedPushNotificationsServiceError, + NotificationsServiceError, + RecipientDisabledNotificationsServiceError, +} from "@domain/notifications" +import UserNotificationSendMutation from "@graphql/admin/root/mutation/user-notification-send" + +const VALID_ACCOUNT_UUID = "39c6e986-979b-40ab-9e7b-df18a9277a84" +const SUPPORT_USER_ID = "support-user-id" + +type Result = { + success?: boolean + errors: { message: string; code?: string }[] +} + +// Mirrors what graphql-admin-server's Apollo `context` fn builds from the +// decoded admin JWT — the resolver reads ctx.user.id off it. +const adminContext = () => ({ + logger: { error: jest.fn() }, + user: { id: SUPPORT_USER_ID, roles: ["support"], ip: "127.0.0.1" }, +}) + +const resolveMutation = async (input: Record): Promise => { + const resolve = UserNotificationSendMutation.resolve as unknown as ( + source: null, + args: { input: Record }, + ctx: Record, + ) => Promise + + return resolve(null, { input }, adminContext()) +} + +describe("userNotificationSend", () => { + beforeEach(() => { + jest.clearAllMocks() + mockSendUserNotification.mockResolvedValue(true) + mockGetAccountByUsername.mockResolvedValue({ + uuid: VALID_ACCOUNT_UUID, + kratosUserId: "user-id", + }) + }) + + describe("input validation", () => { + it("rejects when both accountId and username are provided", async () => { + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + username: "jaceth2009", + title: "t", + body: "b", + }) + + expect(result.errors[0].message).toMatch(/exactly one of accountid or username/i) + expect(result.success).toBe(false) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects when neither accountId nor username is provided", async () => { + const result = await resolveMutation({ title: "t", body: "b" }) + + expect(result.errors[0].message).toMatch(/exactly one of accountid or username/i) + expect(result.success).toBe(false) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects an empty title", async () => { + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: " ", + body: "b", + }) + + expect(result.errors[0].message).toMatch(/title/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects a title over the max length", async () => { + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "x".repeat(257), + body: "b", + }) + + expect(result.errors[0].message).toMatch(/title/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects an empty body", async () => { + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "", + }) + + expect(result.errors[0].message).toMatch(/body/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects a body over the max length", async () => { + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "x".repeat(1025), + }) + + expect(result.errors[0].message).toMatch(/body/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects a malformed accountId", async () => { + const result = await resolveMutation({ + accountId: "not-a-uuid", + title: "t", + body: "b", + }) + + expect(result.errors[0].message).toMatch(/invalid accountid/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + + it("rejects a malformed username", async () => { + mockGetAccountByUsername.mockResolvedValue(new InvalidUsername("x")) + + const result = await resolveMutation({ + username: "x", + title: "t", + body: "b", + }) + + expect(result.errors[0].message).toMatch(/invalid username/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + }) + + describe("by accountId", () => { + it("sends the notification and returns success", async () => { + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "Update available", + body: "Please update your Flash app.", + }) + + expect(result).toEqual({ errors: [], success: true }) + expect(mockSendUserNotification).toHaveBeenCalledWith({ + accountId: VALID_ACCOUNT_UUID, + title: "Update available", + body: "Please update your Flash app.", + sentBy: SUPPORT_USER_ID, + }) + expect(mockGetAccountByUsername).not.toHaveBeenCalled() + }) + + it("trims title and body before sending", async () => { + await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: " Update available ", + body: " Please update. ", + }) + + expect(mockSendUserNotification).toHaveBeenCalledWith({ + accountId: VALID_ACCOUNT_UUID, + title: "Update available", + body: "Please update.", + sentBy: SUPPORT_USER_ID, + }) + }) + }) + + describe("audit trail", () => { + it("threads the calling operator into the app layer", async () => { + // Without this, an arbitrary push to a named user from Flash's verified + // FCM sender is unattributable: the admin server never assigns + // req.gqlContext, so PinoHttp's "gqlContext.user" prop logs undefined. + await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "Update available", + body: "Please update your Flash app.", + }) + + expect(mockSendUserNotification).toHaveBeenCalledWith( + expect.objectContaining({ sentBy: SUPPORT_USER_ID }), + ) + }) + + it("threads the calling operator through the username path too", async () => { + await resolveMutation({ + username: "jaceth2009", + title: "Update available", + body: "Please update your Flash app.", + }) + + expect(mockSendUserNotification).toHaveBeenCalledWith( + expect.objectContaining({ sentBy: SUPPORT_USER_ID }), + ) + }) + }) + + describe("by username", () => { + it("resolves the account and sends the notification", async () => { + const result = await resolveMutation({ + username: "jaceth2009", + title: "Update available", + body: "Please update your Flash app.", + }) + + expect(result).toEqual({ errors: [], success: true }) + expect(mockGetAccountByUsername).toHaveBeenCalledWith("jaceth2009") + expect(mockSendUserNotification).toHaveBeenCalledWith({ + accountId: VALID_ACCOUNT_UUID, + title: "Update available", + body: "Please update your Flash app.", + sentBy: SUPPORT_USER_ID, + }) + }) + + it("trims the username before resolving the account", async () => { + // UsernameRegex rejects surrounding whitespace, so an untrimmed username + // pasted out of a support ticket would read as "Invalid username". + const result = await resolveMutation({ + username: " jaceth2009 ", + title: "Update available", + body: "Please update your Flash app.", + }) + + expect(result).toEqual({ errors: [], success: true }) + expect(mockGetAccountByUsername).toHaveBeenCalledWith("jaceth2009") + }) + + it("returns a mapped error when the username does not exist", async () => { + mockGetAccountByUsername.mockResolvedValue( + new CouldNotFindAccountFromUsernameError("ghost"), + ) + + const result = await resolveMutation({ + username: "ghost", + title: "t", + body: "b", + }) + + expect(result.success).toBe(false) + expect(result.errors).toHaveLength(1) + // "does not exist" must not collapse into the "Invalid username" branch — + // an operator who typos an existing username needs to know which it was. + expect(result.errors[0].message).toMatch(/does not exist for username/i) + expect(mockSendUserNotification).not.toHaveBeenCalled() + }) + }) + + describe("send failures", () => { + it("returns a clear error when the user has no device tokens", async () => { + mockSendUserNotification.mockResolvedValue( + new InvalidDeviceNotificationsServiceError(), + ) + + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "b", + }) + + expect(result.errors[0].message).toMatch(/no registered device tokens/i) + expect(result.errors[0].code).toBe("PUSH_NO_DEVICE_TOKENS") + expect(result.success).toBe(false) + }) + + it("distinguishes all-stale tokens from having no tokens at all", async () => { + mockSendUserNotification.mockResolvedValue( + new AllDeviceTokensStaleNotificationsServiceError(), + ) + + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "b", + }) + + expect(result.success).toBe(false) + expect(result.errors[0].message).toMatch(/stale/i) + expect(result.errors[0].message).toMatch(/reopen the app/i) + expect(result.errors[0].code).toBe("PUSH_ALL_TOKENS_STALE") + // the operator must not be sent chasing "they never logged in" + expect(result.errors[0].message).not.toMatch(/no registered device tokens/i) + }) + + it("points at push infrastructure when nothing was delivered but tokens were live", async () => { + mockSendUserNotification.mockResolvedValue( + new NoDeviceAcceptedPushNotificationsServiceError("no device accepted the push"), + ) + + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "b", + }) + + expect(result.success).toBe(false) + expect(result.errors[0].message).toMatch(/push infrastructure/i) + expect(result.errors[0].code).toBe("PUSH_NO_DEVICE_ACCEPTED") + // Must not send the operator chasing the user: the live token was never + // stale and was never cleared, so reopening the app fixes nothing. + expect(result.errors[0].message).not.toMatch(/reopen the app/i) + // and must not fall through to the generic error-map wrapper + expect(result.errors[0].message).not.toMatch(/unexpected error occurred/i) + }) + + it("reports an opted-out recipient instead of claiming success", async () => { + mockSendUserNotification.mockResolvedValue( + new RecipientDisabledNotificationsServiceError(), + ) + + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "b", + }) + + expect(result.success).toBe(false) + expect(result.errors[0].message).toMatch(/disabled admin notifications/i) + expect(result.errors[0].code).toBe("PUSH_RECIPIENT_DISABLED") + expect(result.errors[0].message).not.toMatch(/unexpected error occurred/i) + }) + + it("gives each terminal state its own machine-readable code", async () => { + // The default PushNotificationError code is FIREBASE_ERROR. If these all + // came back under it, the support panel could only tell "user opted out" + // (ignore) from "push infrastructure is down" (page eng) by regexing the + // English message — which a copy edit silently breaks. + const codeFor = async (error: Error) => { + mockSendUserNotification.mockResolvedValue(error) + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "b", + }) + return result.errors[0].code + } + + const codes = [ + await codeFor(new AllDeviceTokensStaleNotificationsServiceError()), + await codeFor(new NoDeviceAcceptedPushNotificationsServiceError("nope")), + await codeFor(new RecipientDisabledNotificationsServiceError()), + await codeFor(new InvalidDeviceNotificationsServiceError()), + ] + + expect(new Set(codes).size).toBe(codes.length) + expect(codes).not.toContain("FIREBASE_ERROR") + }) + + it("returns a mapped error when the push send fails", async () => { + mockSendUserNotification.mockResolvedValue( + new NotificationsServiceError("firebase down"), + ) + + const result = await resolveMutation({ + accountId: VALID_ACCOUNT_UUID, + title: "t", + body: "b", + }) + + expect(result.success).toBe(false) + expect(result.errors).toHaveLength(1) + expect(result.errors[0].message).toMatch(/firebase down/i) + }) + }) +}) diff --git a/test/flash/unit/services/notifications/push-notifications.spec.ts b/test/flash/unit/services/notifications/push-notifications.spec.ts new file mode 100644 index 000000000..fec9aeccd --- /dev/null +++ b/test/flash/unit/services/notifications/push-notifications.spec.ts @@ -0,0 +1,174 @@ +const mockSendEachForMulticast = jest.fn() +let mockMessaging: unknown = { sendEachForMulticast: mockSendEachForMulticast } + +jest.mock("@services/notifications/firebase", () => ({ + get messaging() { + return mockMessaging + }, +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncToRunInSpan: + ({ fn }: { fn: (...args: unknown[]) => unknown }) => + (...args: unknown[]) => + fn(...args), + recordExceptionInCurrentSpan: jest.fn(), + addAttributesToCurrentSpan: jest.fn(), +})) + +import { + DeviceTokensNotRegisteredNotificationsServiceError, + InvalidDeviceNotificationsServiceError, + NoDeviceAcceptedPushNotificationsServiceError, + NotificationsServiceError, +} from "@domain/notifications" +import { PushNotificationsService } from "@services/notifications/push-notifications" + +const NOT_REGISTERED = "messaging/registration-token-not-registered" + +const args = { + deviceTokens: ["token-1", "token-2"] as DeviceToken[], + title: "Update available", + body: "Please update your Flash app.", +} + +describe("PushNotificationsService.sendNotification", () => { + beforeEach(() => { + jest.clearAllMocks() + mockMessaging = { sendEachForMulticast: mockSendEachForMulticast } + }) + + it("returns true when at least one device accepted the push", async () => { + mockSendEachForMulticast.mockResolvedValue({ + successCount: 2, + failureCount: 0, + responses: [{ success: true }, { success: true }], + }) + + const result = await PushNotificationsService().sendNotification(args) + + expect(result).toBe(true) + }) + + it("does not report success when every token failed for a non-stale reason", async () => { + // Expired APNs auth key: the token is not "unregistered", so nothing lands + // in invalidTokens — but nothing was delivered either. + mockSendEachForMulticast.mockResolvedValue({ + successCount: 0, + failureCount: 2, + responses: [ + { success: false, error: { code: "messaging/third-party-auth-error" } }, + { success: false, error: { code: "messaging/third-party-auth-error" } }, + ], + }) + + const result = await PushNotificationsService().sendNotification(args) + + expect(result).toBeInstanceOf(NotificationsServiceError) + expect((result as NotificationsServiceError).message).toMatch( + /no device accepted the push/i, + ) + }) + + it("carries the distinct FCM failure codes on the no-device-accepted error", async () => { + // The caller escalates this to engineering. Without the codes the + // escalation says "push is broken" and nothing else — the per-token warn + // lines are keyed by token only, with no account on them. + mockSendEachForMulticast.mockResolvedValue({ + successCount: 0, + failureCount: 3, + responses: [ + { success: false, error: { code: "messaging/third-party-auth-error" } }, + { success: false, error: { code: "messaging/third-party-auth-error" } }, + { success: false, error: { code: "messaging/mismatched-credential" } }, + ], + }) + + const result = await PushNotificationsService().sendNotification({ + ...args, + deviceTokens: ["token-1", "token-2", "token-3"] as DeviceToken[], + }) + + expect(result).toBeInstanceOf(NoDeviceAcceptedPushNotificationsServiceError) + // Deduped: a large fleet failing on one bad key must not emit one entry per + // device. + expect( + (result as NoDeviceAcceptedPushNotificationsServiceError).failureCodes, + ).toEqual(["messaging/third-party-auth-error", "messaging/mismatched-credential"]) + }) + + it("carries the failure codes on the stale-token error too", async () => { + // Mixed fleet: the caller turns this into a no-device-accepted escalation + // when successCount is 0, so the codes have to survive that hop. + mockSendEachForMulticast.mockResolvedValue({ + successCount: 0, + failureCount: 2, + responses: [ + { success: false, error: { code: NOT_REGISTERED } }, + { success: false, error: { code: "messaging/third-party-auth-error" } }, + ], + }) + + const result = await PushNotificationsService().sendNotification(args) + + expect(result).toBeInstanceOf(DeviceTokensNotRegisteredNotificationsServiceError) + expect( + (result as DeviceTokensNotRegisteredNotificationsServiceError).failureCodes, + ).toEqual([NOT_REGISTERED, "messaging/third-party-auth-error"]) + }) + + it("reports the real success count alongside the stale tokens", async () => { + mockSendEachForMulticast.mockResolvedValue({ + successCount: 1, + failureCount: 1, + responses: [{ success: false, error: { code: NOT_REGISTERED } }, { success: true }], + }) + + const result = await PushNotificationsService().sendNotification(args) + + expect(result).toBeInstanceOf(DeviceTokensNotRegisteredNotificationsServiceError) + const err = result as DeviceTokensNotRegisteredNotificationsServiceError + expect(err.tokens).toEqual(["token-1"]) + expect(err.successCount).toBe(1) + }) + + it("reports successCount 0 when the only non-stale token failed too", async () => { + mockSendEachForMulticast.mockResolvedValue({ + successCount: 0, + failureCount: 2, + responses: [ + { success: false, error: { code: NOT_REGISTERED } }, + { success: false, error: { code: "messaging/third-party-auth-error" } }, + ], + }) + + const result = await PushNotificationsService().sendNotification(args) + + expect(result).toBeInstanceOf(DeviceTokensNotRegisteredNotificationsServiceError) + const err = result as DeviceTokensNotRegisteredNotificationsServiceError + expect(err.tokens).toEqual(["token-1"]) + expect(err.successCount).toBe(0) + }) + + it("returns an error rather than true when firebase is not initialised", async () => { + mockMessaging = null + + const result = await PushNotificationsService().sendNotification(args) + + expect(result).toBeInstanceOf(NotificationsServiceError) + expect((result as NotificationsServiceError).message).toMatch( + /firebase messaging module not loaded/i, + ) + expect(mockSendEachForMulticast).not.toHaveBeenCalled() + }) + + it("returns InvalidDevice when the user has no device tokens", async () => { + const result = await PushNotificationsService().sendNotification({ + ...args, + deviceTokens: [] as DeviceToken[], + }) + + expect(result).toBeInstanceOf(InvalidDeviceNotificationsServiceError) + expect(mockSendEachForMulticast).not.toHaveBeenCalled() + }) +})