@open-mercato/channel-fcm 0.6.8-develop.6985.1.fb93574faa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +31 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js +23 -0
  7. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js.map +7 -0
  8. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js +43 -0
  9. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js.map +7 -0
  10. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js +7 -0
  11. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js.map +7 -0
  12. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js +64 -0
  13. package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js.map +7 -0
  14. package/dist/modules/channel_fcm/acl.js +10 -0
  15. package/dist/modules/channel_fcm/acl.js.map +7 -0
  16. package/dist/modules/channel_fcm/di.js +25 -0
  17. package/dist/modules/channel_fcm/di.js.map +7 -0
  18. package/dist/modules/channel_fcm/index.js +9 -0
  19. package/dist/modules/channel_fcm/index.js.map +7 -0
  20. package/dist/modules/channel_fcm/integration.js +54 -0
  21. package/dist/modules/channel_fcm/integration.js.map +7 -0
  22. package/dist/modules/channel_fcm/lib/adapter.js +160 -0
  23. package/dist/modules/channel_fcm/lib/adapter.js.map +7 -0
  24. package/dist/modules/channel_fcm/lib/credentials.js +56 -0
  25. package/dist/modules/channel_fcm/lib/credentials.js.map +7 -0
  26. package/dist/modules/channel_fcm/lib/fake-provider.js +32 -0
  27. package/dist/modules/channel_fcm/lib/fake-provider.js.map +7 -0
  28. package/dist/modules/channel_fcm/lib/health.js +10 -0
  29. package/dist/modules/channel_fcm/lib/health.js.map +7 -0
  30. package/dist/modules/channel_fcm/setup.js +25 -0
  31. package/dist/modules/channel_fcm/setup.js.map +7 -0
  32. package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js +176 -0
  33. package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js.map +7 -0
  34. package/dist/modules/channel_fcm/widgets/injection/connect/widget.js +17 -0
  35. package/dist/modules/channel_fcm/widgets/injection/connect/widget.js.map +7 -0
  36. package/dist/modules/channel_fcm/widgets/injection-table.js +15 -0
  37. package/dist/modules/channel_fcm/widgets/injection-table.js.map +7 -0
  38. package/jest.config.cjs +34 -0
  39. package/package.json +96 -0
  40. package/src/index.ts +1 -0
  41. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.ts +39 -0
  42. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.ts +64 -0
  43. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.ts +3 -0
  44. package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.ts +88 -0
  45. package/src/modules/channel_fcm/acl.ts +6 -0
  46. package/src/modules/channel_fcm/di.ts +27 -0
  47. package/src/modules/channel_fcm/index.ts +6 -0
  48. package/src/modules/channel_fcm/integration.ts +55 -0
  49. package/src/modules/channel_fcm/lib/__tests__/adapter-app-cache.test.ts +84 -0
  50. package/src/modules/channel_fcm/lib/__tests__/adapter.test.ts +176 -0
  51. package/src/modules/channel_fcm/lib/__tests__/credentials.test.ts +36 -0
  52. package/src/modules/channel_fcm/lib/__tests__/fake-provider.test.ts +96 -0
  53. package/src/modules/channel_fcm/lib/__tests__/message-golden.test.ts +98 -0
  54. package/src/modules/channel_fcm/lib/adapter.ts +238 -0
  55. package/src/modules/channel_fcm/lib/credentials.ts +79 -0
  56. package/src/modules/channel_fcm/lib/fake-provider.ts +46 -0
  57. package/src/modules/channel_fcm/lib/health.ts +13 -0
  58. package/src/modules/channel_fcm/setup.ts +34 -0
  59. package/src/modules/channel_fcm/widgets/injection/connect/widget.client.tsx +208 -0
  60. package/src/modules/channel_fcm/widgets/injection/connect/widget.ts +16 -0
  61. package/src/modules/channel_fcm/widgets/injection-table.ts +13 -0
  62. package/tsconfig.json +9 -0
  63. package/watch.mjs +7 -0
