@open-mercato/channel-expo 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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +26 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js +56 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js +65 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js.map +7 -0
- package/dist/modules/channel_expo/acl.js +10 -0
- package/dist/modules/channel_expo/acl.js.map +7 -0
- package/dist/modules/channel_expo/di.js +24 -0
- package/dist/modules/channel_expo/di.js.map +7 -0
- package/dist/modules/channel_expo/index.js +9 -0
- package/dist/modules/channel_expo/index.js.map +7 -0
- package/dist/modules/channel_expo/integration.js +47 -0
- package/dist/modules/channel_expo/integration.js.map +7 -0
- package/dist/modules/channel_expo/lib/adapter.js +154 -0
- package/dist/modules/channel_expo/lib/adapter.js.map +7 -0
- package/dist/modules/channel_expo/lib/credentials.js +8 -0
- package/dist/modules/channel_expo/lib/credentials.js.map +7 -0
- package/dist/modules/channel_expo/lib/fake-provider.js +40 -0
- package/dist/modules/channel_expo/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_expo/lib/health.js +10 -0
- package/dist/modules/channel_expo/lib/health.js.map +7 -0
- package/dist/modules/channel_expo/setup.js +25 -0
- package/dist/modules/channel_expo/setup.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js +157 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection-table.js +15 -0
- package/dist/modules/channel_expo/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +96 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.ts +76 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.ts +3 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.ts +83 -0
- package/src/modules/channel_expo/acl.ts +6 -0
- package/src/modules/channel_expo/di.ts +26 -0
- package/src/modules/channel_expo/index.ts +6 -0
- package/src/modules/channel_expo/integration.ts +48 -0
- package/src/modules/channel_expo/lib/__tests__/adapter-instance-cache.test.ts +65 -0
- package/src/modules/channel_expo/lib/__tests__/adapter.test.ts +211 -0
- package/src/modules/channel_expo/lib/__tests__/credentials.test.ts +18 -0
- package/src/modules/channel_expo/lib/__tests__/fake-provider.test.ts +94 -0
- package/src/modules/channel_expo/lib/__tests__/message-golden.test.ts +77 -0
- package/src/modules/channel_expo/lib/adapter.ts +256 -0
- package/src/modules/channel_expo/lib/credentials.ts +14 -0
- package/src/modules/channel_expo/lib/fake-provider.ts +64 -0
- package/src/modules/channel_expo/lib/health.ts +13 -0
- package/src/modules/channel_expo/setup.ts +31 -0
- package/src/modules/channel_expo/widgets/injection/connect/widget.client.tsx +193 -0
- package/src/modules/channel_expo/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_expo/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
id: 'channel_expo',
|
|
3
|
+
title: 'Expo Push Channel',
|
|
4
|
+
description:
|
|
5
|
+
'Deliver mobile push to Expo apps via the Expo push service. Registers a `push` ChannelAdapter (providerKey `expo`) into the Communications Hub; consumed by the push_notifications delivery strategy.',
|
|
6
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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_expo',
|
|
8
|
+
title: 'Expo Push',
|
|
9
|
+
description:
|
|
10
|
+
'Deliver mobile push to Expo apps via the Expo push service (ExponentPushToken). Provider for the push_notifications channel.',
|
|
11
|
+
category: 'communication',
|
|
12
|
+
hub: 'communication_channels',
|
|
13
|
+
providerKey: 'expo',
|
|
14
|
+
icon: 'expo',
|
|
15
|
+
docsUrl: 'https://docs.expo.dev/push-notifications/sending-notifications/',
|
|
16
|
+
package: '@open-mercato/channel-expo',
|
|
17
|
+
version: '0.1.0',
|
|
18
|
+
author: 'Open Mercato Team',
|
|
19
|
+
company: 'Open Mercato',
|
|
20
|
+
license: 'MIT',
|
|
21
|
+
tags: ['push', 'expo', 'react-native', 'communication'],
|
|
22
|
+
apiVersions: [
|
|
23
|
+
{
|
|
24
|
+
id: 'v1',
|
|
25
|
+
label: 'Expo Push API',
|
|
26
|
+
status: 'stable',
|
|
27
|
+
default: true,
|
|
28
|
+
changelog: 'Expo push service via expo-server-sdk (ExponentPushToken delivery).',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
credentials: {
|
|
32
|
+
fields: [
|
|
33
|
+
{
|
|
34
|
+
key: 'accessToken',
|
|
35
|
+
label: 'Expo Access Token (optional)',
|
|
36
|
+
type: 'secret',
|
|
37
|
+
required: false,
|
|
38
|
+
helpText:
|
|
39
|
+
'Only required when Expo enhanced push security is enabled for the project. Expo dashboard -> Account -> Access Tokens. Stored encrypted at rest.',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
healthCheck: { service: 'channelExpoHealthCheck' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const integrations: IntegrationDefinition[] = [integration]
|
|
47
|
+
export const bundles: IntegrationBundle[] = []
|
|
48
|
+
export const bundle: IntegrationBundle | undefined = undefined
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getExpoChannelAdapter } from '../adapter'
|
|
2
|
+
import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
3
|
+
|
|
4
|
+
// Drive the REAL default client factory (no setExpoClientFactory override) so the module-level
|
|
5
|
+
// instance cache in getExpoInstance is exercised. Mock the SDK so each `new Expo(...)` is countable.
|
|
6
|
+
let constructionCount = 0
|
|
7
|
+
|
|
8
|
+
jest.mock('expo-server-sdk', () => {
|
|
9
|
+
class FakeExpo {
|
|
10
|
+
constructor(_options: { accessToken?: string }) {
|
|
11
|
+
constructionCount += 1
|
|
12
|
+
}
|
|
13
|
+
static isExpoPushToken(): boolean {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
async sendPushNotificationsAsync(): Promise<Array<{ status: string; id: string }>> {
|
|
17
|
+
return [{ status: 'ok', id: 'ticket' }]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { Expo: FakeExpo }
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function buildInputForToken(accessToken: string): SendMessageInput {
|
|
24
|
+
return {
|
|
25
|
+
content: {
|
|
26
|
+
text: 'Body text',
|
|
27
|
+
bodyFormat: 'text',
|
|
28
|
+
raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped', notificationId: 'n1' } },
|
|
29
|
+
},
|
|
30
|
+
credentials: { accessToken },
|
|
31
|
+
scope: { tenantId: 't1', organizationId: 'o1' },
|
|
32
|
+
metadata: { pushToken: 'ExponentPushToken[abc]', platform: 'ios' },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('ExpoChannelAdapter default instance cache', () => {
|
|
37
|
+
const INSTANCE_CACHE_MAX = 32
|
|
38
|
+
|
|
39
|
+
it('bounds the cache with an LRU: reuses cached instances, evicts the oldest past the cap', async () => {
|
|
40
|
+
const adapter = getExpoChannelAdapter()
|
|
41
|
+
|
|
42
|
+
// Fill the cache to its cap with distinct access tokens (distinct cache key → distinct instance).
|
|
43
|
+
for (let index = 0; index < INSTANCE_CACHE_MAX; index += 1) {
|
|
44
|
+
const result = await adapter.sendMessage(buildInputForToken(`token-${index}`))
|
|
45
|
+
expect(result.status).toBe('sent')
|
|
46
|
+
}
|
|
47
|
+
expect(constructionCount).toBe(INSTANCE_CACHE_MAX)
|
|
48
|
+
|
|
49
|
+
// Re-sending with a token already cached reuses its instance — no new construction.
|
|
50
|
+
await adapter.sendMessage(buildInputForToken('token-5'))
|
|
51
|
+
expect(constructionCount).toBe(INSTANCE_CACHE_MAX)
|
|
52
|
+
|
|
53
|
+
// The (cap + 1)-th distinct token exceeds the bound and evicts the oldest entry (token-0).
|
|
54
|
+
await adapter.sendMessage(buildInputForToken('token-overflow'))
|
|
55
|
+
expect(constructionCount).toBe(INSTANCE_CACHE_MAX + 1)
|
|
56
|
+
|
|
57
|
+
// token-0 was evicted, so using it again re-constructs a fresh instance...
|
|
58
|
+
await adapter.sendMessage(buildInputForToken('token-0'))
|
|
59
|
+
expect(constructionCount).toBe(INSTANCE_CACHE_MAX + 2)
|
|
60
|
+
|
|
61
|
+
// ...while a token still resident in the cache is served without re-construction.
|
|
62
|
+
await adapter.sendMessage(buildInputForToken('token-overflow'))
|
|
63
|
+
expect(constructionCount).toBe(INSTANCE_CACHE_MAX + 2)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getExpoChannelAdapter,
|
|
3
|
+
setExpoClientFactory,
|
|
4
|
+
type ExpoClientLike,
|
|
5
|
+
type ExpoPushReceipt,
|
|
6
|
+
type ExpoPushTicket,
|
|
7
|
+
} from '../adapter'
|
|
8
|
+
import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
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: {},
|
|
18
|
+
scope: { tenantId: 't1', organizationId: 'o1' },
|
|
19
|
+
metadata: { pushToken: 'ExponentPushToken[abc]', platform: 'ios' },
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildClient(ticket: ExpoPushTicket, validToken = true): { client: ExpoClientLike; send: jest.Mock } {
|
|
25
|
+
const send = jest.fn(async () => [ticket])
|
|
26
|
+
const client: ExpoClientLike = { isExpoPushToken: () => validToken, send, getReceipts: async () => ({}) }
|
|
27
|
+
return { client, send }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildReceiptClient(receipts: Record<string, ExpoPushReceipt>): {
|
|
31
|
+
client: ExpoClientLike
|
|
32
|
+
getReceipts: jest.Mock
|
|
33
|
+
} {
|
|
34
|
+
const getReceipts = jest.fn(async (ticketIds: string[]) => {
|
|
35
|
+
const out: Record<string, ExpoPushReceipt> = {}
|
|
36
|
+
for (const id of ticketIds) if (receipts[id]) out[id] = receipts[id]
|
|
37
|
+
return out
|
|
38
|
+
})
|
|
39
|
+
const client: ExpoClientLike = { isExpoPushToken: () => true, send: jest.fn(async () => []), getReceipts }
|
|
40
|
+
return { client, getReceipts }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => setExpoClientFactory(null))
|
|
44
|
+
|
|
45
|
+
describe('ExpoChannelAdapter', () => {
|
|
46
|
+
it('sends a push and returns the ticket id', async () => {
|
|
47
|
+
const { client, send } = buildClient({ status: 'ok', id: 'ticket-1' })
|
|
48
|
+
setExpoClientFactory(() => client)
|
|
49
|
+
|
|
50
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput())
|
|
51
|
+
|
|
52
|
+
expect(result.status).toBe('sent')
|
|
53
|
+
expect(result.externalMessageId).toBe('ticket-1')
|
|
54
|
+
const messages = send.mock.calls[0][0]
|
|
55
|
+
expect(messages[0]).toMatchObject({
|
|
56
|
+
to: 'ExponentPushToken[abc]',
|
|
57
|
+
title: 'Hello',
|
|
58
|
+
body: 'Body text',
|
|
59
|
+
sound: 'default',
|
|
60
|
+
})
|
|
61
|
+
expect(messages[0].data).toEqual({ type: 'orders.shipped', notificationId: 'n1' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('sends a data-only content-available message when silent', async () => {
|
|
65
|
+
const { client, send } = buildClient({ status: 'ok', id: 'ticket-silent' })
|
|
66
|
+
setExpoClientFactory(() => client)
|
|
67
|
+
|
|
68
|
+
const result = await getExpoChannelAdapter().sendMessage(
|
|
69
|
+
buildInput({
|
|
70
|
+
content: { raw: { title: '', body: '', data: { type: 'sync.data.updated' }, silent: true } },
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(result.status).toBe('sent')
|
|
75
|
+
const message = send.mock.calls[0][0][0]
|
|
76
|
+
expect(message).toEqual({ to: 'ExponentPushToken[abc]', data: { type: 'sync.data.updated' }, _contentAvailable: true })
|
|
77
|
+
expect(message.title).toBeUndefined()
|
|
78
|
+
expect(message.body).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('applies push options (sound/badge/priority/channel/image/body)', async () => {
|
|
82
|
+
const { client, send } = buildClient({ status: 'ok', id: 'ticket-opts' })
|
|
83
|
+
setExpoClientFactory(() => client)
|
|
84
|
+
|
|
85
|
+
await getExpoChannelAdapter().sendMessage(
|
|
86
|
+
buildInput({
|
|
87
|
+
content: {
|
|
88
|
+
raw: {
|
|
89
|
+
title: 'Hello',
|
|
90
|
+
body: 'Body text',
|
|
91
|
+
data: {},
|
|
92
|
+
options: {
|
|
93
|
+
sound: 'chime.caf',
|
|
94
|
+
badge: 5,
|
|
95
|
+
priority: 'high',
|
|
96
|
+
channelId: 'orders',
|
|
97
|
+
image: 'https://cdn/x.png',
|
|
98
|
+
body: 'override body',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
expect(send.mock.calls[0][0][0]).toMatchObject({
|
|
106
|
+
to: 'ExponentPushToken[abc]',
|
|
107
|
+
title: 'Hello',
|
|
108
|
+
body: 'override body',
|
|
109
|
+
sound: 'chime.caf',
|
|
110
|
+
badge: 5,
|
|
111
|
+
priority: 'high',
|
|
112
|
+
channelId: 'orders',
|
|
113
|
+
richContent: { image: 'https://cdn/x.png' },
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('fails fast when the push token is missing', async () => {
|
|
118
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput({ metadata: { platform: 'ios' } }))
|
|
119
|
+
expect(result.status).toBe('failed')
|
|
120
|
+
expect(result.error).toBe('missing_push_token')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('maps a malformed Expo token to the device_unregistered sentinel', async () => {
|
|
124
|
+
const { client, send } = buildClient({ status: 'ok' }, false)
|
|
125
|
+
setExpoClientFactory(() => client)
|
|
126
|
+
|
|
127
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput())
|
|
128
|
+
|
|
129
|
+
expect(result.status).toBe('failed')
|
|
130
|
+
expect(result.error).toBe('device_unregistered')
|
|
131
|
+
expect(result.metadata?.unregistered).toBe(true)
|
|
132
|
+
expect(send).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('maps a DeviceNotRegistered ticket to the device_unregistered sentinel', async () => {
|
|
136
|
+
const { client } = buildClient({ status: 'error', message: 'gone', details: { error: 'DeviceNotRegistered' } })
|
|
137
|
+
setExpoClientFactory(() => client)
|
|
138
|
+
|
|
139
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput())
|
|
140
|
+
|
|
141
|
+
expect(result.status).toBe('failed')
|
|
142
|
+
expect(result.error).toBe('device_unregistered')
|
|
143
|
+
expect(result.metadata?.unregistered).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('treats other ticket errors as transient failures', async () => {
|
|
147
|
+
const { client } = buildClient({ status: 'error', message: 'MessageRateExceeded', details: { error: 'MessageRateExceeded' } })
|
|
148
|
+
setExpoClientFactory(() => client)
|
|
149
|
+
|
|
150
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput())
|
|
151
|
+
|
|
152
|
+
expect(result.status).toBe('failed')
|
|
153
|
+
expect(result.error).toBe('MessageRateExceeded')
|
|
154
|
+
expect(result.metadata?.unregistered).toBeUndefined()
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('ExpoChannelAdapter.checkReceipts', () => {
|
|
159
|
+
it('flags a DeviceNotRegistered receipt as unregistered (the async "app uninstalled" case)', async () => {
|
|
160
|
+
const { client, getReceipts } = buildReceiptClient({
|
|
161
|
+
'ticket-dead': { status: 'error', message: 'gone', details: { error: 'DeviceNotRegistered' } },
|
|
162
|
+
})
|
|
163
|
+
setExpoClientFactory(() => client)
|
|
164
|
+
|
|
165
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts(['ticket-dead'], {})
|
|
166
|
+
|
|
167
|
+
expect(getReceipts).toHaveBeenCalledWith(['ticket-dead'])
|
|
168
|
+
expect(outcomes).toEqual([{ ticketId: 'ticket-dead', unregistered: true }])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('resolves an ok receipt without killing the token', async () => {
|
|
172
|
+
const { client } = buildReceiptClient({ 'ticket-ok': { status: 'ok' } })
|
|
173
|
+
setExpoClientFactory(() => client)
|
|
174
|
+
|
|
175
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts(['ticket-ok'], {})
|
|
176
|
+
|
|
177
|
+
expect(outcomes).toEqual([{ ticketId: 'ticket-ok', unregistered: false }])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('treats MessageRateExceeded as transient (receipt resolved, token kept)', async () => {
|
|
181
|
+
const { client } = buildReceiptClient({
|
|
182
|
+
'ticket-rate': { status: 'error', details: { error: 'MessageRateExceeded' } },
|
|
183
|
+
})
|
|
184
|
+
setExpoClientFactory(() => client)
|
|
185
|
+
|
|
186
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts(['ticket-rate'], {})
|
|
187
|
+
|
|
188
|
+
expect(outcomes).toEqual([{ ticketId: 'ticket-rate', unregistered: false }])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('omits tickets whose receipt is not ready yet (re-checked on a later sweep)', async () => {
|
|
192
|
+
const { client } = buildReceiptClient({
|
|
193
|
+
'ticket-ready': { status: 'ok' },
|
|
194
|
+
})
|
|
195
|
+
setExpoClientFactory(() => client)
|
|
196
|
+
|
|
197
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts(['ticket-ready', 'ticket-pending'], {})
|
|
198
|
+
|
|
199
|
+
expect(outcomes).toEqual([{ ticketId: 'ticket-ready', unregistered: false }])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('short-circuits with no network call when there are no usable ticket ids', async () => {
|
|
203
|
+
const { client, getReceipts } = buildReceiptClient({})
|
|
204
|
+
setExpoClientFactory(() => client)
|
|
205
|
+
|
|
206
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts(['', ''], {})
|
|
207
|
+
|
|
208
|
+
expect(outcomes).toEqual([])
|
|
209
|
+
expect(getReceipts).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expoCredentialsSchema } from '../credentials'
|
|
2
|
+
|
|
3
|
+
describe('expoCredentialsSchema', () => {
|
|
4
|
+
it('accepts credentials with an access token', () => {
|
|
5
|
+
const parsed = expoCredentialsSchema.safeParse({ accessToken: 'expo-access-token' })
|
|
6
|
+
expect(parsed.success).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('accepts credentials without the optional access token', () => {
|
|
10
|
+
const parsed = expoCredentialsSchema.safeParse({})
|
|
11
|
+
expect(parsed.success).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('rejects malformed input', () => {
|
|
15
|
+
expect(expoCredentialsSchema.safeParse({ accessToken: 123 }).success).toBe(false)
|
|
16
|
+
expect(expoCredentialsSchema.safeParse('not-an-object').success).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { getExpoChannelAdapter, setExpoClientFactory } from '../adapter'
|
|
10
|
+
import { ensureExpoFakeProviderInstalled } from '../fake-provider'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Drives the REAL Expo adapter against the fake client, the same way `di.ts` installs it.
|
|
14
|
+
*
|
|
15
|
+
* Expo is the only two-phase provider: an `unregistered` token is ACCEPTED at send time and only
|
|
16
|
+
* reported dead later, by the receipt. That asymmetry is what TC-PUSH-008 exercises end-to-end, and what
|
|
17
|
+
* the ticket-id encoding below makes work across processes.
|
|
18
|
+
*/
|
|
19
|
+
function buildInput(pushToken: string): SendMessageInput {
|
|
20
|
+
return {
|
|
21
|
+
content: {
|
|
22
|
+
text: 'Body text',
|
|
23
|
+
bodyFormat: 'text',
|
|
24
|
+
raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped' }, options: { sound: 'chime.caf' } },
|
|
25
|
+
},
|
|
26
|
+
credentials: { accessToken: 'om-fake-expo-token' },
|
|
27
|
+
scope: { tenantId: 't1', organizationId: 'o1' },
|
|
28
|
+
metadata: { pushToken, platform: 'android' },
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let tempDir: string
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-expo-fake-'))
|
|
36
|
+
process.env.QUEUE_BASE_DIR = tempDir
|
|
37
|
+
process.env.OM_PUSH_FAKE_PROVIDERS = '1'
|
|
38
|
+
ensureExpoFakeProviderInstalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
setExpoClientFactory(null)
|
|
43
|
+
delete process.env.OM_PUSH_FAKE_PROVIDERS
|
|
44
|
+
delete process.env.QUEUE_BASE_DIR
|
|
45
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
clearFakePushLog()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('Expo fake provider', () => {
|
|
53
|
+
it('sends through the real adapter and records the native message', async () => {
|
|
54
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput('expo-token-ABCDEF12'))
|
|
55
|
+
expect(result.status).toBe('sent')
|
|
56
|
+
expect(result.externalMessageId).toBeTruthy()
|
|
57
|
+
|
|
58
|
+
const recorded = findFakePush('expo', 'ABCDEF12')
|
|
59
|
+
expect(recorded?.native).toMatchObject({
|
|
60
|
+
to: 'expo-token-ABCDEF12',
|
|
61
|
+
title: 'Hello',
|
|
62
|
+
body: 'Body text',
|
|
63
|
+
sound: 'chime.caf',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('accepts an unregistered token at send time — Expo only reports it in the receipt', async () => {
|
|
68
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput('expo-unregistered-token-99'))
|
|
69
|
+
expect(result.status).toBe('sent')
|
|
70
|
+
expect(result.externalMessageId).toBeTruthy()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('reports DeviceNotRegistered for that ticket when the receipt is polled', async () => {
|
|
74
|
+
const sent = await getExpoChannelAdapter().sendMessage(buildInput('expo-unregistered-token-99'))
|
|
75
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts!([sent.externalMessageId], {
|
|
76
|
+
accessToken: 'om-fake-expo-token',
|
|
77
|
+
})
|
|
78
|
+
expect(outcomes).toEqual([{ ticketId: sent.externalMessageId, unregistered: true }])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('reports a healthy ticket as still registered', async () => {
|
|
82
|
+
const sent = await getExpoChannelAdapter().sendMessage(buildInput('expo-token-ABCDEF12'))
|
|
83
|
+
const outcomes = await getExpoChannelAdapter().checkReceipts!([sent.externalMessageId], {
|
|
84
|
+
accessToken: 'om-fake-expo-token',
|
|
85
|
+
})
|
|
86
|
+
expect(outcomes).toEqual([{ ticketId: sent.externalMessageId, unregistered: false }])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('treats a fail token as a retryable error, not a token verdict', async () => {
|
|
90
|
+
const result = await getExpoChannelAdapter().sendMessage(buildInput('expo-fail-token-77'))
|
|
91
|
+
expect(result.status).toBe('failed')
|
|
92
|
+
expect(result.error).not.toBe('device_unregistered')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
|
|
2
|
+
import { buildExpoMessage } from '../adapter'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Golden assertions pinning the Expo push message we build against Expo's published reference.
|
|
6
|
+
*
|
|
7
|
+
* These are the only tests that catch serialization drift in *our* builder: the integration fakes
|
|
8
|
+
* replace the SDK client, so nothing else ever validates the message body. Drift in *Expo's* schema is
|
|
9
|
+
* out of reach of any fake and stays a manual live-key check.
|
|
10
|
+
*
|
|
11
|
+
* Reference: https://docs.expo.dev/push-notifications/sending-notifications/#message-request-format
|
|
12
|
+
* `_contentAvailable` (data-only iOS background wake-up):
|
|
13
|
+
* https://docs.expo.dev/push-notifications/sending-notifications/#formats
|
|
14
|
+
*
|
|
15
|
+
* Written as exact `toEqual` fixtures rather than snapshots: a snapshot would re-record drift on
|
|
16
|
+
* `--updateSnapshot` instead of failing.
|
|
17
|
+
*/
|
|
18
|
+
const TOKEN = 'ExponentPushToken[abcdef12]'
|
|
19
|
+
|
|
20
|
+
function envelope(overrides: Partial<PushEnvelope> = {}): PushEnvelope {
|
|
21
|
+
return {
|
|
22
|
+
title: 'Order shipped',
|
|
23
|
+
body: 'Your order #42 is on its way',
|
|
24
|
+
data: { type: 'orders.shipped', notificationId: 'n1' },
|
|
25
|
+
options: {},
|
|
26
|
+
silent: false,
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('buildExpoMessage — golden payloads', () => {
|
|
32
|
+
it('visible notification matches the reference shape', () => {
|
|
33
|
+
expect(buildExpoMessage(TOKEN, envelope())).toEqual({
|
|
34
|
+
to: TOKEN,
|
|
35
|
+
title: 'Order shipped',
|
|
36
|
+
body: 'Your order #42 is on its way',
|
|
37
|
+
data: { type: 'orders.shipped', notificationId: 'n1' },
|
|
38
|
+
sound: 'default',
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('silent notification is data-only with _contentAvailable and no user-facing copy', () => {
|
|
43
|
+
expect(buildExpoMessage(TOKEN, envelope({ silent: true }))).toEqual({
|
|
44
|
+
to: TOKEN,
|
|
45
|
+
data: { type: 'orders.shipped', notificationId: 'n1' },
|
|
46
|
+
_contentAvailable: true,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('full pushOptions map onto the message', () => {
|
|
51
|
+
expect(
|
|
52
|
+
buildExpoMessage(
|
|
53
|
+
TOKEN,
|
|
54
|
+
envelope({
|
|
55
|
+
options: {
|
|
56
|
+
sound: 'chime.caf',
|
|
57
|
+
badge: 7,
|
|
58
|
+
image: 'https://cdn.example.com/hero.png',
|
|
59
|
+
priority: 'high',
|
|
60
|
+
channelId: 'orders',
|
|
61
|
+
body: 'Overridden push body',
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
).toEqual({
|
|
66
|
+
to: TOKEN,
|
|
67
|
+
title: 'Order shipped',
|
|
68
|
+
body: 'Overridden push body',
|
|
69
|
+
data: { type: 'orders.shipped', notificationId: 'n1' },
|
|
70
|
+
sound: 'chime.caf',
|
|
71
|
+
badge: 7,
|
|
72
|
+
priority: 'high',
|
|
73
|
+
channelId: 'orders',
|
|
74
|
+
richContent: { image: 'https://cdn.example.com/hero.png' },
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
})
|