@goliapkg/sentori-react-native 2.2.0 → 3.0.0

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.
@@ -0,0 +1,178 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+
3
+ // Mock the native bridge layer only — leaves expo-modules-core / react-native
4
+ // pristine so other test files in the same bun process aren't affected.
5
+
6
+ type DrainResult = {
7
+ token?: string
8
+ error?: string
9
+ notifications: Array<Record<string, unknown>>
10
+ taps: Array<Record<string, unknown>>
11
+ }
12
+
13
+ let _drainQueue: DrainResult[] = []
14
+ let _permissionAnswer: null | string = null
15
+ let _registerInvocations = 0
16
+ let _unregisterInvocations = 0
17
+
18
+ mock.module('../native', () => ({
19
+ pushGetStatus: async () => _permissionAnswer,
20
+ pushRequestPermission: async () => _permissionAnswer ?? 'notDetermined',
21
+ pushRegister: () => {
22
+ _registerInvocations++
23
+ },
24
+ pushUnregister: () => {
25
+ _unregisterInvocations++
26
+ },
27
+ pushDrainState: async () => {
28
+ if (_drainQueue.length === 0) {
29
+ return { notifications: [], taps: [] }
30
+ }
31
+ return _drainQueue.shift()!
32
+ },
33
+ }))
34
+
35
+ // Mock the config getter — push reads ingest URL + bearer from here.
36
+ // Stub ALL public exports so other test files that import named
37
+ // exports from '../config' don't get a "Export named X not found"
38
+ // error from the partial mock (bun mock.module replaces the whole
39
+ // module surface, not just the named exports listed).
40
+ let _mockConfig: { ingestUrl: string; token: string } | null = null
41
+ mock.module('../config', () => ({
42
+ getConfig: () => _mockConfig,
43
+ isInitialized: () => _mockConfig !== null,
44
+ setConfig: (c: typeof _mockConfig) => {
45
+ _mockConfig = c
46
+ },
47
+ __resetForTests: () => {
48
+ _mockConfig = null
49
+ },
50
+ }))
51
+
52
+ import { register, unregister, getCachedIpt, __setPlatformForTests } from '../push'
53
+
54
+ type FetchCall = { url: string; method: string; body?: string }
55
+ let _fetchCalls: FetchCall[] = []
56
+ let _fetchResponse: { status: number; body: unknown } = {
57
+ status: 200,
58
+ body: { id: 'ipt_deadbeef' },
59
+ }
60
+
61
+ const originalFetch = globalThis.fetch
62
+ beforeEach(() => {
63
+ _fetchCalls = []
64
+ _mockConfig = { ingestUrl: 'https://ingest.test', token: 'st_test' }
65
+ _drainQueue = []
66
+ _permissionAnswer = null
67
+ _registerInvocations = 0
68
+ _unregisterInvocations = 0
69
+ _fetchResponse = { status: 200, body: { id: 'ipt_deadbeef' } }
70
+ globalThis.fetch = (async (
71
+ input: RequestInfo | URL,
72
+ init?: RequestInit,
73
+ ): Promise<Response> => {
74
+ _fetchCalls.push({
75
+ url: typeof input === 'string' ? input : input.toString(),
76
+ method: init?.method ?? 'GET',
77
+ body: typeof init?.body === 'string' ? init.body : undefined,
78
+ })
79
+ return new Response(JSON.stringify(_fetchResponse.body), {
80
+ status: _fetchResponse.status,
81
+ })
82
+ }) as typeof fetch
83
+ })
84
+
85
+ afterEach(() => {
86
+ globalThis.fetch = originalFetch
87
+ })
88
+
89
+ describe('push.register', () => {
90
+ it('rejects cleanly when permission is denied', async () => {
91
+ _permissionAnswer = 'denied'
92
+ await expect(register()).rejects.toThrow(/permission/i)
93
+ expect(_fetchCalls).toHaveLength(0)
94
+ })
95
+
96
+ it('rejects when the native token times out', async () => {
97
+ _permissionAnswer = 'granted'
98
+ await expect(register({ tokenTimeoutMs: 50 })).rejects.toThrow(/not received/i)
99
+ expect(_fetchCalls).toHaveLength(0)
100
+ })
101
+
102
+ it('POSTs to /v1/push/tokens with the APNs hex token and resolves to ipt', async () => {
103
+ _permissionAnswer = 'granted'
104
+ _drainQueue = [
105
+ { notifications: [], taps: [] },
106
+ { token: '0123abcdef', notifications: [], taps: [] },
107
+ ]
108
+ const result = await register({ linkHash: 'h1' })
109
+ expect(result.ipt).toBe('ipt_deadbeef')
110
+ expect(getCachedIpt()).toBe('ipt_deadbeef')
111
+ expect(_fetchCalls).toHaveLength(1)
112
+ expect(_fetchCalls[0]?.url).toContain('/v1/push/tokens')
113
+ expect(_fetchCalls[0]?.method).toBe('POST')
114
+ const body = JSON.parse(_fetchCalls[0]?.body ?? '{}')
115
+ expect(body.provider).toBe('apns')
116
+ expect(body.nativeToken).toBe('0123abcdef')
117
+ expect(body.linkHash).toBe('h1')
118
+ expect(_registerInvocations).toBe(1)
119
+ })
120
+
121
+ it('surfaces server failures', async () => {
122
+ _permissionAnswer = 'granted'
123
+ _drainQueue = [{ token: '00ff', notifications: [], taps: [] }]
124
+ _fetchResponse = { status: 503, body: { error: 'dbNotConfigured' } }
125
+ await expect(register()).rejects.toThrow(/503/)
126
+ })
127
+
128
+ it('fires onMessage for buffered notifications surfaced during waitForToken', async () => {
129
+ _permissionAnswer = 'granted'
130
+ _drainQueue = [
131
+ {
132
+ notifications: [
133
+ { id: 'n1', title: 'Hi', body: 'hello', userInfo: { x: 1 } },
134
+ ],
135
+ taps: [],
136
+ },
137
+ { token: 'abcd', notifications: [], taps: [] },
138
+ ]
139
+ const seen: Array<{ title?: string; body?: string }> = []
140
+ await register({ onMessage: (m) => seen.push(m) })
141
+ expect(seen).toHaveLength(1)
142
+ expect(seen[0]?.title).toBe('Hi')
143
+ expect(seen[0]?.body).toBe('hello')
144
+ })
145
+ })
146
+
147
+ describe('push.register — Android (FCM) branch', () => {
148
+ beforeEach(() => __setPlatformForTests('android'))
149
+ afterEach(() => __setPlatformForTests(null))
150
+
151
+ it('POSTs with provider:"fcm" and omits env on Android', async () => {
152
+ _permissionAnswer = 'granted'
153
+ _drainQueue = [{ token: 'fcm-reg-token', notifications: [], taps: [] }]
154
+ await register()
155
+ expect(_fetchCalls).toHaveLength(1)
156
+ const body = JSON.parse(_fetchCalls[0]?.body ?? '{}') as Record<string, unknown>
157
+ expect(body.provider).toBe('fcm')
158
+ expect(body.nativeToken).toBe('fcm-reg-token')
159
+ expect('env' in body).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe('push.unregister', () => {
164
+ it('DELETEs the cached ipt and clears the local state', async () => {
165
+ _permissionAnswer = 'granted'
166
+ _drainQueue = [{ token: 'feedface', notifications: [], taps: [] }]
167
+ await register()
168
+ expect(getCachedIpt()).toBe('ipt_deadbeef')
169
+ _fetchCalls = []
170
+
171
+ await unregister()
172
+ expect(_fetchCalls).toHaveLength(1)
173
+ expect(_fetchCalls[0]?.url).toContain('/v1/push/tokens/ipt_deadbeef')
174
+ expect(_fetchCalls[0]?.method).toBe('DELETE')
175
+ expect(getCachedIpt()).toBeNull()
176
+ expect(_unregisterInvocations).toBeGreaterThan(0)
177
+ })
178
+ })