@@ -0,0 +1,64 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
5
+
6
+ /**
7
+ * TC-CHANNEL-PUSH-002 — tenant-wide connect route for push providers.
8
+ *
9
+ * Push providers (FCM/APNs/Expo) declare `channelScope: 'tenant'` so their channel
10
+ * is created once per tenant with `user_id = NULL` and serves every user's devices.
11
+ * They connect through the dedicated admin route
12
+ * `POST /api/communication_channels/channels/connect/tenant-credentials`
13
+ * (feature `communication_channels.connect_tenant_channel`), NOT the per-user route.
14
+ *
15
+ * These assertions are network-free and self-contained: with empty/invalid input the
16
+ * flow never creates a channel or credential row, so there is nothing to clean up.
17
+ * The read side (fan-out + delivery) is already user-agnostic — it resolves a push
18
+ * channel by `{ tenantId, channelType: 'push' }` with no `user_id` filter and reads
19
+ * credentials at `channel.userId ?? null` — which is what makes one tenant channel
20
+ * serve the whole tenant; that path is covered network-free by
21
+ * `push_notifications/lib/__tests__/push-delivery.test.ts`.
22
+ */
23
+ test.describe('TC-CHANNEL-PUSH-002: tenant-wide push connect route', () => {
24
+ test('reaches the registered FCM adapter and rejects missing credentials (422)', async ({ request }) => {
25
+ const token = await getAuthToken(request)
26
+ const response = await apiRequest(
27
+ request,
28
+ 'POST',
29
+ '/api/communication_channels/channels/connect/tenant-credentials',
30
+ {
31
+ token,
32
+ // Missing serviceAccountJson → the adapter's validateCredentials rejects it,
33
+ // so the flow never creates a tenant channel (no side effect / no cleanup).
34
+ data: { providerKey: 'fcm', displayName: 'FCM — tenant connect', credentials: {} },
35
+ },
36
+ )
37
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
38
+ // 422 proves: authenticated, the caller HAS connect_tenant_channel, the provider
39
+ // is tenant-scoped (else 400), and the registered FCM adapter's validateCredentials
40
+ // ran and rejected the empty credentials.
41
+ expect(response.status()).toBe(422)
42
+ const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)
43
+ expect(body?.fieldErrors ?? body?.error).toBeTruthy()
44
+ })
45
+
46
+ test('rejects a per-user provider on the tenant route (400)', async ({ request }) => {
47
+ const token = await getAuthToken(request)
48
+ const response = await apiRequest(
49
+ request,
50
+ 'POST',
51
+ '/api/communication_channels/channels/connect/tenant-credentials',
52
+ {
53
+ token,
54
+ // IMAP is a per-user provider (no channelScope) — an admin must not be able
55
+ // to force it into a shared tenant-wide channel through this route.
56
+ data: { providerKey: 'imap', displayName: 'IMAP — should be rejected', credentials: {} },
57
+ },
58
+ )
59
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
60
+ expect(response.status()).toBe(400)
61
+ const body = await readJsonSafe<{ code?: string }>(response)
62
+ expect(body?.code).toBe('provider_not_tenant_scoped')
63
+ })
64
+ })
@@ -0,0 +1,3 @@
1
+ export const integrationMeta = {
2
+ requiredEnvVars: ['OM_PUSH_FAKE_PROVIDERS'],
3
+ }
@@ -0,0 +1,88 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import { createNotificationFixture } from '@open-mercato/core/helpers/integration/notificationsFixtures'
5
+ import { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'
6
+ import {
7
+ connectFakePushChannel,
8
+ deleteChannelIfExists,
9
+ deleteDeliveriesForDevice,
10
+ deleteFakePushDevice,
11
+ expectNativeMessage,
12
+ makeFakePushToken,
13
+ readLatestDelivery,
14
+ registerFakePushDevice,
15
+ } from '@open-mercato/core/helpers/integration/pushFake'
16
+
17
+ /**
18
+ * TC-CHANNEL-PUSH-005 — the REAL FCM adapter, end-to-end, without live credentials.
19
+ *
20
+ * `push_stub` replaces the whole adapter, so TC-PUSH-003 never executes a line of `channel-fcm`. Here
21
+ * only the `firebase-admin` client is faked (`OM_PUSH_FAKE_PROVIDERS`), so the adapter's own credential
22
+ * parsing, client caching, and `buildFcmMessage` all run: connect a tenant channel through the real
23
+ * credential-connect route → register a device routed to `fcm` → `POST /api/notifications` → drain
24
+ * `events` (fan-out) then `push-deliveries` (worker) → the delivery row reaches `sent` AND the
25
+ * provider-native message the adapter handed the SDK is asserted.
26
+ */
27
+ const PROVIDER = 'fcm'
28
+
29
+ test.describe('TC-CHANNEL-PUSH-005: real FCM adapter reaches sent with a correct native message', () => {
30
+ test('delivers a visible notification and maps pushOptions onto the FCM message', async ({ request }) => {
31
+ // Budgeted explicitly rather than via `test.slow()` (which only triples the config's 20s budget).
32
+ // NOTE: this does not fix the current CI failure — `POST /api/notifications` hangs >30s in the
33
+ // channel-package shard, so the test dies on `apiRequest`'s own 30s timeout. See the spec changelog.
34
+ test.setTimeout(120_000)
35
+ const adminToken = await getAuthToken(request, 'admin')
36
+ const { tenantId, organizationId, userId } = getTokenScope(adminToken)
37
+ // Run-unique: the sink is append-only and is not truncated on the reused-environment path.
38
+ const { pushToken, tokenTail } = makeFakePushToken(PROVIDER)
39
+ const startedAt = new Date().toISOString()
40
+
41
+ let channelId: string | null = null
42
+ let userDeviceId: string | null = null
43
+ try {
44
+ channelId = await connectFakePushChannel(request, adminToken, PROVIDER, 'TC-CHANNEL-PUSH-005 FCM')
45
+ userDeviceId = await registerFakePushDevice(
46
+ request,
47
+ adminToken,
48
+ PROVIDER,
49
+ pushToken,
50
+ `qa-tc-channel-push-005-${Date.now()}`,
51
+ )
52
+
53
+ await createNotificationFixture(request, adminToken, {
54
+ recipientUserId: userId,
55
+ type: 'admin.custom_message',
56
+ title: 'Order shipped',
57
+ body: 'Your order is on its way',
58
+ data: { probe: 'tc-channel-push-005' },
59
+ pushOptions: { channelId: 'orders', badge: 3, sound: 'chime.caf' },
60
+ })
61
+
62
+ await drainIntegrationQueue('events')
63
+ await drainIntegrationQueue('push-deliveries')
64
+
65
+ await expect
66
+ .poll(async () => (await readLatestDelivery(tenantId, userDeviceId as string))?.status ?? null, {
67
+ timeout: 30_000,
68
+ })
69
+ .toBe('sent')
70
+
71
+ const row = await readLatestDelivery(tenantId, userDeviceId as string)
72
+ expect(row?.provider).toBe(PROVIDER)
73
+ expect(row?.token_snapshot).toBe(tokenTail)
74
+ expect(row?.organization_id).toBe(organizationId ?? null)
75
+
76
+ // The message firebase-admin would have transmitted — proof the real adapter, not a stub, ran.
77
+ const native = await expectNativeMessage(PROVIDER, tokenTail, startedAt)
78
+ expect(native.token).toBe(pushToken)
79
+ expect(native.notification).toMatchObject({ title: 'Order shipped', body: 'Your order is on its way' })
80
+ expect(native.android).toMatchObject({ notification: { channelId: 'orders', sound: 'chime.caf' } })
81
+ expect(native.apns).toMatchObject({ payload: { aps: { badge: 3, sound: 'chime.caf' } } })
82
+ } finally {
83
+ await deleteDeliveriesForDevice(userDeviceId)
84
+ await deleteFakePushDevice(request, adminToken, userDeviceId)
85
+ await deleteChannelIfExists(request, adminToken, channelId)
86
+ }
87
+ })
88
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'channel_fcm.view', title: 'View FCM channel configuration', module: 'channel_fcm' },
3
+ { id: 'channel_fcm.configure', title: 'Configure FCM credentials', module: 'channel_fcm' },
4
+ ]
5
+
6
+ export default features
@@ -0,0 +1,27 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import {
4
+ hasChannelAdapter,
5
+ registerChannelAdapter,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
7
+ import { getFcmChannelAdapter } from './lib/adapter'
8
+ import { ensureFcmFakeProviderInstalled } from './lib/fake-provider'
9
+ import { channelFcmHealthCheck } from './lib/health'
10
+
11
+ export function register(container: AppContainer): void {
12
+ // Test-only: swap firebase-admin for a network-free fake when `OM_PUSH_FAKE_PROVIDERS` is set
13
+ // (no-op in production), so integration specs exercise this adapter end-to-end. Swaps the SDK
14
+ // client only — the adapter below stays registered. See lib/fake-provider.ts.
15
+ ensureFcmFakeProviderInstalled()
16
+
17
+ if (!hasChannelAdapter('fcm')) {
18
+ registerChannelAdapter(getFcmChannelAdapter())
19
+ }
20
+ container.register({
21
+ channelFcmAdapter: asValue(getFcmChannelAdapter()),
22
+ // Registered under the exact service name declared in `integration.ts`
23
+ // (`healthCheck.service`); without it the hub's resolve throws and the
24
+ // channel reports permanently 'unhealthy'.
25
+ channelFcmHealthCheck: asValue(channelFcmHealthCheck),
26
+ })
27
+ }
@@ -0,0 +1,6 @@
1
+ export const metadata = {
2
+ id: 'channel_fcm',
3
+ title: 'Firebase Cloud Messaging Channel',
4
+ description:
5
+ 'Deliver mobile push to Android/iOS devices via Firebase Cloud Messaging. Registers a `push` ChannelAdapter (providerKey `fcm`) into the Communications Hub; consumed by the push_notifications delivery strategy.',
6
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ type IntegrationBundle,
3
+ type IntegrationDefinition,
4
+ } from '@open-mercato/shared/modules/integrations/types'
5
+
6
+ export const integration: IntegrationDefinition = {
7
+ id: 'channel_fcm',
8
+ title: 'Firebase Cloud Messaging',
9
+ description:
10
+ 'Deliver mobile push to Android (and iOS via FCM) devices using Firebase Cloud Messaging. Provider for the push_notifications channel.',
11
+ category: 'communication',
12
+ hub: 'communication_channels',
13
+ providerKey: 'fcm',
14
+ icon: 'firebase',
15
+ docsUrl: 'https://firebase.google.com/docs/cloud-messaging',
16
+ package: '@open-mercato/channel-fcm',
17
+ version: '0.1.0',
18
+ author: 'Open Mercato Team',
19
+ company: 'Open Mercato',
20
+ license: 'MIT',
21
+ tags: ['push', 'fcm', 'firebase', 'android', 'communication'],
22
+ apiVersions: [
23
+ {
24
+ id: 'v1',
25
+ label: 'FCM HTTP v1',
26
+ status: 'stable',
27
+ default: true,
28
+ changelog: 'Firebase Cloud Messaging HTTP v1 via firebase-admin (service-account auth).',
29
+ },
30
+ ],
31
+ credentials: {
32
+ fields: [
33
+ {
34
+ key: 'serviceAccountJson',
35
+ label: 'Service Account JSON',
36
+ type: 'secret',
37
+ required: true,
38
+ helpText:
39
+ 'Firebase Console -> Project Settings -> Service Accounts -> Generate New Private Key. Paste the full JSON. Stored encrypted at rest.',
40
+ },
41
+ {
42
+ key: 'appName',
43
+ label: 'App Name (optional)',
44
+ type: 'text',
45
+ required: false,
46
+ helpText: 'Optional label for the Firebase app instance. Leave blank to auto-derive.',
47
+ },
48
+ ],
49
+ },
50
+ healthCheck: { service: 'channelFcmHealthCheck' },
51
+ }
52
+
53
+ export const integrations: IntegrationDefinition[] = [integration]
54
+ export const bundles: IntegrationBundle[] = []
55
+ export const bundle: IntegrationBundle | undefined = undefined
@@ -0,0 +1,84 @@
1
+ import { getFcmChannelAdapter } from '../adapter'
2
+ import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
3
+
4
+ type FakeApp = { name: string; delete: jest.Mock<Promise<void>, []> }
5
+
6
+ const mockApps: FakeApp[] = []
7
+ const mockDeletedNames: string[] = []
8
+
9
+ jest.mock('firebase-admin/app', () => ({
10
+ cert: jest.fn((config: unknown) => ({ __cert: config })),
11
+ getApps: jest.fn(() => mockApps),
12
+ initializeApp: jest.fn((_options: unknown, name: string) => {
13
+ const app: FakeApp = {
14
+ name,
15
+ delete: jest.fn(async () => {
16
+ mockDeletedNames.push(name)
17
+ const index = mockApps.indexOf(app)
18
+ if (index >= 0) mockApps.splice(index, 1)
19
+ }),
20
+ }
21
+ mockApps.push(app)
22
+ return app
23
+ }),
24
+ }))
25
+
26
+ jest.mock('firebase-admin/messaging', () => ({
27
+ getMessaging: jest.fn(() => ({ send: jest.fn(async () => 'projects/demo/messages/1') })),
28
+ }))
29
+
30
+ function buildInputForPrivateKey(privateKey: string): SendMessageInput {
31
+ const serviceAccountJson = JSON.stringify({
32
+ project_id: 'demo-project',
33
+ client_email: 'svc@demo-project.iam.gserviceaccount.com',
34
+ private_key: privateKey,
35
+ })
36
+ return {
37
+ content: {
38
+ text: 'Body text',
39
+ bodyFormat: 'text',
40
+ raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped' } },
41
+ },
42
+ credentials: { serviceAccountJson },
43
+ scope: { tenantId: 't1', organizationId: 'o1' },
44
+ metadata: { pushToken: 'device-token-abc', platform: 'android' },
45
+ }
46
+ }
47
+
48
+ describe('FcmChannelAdapter default app cache', () => {
49
+ const APP_CACHE_MAX = 32
50
+
51
+ it('LRU-evicts the least-recently used app and calls app.delete() once the cap is exceeded', async () => {
52
+ const adapter = getFcmChannelAdapter()
53
+
54
+ // Fill the cache to its cap with distinct service accounts (distinct key hash → distinct app).
55
+ for (let index = 0; index < APP_CACHE_MAX; index += 1) {
56
+ const result = await adapter.sendMessage(buildInputForPrivateKey(`key-${index}`))
57
+ expect(result.status).toBe('sent')
58
+ }
59
+
60
+ expect(mockApps).toHaveLength(APP_CACHE_MAX)
61
+ expect(mockDeletedNames).toHaveLength(0)
62
+
63
+ // The (cap + 1)-th distinct account exceeds the bound and evicts the oldest app.
64
+ const overflow = await adapter.sendMessage(buildInputForPrivateKey('key-overflow'))
65
+ expect(overflow.status).toBe('sent')
66
+
67
+ expect(mockDeletedNames).toHaveLength(1)
68
+ expect(mockApps).toHaveLength(APP_CACHE_MAX)
69
+ })
70
+
71
+ it('reuses the cached app for repeated sends with the same service account (no new init, no delete)', async () => {
72
+ const before = mockApps.length
73
+ const deletesBefore = mockDeletedNames.length
74
+
75
+ const first = await getFcmChannelAdapter().sendMessage(buildInputForPrivateKey('key-overflow'))
76
+ const second = await getFcmChannelAdapter().sendMessage(buildInputForPrivateKey('key-overflow'))
77
+
78
+ expect(first.status).toBe('sent')
79
+ expect(second.status).toBe('sent')
80
+ // Same account → same cache entry; no additional app initialized, none evicted.
81
+ expect(mockApps.length).toBe(before)
82
+ expect(mockDeletedNames.length).toBe(deletesBefore)
83
+ })
84
+ })
@@ -0,0 +1,176 @@
1
+ import { getFcmChannelAdapter, setFcmMessagingFactory, type FcmMessagingFactory } from '../adapter'
2
+ import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
3
+
4
+ const serviceAccountJson = JSON.stringify({
5
+ project_id: 'demo-project',
6
+ client_email: 'svc@demo-project.iam.gserviceaccount.com',
7
+ private_key: '-----BEGIN PRIVATE KEY-----\nMIIfake\n-----END PRIVATE KEY-----\n',
8
+ })
9
+
10
+ function buildInput(overrides?: Partial<SendMessageInput>): SendMessageInput {
11
+ return {
12
+ content: {
13
+ text: 'Body text',
14
+ bodyFormat: 'text',
15
+ raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped', notificationId: 'n1' } },
16
+ },
17
+ credentials: { serviceAccountJson },
18
+ scope: { tenantId: 't1', organizationId: 'o1' },
19
+ metadata: { pushToken: 'device-token-abc', platform: 'android' },
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ afterEach(() => setFcmMessagingFactory(null))
25
+
26
+ describe('FcmChannelAdapter', () => {
27
+ it('sends a notification and returns the provider message id', async () => {
28
+ const send = jest.fn().mockResolvedValue('projects/demo/messages/123')
29
+ const factory: FcmMessagingFactory = () => ({ send })
30
+ setFcmMessagingFactory(factory)
31
+
32
+ const result = await getFcmChannelAdapter().sendMessage(buildInput())
33
+
34
+ expect(result.status).toBe('sent')
35
+ expect(result.externalMessageId).toBe('projects/demo/messages/123')
36
+ expect(send).toHaveBeenCalledTimes(1)
37
+ const message = send.mock.calls[0][0]
38
+ expect(message.token).toBe('device-token-abc')
39
+ expect(message.notification).toEqual({ title: 'Hello', body: 'Body text' })
40
+ expect(message.data).toEqual({ type: 'orders.shipped', notificationId: 'n1' })
41
+ })
42
+
43
+ it('sends a data-only content-available message when the envelope is silent', async () => {
44
+ const send = jest.fn().mockResolvedValue('projects/demo/messages/silent')
45
+ setFcmMessagingFactory(() => ({ send }))
46
+
47
+ const result = await getFcmChannelAdapter().sendMessage(
48
+ buildInput({
49
+ content: { raw: { title: '', body: '', data: { type: 'sync.data.updated' }, silent: true } },
50
+ }),
51
+ )
52
+
53
+ expect(result.status).toBe('sent')
54
+ const message = send.mock.calls[0][0]
55
+ expect(message.notification).toBeUndefined()
56
+ expect(message.data).toEqual({ type: 'sync.data.updated' })
57
+ expect(message.apns.payload.aps['content-available']).toBe(1)
58
+ expect(message.apns.headers['apns-push-type']).toBe('background')
59
+ })
60
+
61
+ it('applies push options (sound/badge/image/priority/channel/body) to the native message', async () => {
62
+ const send = jest.fn().mockResolvedValue('projects/demo/messages/opts')
63
+ setFcmMessagingFactory(() => ({ send }))
64
+
65
+ await getFcmChannelAdapter().sendMessage(
66
+ buildInput({
67
+ content: {
68
+ raw: {
69
+ title: 'Hello',
70
+ body: 'Body text',
71
+ data: {},
72
+ options: {
73
+ sound: 'chime.caf',
74
+ badge: 3,
75
+ image: 'https://cdn/x.png',
76
+ priority: 'normal',
77
+ channelId: 'orders',
78
+ body: 'override body',
79
+ },
80
+ },
81
+ },
82
+ }),
83
+ )
84
+
85
+ const message = send.mock.calls[0][0]
86
+ expect(message.notification).toEqual({ title: 'Hello', body: 'override body', imageUrl: 'https://cdn/x.png' })
87
+ expect(message.android.priority).toBe('normal')
88
+ expect(message.android.notification).toEqual({ sound: 'chime.caf', channelId: 'orders', imageUrl: 'https://cdn/x.png' })
89
+ expect(message.apns.headers['apns-priority']).toBe('5')
90
+ expect(message.apns.payload.aps).toEqual({ sound: 'chime.caf', badge: 3 })
91
+ })
92
+
93
+ it('fails fast when the push token is missing', async () => {
94
+ const result = await getFcmChannelAdapter().sendMessage(buildInput({ metadata: { platform: 'android' } }))
95
+ expect(result.status).toBe('failed')
96
+ expect(result.error).toBe('missing_push_token')
97
+ })
98
+
99
+ it('rejects invalid credentials without attempting a send', async () => {
100
+ const send = jest.fn()
101
+ setFcmMessagingFactory(() => ({ send }))
102
+ const result = await getFcmChannelAdapter().sendMessage(buildInput({ credentials: {} }))
103
+ expect(result.status).toBe('failed')
104
+ expect(result.error).toBe('invalid_fcm_credentials')
105
+ expect(send).not.toHaveBeenCalled()
106
+ })
107
+
108
+ it.each([
109
+ 'messaging/registration-token-not-registered',
110
+ 'messaging/invalid-registration-token',
111
+ ])('maps the permanent token error %s to the uniform device_unregistered sentinel', async (code) => {
112
+ const send = jest.fn().mockRejectedValue(
113
+ Object.assign(new Error('Requested entity was not found.'), { code }),
114
+ )
115
+ setFcmMessagingFactory(() => ({ send }))
116
+
117
+ const result = await getFcmChannelAdapter().sendMessage(buildInput())
118
+
119
+ expect(result.status).toBe('failed')
120
+ expect(result.error).toBe('device_unregistered')
121
+ expect(result.metadata?.unregistered).toBe(true)
122
+ })
123
+
124
+ it('treats messaging/invalid-argument as a transient failure, NOT device_unregistered', async () => {
125
+ // invalid-argument fires for any malformed request field, not just a bad token — mapping it to
126
+ // device_unregistered would let a payload bug soft-delete devices tenant-wide.
127
+ const send = jest.fn().mockRejectedValue(
128
+ Object.assign(new Error('Invalid value at data.foo'), { code: 'messaging/invalid-argument' }),
129
+ )
130
+ setFcmMessagingFactory(() => ({ send }))
131
+
132
+ const result = await getFcmChannelAdapter().sendMessage(buildInput())
133
+
134
+ expect(result.status).toBe('failed')
135
+ expect(result.error).not.toBe('device_unregistered')
136
+ expect(result.metadata?.unregistered).toBeUndefined()
137
+ })
138
+
139
+ it('preserves the real error message when the messaging factory throws (transient init faults must not masquerade as bad credentials)', async () => {
140
+ setFcmMessagingFactory(() => {
141
+ throw new Error('failed to initialize firebase-admin app')
142
+ })
143
+ const result = await getFcmChannelAdapter().sendMessage(buildInput())
144
+ expect(result.status).toBe('failed')
145
+ expect(result.error).toBe('failed to initialize firebase-admin app')
146
+ })
147
+
148
+ it('validateCredentials accepts a well-formed service account', async () => {
149
+ const result = await getFcmChannelAdapter().validateCredentials({ credentials: { serviceAccountJson } })
150
+ expect(result.ok).toBe(true)
151
+ })
152
+
153
+ it('validateCredentials rejects malformed credentials with a populated errors map', async () => {
154
+ const result = await getFcmChannelAdapter().validateCredentials({ credentials: {} })
155
+ expect(result.ok).toBe(false)
156
+ expect(result.errors && Object.keys(result.errors).length).toBeGreaterThan(0)
157
+ })
158
+
159
+ it('treats other errors as transient (retryable) failures', async () => {
160
+ const send = jest.fn().mockRejectedValue(
161
+ Object.assign(new Error('temporarily unavailable'), { code: 'messaging/server-unavailable' }),
162
+ )
163
+ setFcmMessagingFactory(() => ({ send }))
164
+
165
+ const result = await getFcmChannelAdapter().sendMessage(buildInput())
166
+
167
+ expect(result.status).toBe('failed')
168
+ expect(result.error).toBe('temporarily unavailable')
169
+ expect(result.metadata?.unregistered).toBeUndefined()
170
+ })
171
+
172
+ it('convertOutbound passes the body through unchanged', async () => {
173
+ const converted = await getFcmChannelAdapter().convertOutbound({ body: 'Hi', bodyFormat: 'text' })
174
+ expect(converted.content).toEqual({ text: 'Hi', bodyFormat: 'text' })
175
+ })
176
+ })
@@ -0,0 +1,36 @@
1
+ import { fcmCredentialsSchema, parseFcmServiceAccount } from '../credentials'
2
+
3
+ const validJson = JSON.stringify({
4
+ project_id: 'demo-project',
5
+ client_email: 'svc@demo-project.iam.gserviceaccount.com',
6
+ private_key: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n',
7
+ })
8
+
9
+ describe('fcmCredentialsSchema', () => {
10
+ it('accepts a well-formed service account JSON', () => {
11
+ const parsed = fcmCredentialsSchema.safeParse({ serviceAccountJson: validJson })
12
+ expect(parsed.success).toBe(true)
13
+ })
14
+
15
+ it('rejects missing service account JSON', () => {
16
+ const parsed = fcmCredentialsSchema.safeParse({})
17
+ expect(parsed.success).toBe(false)
18
+ })
19
+
20
+ it('rejects malformed JSON', () => {
21
+ const parsed = fcmCredentialsSchema.safeParse({ serviceAccountJson: '{not json' })
22
+ expect(parsed.success).toBe(false)
23
+ })
24
+
25
+ it('rejects JSON missing required service-account fields', () => {
26
+ const parsed = fcmCredentialsSchema.safeParse({ serviceAccountJson: JSON.stringify({ project_id: 'x' }) })
27
+ expect(parsed.success).toBe(false)
28
+ })
29
+
30
+ it('normalizes snake_case service account into camelCase', () => {
31
+ const account = parseFcmServiceAccount({ serviceAccountJson: validJson })
32
+ expect(account.projectId).toBe('demo-project')
33
+ expect(account.clientEmail).toBe('svc@demo-project.iam.gserviceaccount.com')
34
+ expect(account.privateKey).toContain('BEGIN PRIVATE KEY')
35
+ })
36
+ })
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
5
+ import {
6
+ clearFakePushLog,
7
+ findFakePush,
8
+ } from '@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder'
9
+ import { getFcmChannelAdapter, setFcmMessagingFactory } from '../adapter'
10
+ import { ensureFcmFakeProviderInstalled } from '../fake-provider'
11
+
12
+ /**
13
+ * Drives the REAL FCM adapter against the fake SDK client, the same way `di.ts` installs it. Guards the
14
+ * integration specs' assumptions in-process: the token sentinels, the native-message recording, and the
15
+ * `messaging/registration-token-not-registered` → `device_unregistered` mapping.
16
+ */
17
+ const serviceAccountJson = JSON.stringify({
18
+ project_id: 'om-fake-project',
19
+ client_email: 'fake@om-fake-project.iam.gserviceaccount.com',
20
+ private_key: '-----BEGIN PRIVATE KEY-----\nom-fake-key\n-----END PRIVATE KEY-----\n',
21
+ })
22
+
23
+ function buildInput(pushToken: string): SendMessageInput {
24
+ return {
25
+ content: {
26
+ text: 'Body text',
27
+ bodyFormat: 'text',
28
+ raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped' }, options: { badge: 2 } },
29
+ },
30
+ credentials: { serviceAccountJson },
31
+ scope: { tenantId: 't1', organizationId: 'o1' },
32
+ metadata: { pushToken, platform: 'android' },
33
+ }
34
+ }
35
+
36
+ let tempDir: string
37
+
38
+ beforeAll(() => {
39
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-fcm-fake-'))
40
+ process.env.QUEUE_BASE_DIR = tempDir
41
+ process.env.OM_PUSH_FAKE_PROVIDERS = '1'
42
+ ensureFcmFakeProviderInstalled()
43
+ })
44
+
45
+ afterAll(() => {
46
+ setFcmMessagingFactory(null)
47
+ delete process.env.OM_PUSH_FAKE_PROVIDERS
48
+ delete process.env.QUEUE_BASE_DIR
49
+ fs.rmSync(tempDir, { recursive: true, force: true })
50
+ })
51
+
52
+ beforeEach(() => {
53
+ clearFakePushLog()
54
+ })
55
+
56
+ describe('FCM fake provider', () => {
57
+ it('installs nothing when the env flag is unset, so production never reaches the fake', async () => {
58
+ setFcmMessagingFactory(null)
59
+ delete process.env.OM_PUSH_FAKE_PROVIDERS
60
+ ensureFcmFakeProviderInstalled()
61
+
62
+ // Re-enable the flag WITHOUT re-installing, so the recorder is armed. Any recording now could only
63
+ // come from a fake the flag-less `ensure...()` above wrongly installed. The adapter instead falls
64
+ // through to firebase-admin, which cannot initialize with these credentials.
65
+ process.env.OM_PUSH_FAKE_PROVIDERS = '1'
66
+ await getFcmChannelAdapter()
67
+ .sendMessage(buildInput('device-token-INERT001'))
68
+ .catch(() => undefined)
69
+ expect(findFakePush('fcm', 'INERT001')).toBeUndefined()
70
+
71
+ ensureFcmFakeProviderInstalled()
72
+ })
73
+
74
+ it('sends through the real adapter and records the native message', async () => {
75
+ const result = await getFcmChannelAdapter().sendMessage(buildInput('device-token-ABCDEF12'))
76
+ expect(result.status).toBe('sent')
77
+
78
+ const recorded = findFakePush('fcm', 'ABCDEF12')
79
+ expect(recorded).toBeTruthy()
80
+ expect(recorded?.native.token).toBe('device-token-ABCDEF12')
81
+ expect(recorded?.native.notification).toMatchObject({ title: 'Hello', body: 'Body text' })
82
+ expect(recorded?.native.apns).toMatchObject({ payload: { aps: { badge: 2 } } })
83
+ })
84
+
85
+ it('maps an unregistered token to the device_unregistered sentinel', async () => {
86
+ const result = await getFcmChannelAdapter().sendMessage(buildInput('device-unregistered-token-99'))
87
+ expect(result.status).toBe('failed')
88
+ expect(result.error).toBe('device_unregistered')
89
+ })
90
+
91
+ it('treats a fail token as a retryable error, not a token verdict', async () => {
92
+ const result = await getFcmChannelAdapter().sendMessage(buildInput('device-fail-token-77'))
93
+ expect(result.status).toBe('failed')
94
+ expect(result.error).not.toBe('device_unregistered')
95
+ })
96
+ })