@flowerforce/flowerbase-client 0.1.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/LICENSE +3 -0
  3. package/README.md +209 -0
  4. package/dist/app.d.ts +85 -0
  5. package/dist/app.d.ts.map +1 -0
  6. package/dist/app.js +461 -0
  7. package/dist/bson.d.ts +8 -0
  8. package/dist/bson.d.ts.map +1 -0
  9. package/dist/bson.js +10 -0
  10. package/dist/credentials.d.ts +8 -0
  11. package/dist/credentials.d.ts.map +1 -0
  12. package/dist/credentials.js +30 -0
  13. package/dist/functions.d.ts +6 -0
  14. package/dist/functions.d.ts.map +1 -0
  15. package/dist/functions.js +47 -0
  16. package/dist/http.d.ts +35 -0
  17. package/dist/http.d.ts.map +1 -0
  18. package/dist/http.js +170 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +16 -0
  22. package/dist/mongo.d.ts +4 -0
  23. package/dist/mongo.d.ts.map +1 -0
  24. package/dist/mongo.js +106 -0
  25. package/dist/session.d.ts +18 -0
  26. package/dist/session.d.ts.map +1 -0
  27. package/dist/session.js +105 -0
  28. package/dist/session.native.d.ts +14 -0
  29. package/dist/session.native.d.ts.map +1 -0
  30. package/dist/session.native.js +76 -0
  31. package/dist/types.d.ts +97 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +2 -0
  34. package/dist/user.d.ts +37 -0
  35. package/dist/user.d.ts.map +1 -0
  36. package/dist/user.js +125 -0
  37. package/dist/watch.d.ts +3 -0
  38. package/dist/watch.d.ts.map +1 -0
  39. package/dist/watch.js +139 -0
  40. package/jest.config.ts +13 -0
  41. package/package.json +41 -0
  42. package/project.json +11 -0
  43. package/rollup.config.js +17 -0
  44. package/src/__tests__/auth.test.ts +213 -0
  45. package/src/__tests__/compat.test.ts +22 -0
  46. package/src/__tests__/functions.test.ts +312 -0
  47. package/src/__tests__/mongo.test.ts +83 -0
  48. package/src/__tests__/session.test.ts +597 -0
  49. package/src/__tests__/watch.test.ts +336 -0
  50. package/src/app.ts +562 -0
  51. package/src/bson.ts +6 -0
  52. package/src/credentials.ts +31 -0
  53. package/src/functions.ts +56 -0
  54. package/src/http.ts +221 -0
  55. package/src/index.ts +15 -0
  56. package/src/mongo.ts +112 -0
  57. package/src/session.native.ts +89 -0
  58. package/src/session.ts +114 -0
  59. package/src/types.ts +114 -0
  60. package/src/user.ts +150 -0
  61. package/src/watch.ts +156 -0
  62. package/tsconfig.json +34 -0
  63. package/tsconfig.spec.json +13 -0
@@ -0,0 +1,597 @@
1
+ import { App } from '../app'
2
+ import { Credentials } from '../credentials'
3
+ import { MongoDBRealmError } from '../http'
4
+
5
+ const encodeBase64Url = (value: string) =>
6
+ Buffer.from(value, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
7
+
8
+ const buildJwt = (payload: Record<string, unknown>) => `header.${encodeBase64Url(JSON.stringify(payload))}.signature`
9
+
10
+ describe('flowerbase-client session', () => {
11
+ const originalFetch = global.fetch
12
+ const originalLocalStorage = (globalThis as typeof globalThis & { localStorage?: unknown }).localStorage
13
+
14
+ afterEach(() => {
15
+ global.fetch = originalFetch
16
+ if (typeof originalLocalStorage === 'undefined') {
17
+ Reflect.deleteProperty(globalThis, 'localStorage')
18
+ } else {
19
+ Object.defineProperty(globalThis, 'localStorage', {
20
+ configurable: true,
21
+ value: originalLocalStorage
22
+ })
23
+ }
24
+ })
25
+
26
+ it('refreshes access token', async () => {
27
+ global.fetch = jest
28
+ .fn()
29
+ .mockResolvedValueOnce({
30
+ ok: true,
31
+ text: async () => JSON.stringify({
32
+ access_token: 'access',
33
+ refresh_token: 'refresh',
34
+ user_id: 'user-1'
35
+ })
36
+ })
37
+ .mockResolvedValueOnce({
38
+ ok: true,
39
+ text: async () => JSON.stringify({ access_token: 'access' })
40
+ })
41
+ .mockResolvedValueOnce({
42
+ ok: true,
43
+ text: async () => JSON.stringify({ access_token: 'access-2' })
44
+ })
45
+ .mockResolvedValueOnce({
46
+ ok: true,
47
+ text: async () => JSON.stringify({ access_token: 'access-3' })
48
+ })
49
+ .mockResolvedValue({
50
+ ok: true,
51
+ text: async () => JSON.stringify({ access_token: 'access-fallback' })
52
+ }) as unknown as typeof fetch
53
+
54
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
55
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
56
+
57
+ const token = await app.currentUser!.refreshAccessToken()
58
+ expect(token).toBe('access-2')
59
+ expect(global.fetch).toHaveBeenLastCalledWith(
60
+ 'http://localhost:3000/api/client/v2.0/auth/session',
61
+ expect.objectContaining({
62
+ method: 'POST',
63
+ headers: expect.objectContaining({ Authorization: 'Bearer refresh' })
64
+ })
65
+ )
66
+ })
67
+
68
+ it('clears session when refresh fails', async () => {
69
+ global.fetch = jest
70
+ .fn()
71
+ .mockResolvedValueOnce({
72
+ ok: true,
73
+ text: async () => JSON.stringify({
74
+ access_token: 'access',
75
+ refresh_token: 'refresh',
76
+ user_id: 'user-1'
77
+ })
78
+ })
79
+ .mockResolvedValueOnce({
80
+ ok: true,
81
+ text: async () => JSON.stringify({ access_token: 'access' })
82
+ })
83
+ .mockResolvedValueOnce({
84
+ ok: false,
85
+ status: 401,
86
+ text: async () => JSON.stringify({ message: 'Invalid refresh token provided' })
87
+ }) as unknown as typeof fetch
88
+
89
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
90
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
91
+
92
+ await expect(app.currentUser!.refreshAccessToken()).rejects.toThrow('Invalid refresh token provided')
93
+ expect(app.currentUser).toBeNull()
94
+ })
95
+
96
+ it('revokes session on logout', async () => {
97
+ global.fetch = jest
98
+ .fn()
99
+ .mockResolvedValueOnce({
100
+ ok: true,
101
+ text: async () => JSON.stringify({
102
+ access_token: 'access',
103
+ refresh_token: 'refresh',
104
+ user_id: 'user-1'
105
+ })
106
+ })
107
+ .mockResolvedValueOnce({
108
+ ok: true,
109
+ text: async () => JSON.stringify({ access_token: 'access' })
110
+ })
111
+ .mockResolvedValueOnce({
112
+ ok: true,
113
+ text: async () => JSON.stringify({ status: 'ok' })
114
+ }) as unknown as typeof fetch
115
+
116
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
117
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
118
+ await app.currentUser!.logOut()
119
+
120
+ expect(global.fetch).toHaveBeenLastCalledWith(
121
+ 'http://localhost:3000/api/client/v2.0/auth/session',
122
+ expect.objectContaining({ method: 'DELETE' })
123
+ )
124
+ expect(app.currentUser).toBeNull()
125
+ })
126
+
127
+ it('retries function call after access token 401', async () => {
128
+ global.fetch = jest
129
+ .fn()
130
+ .mockResolvedValueOnce({
131
+ ok: true,
132
+ text: async () =>
133
+ JSON.stringify({
134
+ access_token: 'access',
135
+ refresh_token: 'refresh',
136
+ user_id: 'user-1'
137
+ })
138
+ })
139
+ .mockResolvedValueOnce({
140
+ ok: true,
141
+ text: async () => JSON.stringify({ access_token: 'access' })
142
+ })
143
+ .mockResolvedValueOnce({
144
+ ok: false,
145
+ status: 401,
146
+ text: async () => JSON.stringify({ message: 'token expired' })
147
+ })
148
+ .mockResolvedValueOnce({
149
+ ok: true,
150
+ text: async () => JSON.stringify({ access_token: 'access-2' })
151
+ })
152
+ .mockResolvedValueOnce({
153
+ ok: true,
154
+ text: async () => JSON.stringify({ result: 42 })
155
+ }) as unknown as typeof fetch
156
+
157
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
158
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
159
+
160
+ const result = await app.currentUser!.functions.sum(40, 2)
161
+ expect(result).toEqual({ result: 42 })
162
+ expect((global.fetch as jest.Mock).mock.calls[3][0]).toBe('http://localhost:3000/api/client/v2.0/auth/session')
163
+ })
164
+
165
+ it('tracks users in allUsers and supports switch/remove', async () => {
166
+ global.fetch = jest
167
+ .fn()
168
+ .mockResolvedValueOnce({
169
+ ok: true,
170
+ text: async () => JSON.stringify({
171
+ access_token: 'access',
172
+ refresh_token: 'refresh',
173
+ user_id: 'user-1'
174
+ })
175
+ })
176
+ .mockResolvedValueOnce({
177
+ ok: true,
178
+ text: async () => JSON.stringify({ access_token: 'access' })
179
+ })
180
+ .mockResolvedValueOnce({
181
+ ok: true,
182
+ text: async () => JSON.stringify({ status: 'ok' })
183
+ }) as unknown as typeof fetch
184
+
185
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
186
+ const user = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
187
+
188
+ expect(Object.keys(app.allUsers)).toContain('user-1')
189
+ app.switchUser(user)
190
+
191
+ await app.removeUser(user)
192
+ expect(app.currentUser).toBeNull()
193
+ expect(Object.keys(app.allUsers)).not.toContain('user-1')
194
+ expect(user.state).toBe('removed')
195
+ })
196
+
197
+ it('exposes providerType, customData and identities', async () => {
198
+ const accessToken = buildJwt({ user_data: { plan: 'pro' } })
199
+ global.fetch = jest
200
+ .fn()
201
+ .mockResolvedValueOnce({
202
+ ok: true,
203
+ text: async () =>
204
+ JSON.stringify({
205
+ access_token: accessToken,
206
+ refresh_token: 'refresh',
207
+ user_id: 'user-1'
208
+ })
209
+ })
210
+ .mockResolvedValueOnce({
211
+ ok: true,
212
+ text: async () => JSON.stringify({ access_token: accessToken })
213
+ })
214
+ .mockResolvedValueOnce({
215
+ ok: true,
216
+ text: async () =>
217
+ JSON.stringify({
218
+ data: { email: 'john@doe.com' },
219
+ identities: [{ id: 'identity-1', provider_type: 'local-userpass' }],
220
+ custom_data: { plan: 'pro' }
221
+ })
222
+ }) as unknown as typeof fetch
223
+
224
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
225
+ const user = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
226
+ await user.refreshCustomData()
227
+
228
+ expect(user.providerType).toBe('local-userpass')
229
+ expect(user.customData).toEqual({ plan: 'pro' })
230
+ expect(user.identities).toEqual([{ id: 'identity-1', provider_type: 'local-userpass' }])
231
+ })
232
+
233
+ it('throws MongoDBRealmError with status metadata', async () => {
234
+ global.fetch = jest.fn().mockResolvedValue({
235
+ ok: false,
236
+ status: 401,
237
+ statusText: 'Unauthorized',
238
+ text: async () => JSON.stringify({ error: 'Unauthorized', error_code: 'InvalidSession' })
239
+ }) as unknown as typeof fetch
240
+
241
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
242
+ await expect(app.logIn(Credentials.anonymous())).rejects.toBeInstanceOf(MongoDBRealmError)
243
+ })
244
+
245
+ it('orders allUsers as active first then logged-out and persists order', async () => {
246
+ const storage = new Map<string, string>()
247
+ Object.defineProperty(globalThis, 'localStorage', {
248
+ configurable: true,
249
+ value: {
250
+ getItem: (key: string) => storage.get(key) ?? null,
251
+ setItem: (key: string, value: string) => {
252
+ storage.set(key, value)
253
+ },
254
+ removeItem: (key: string) => {
255
+ storage.delete(key)
256
+ }
257
+ }
258
+ })
259
+
260
+ global.fetch = jest
261
+ .fn()
262
+ .mockResolvedValueOnce({
263
+ ok: true,
264
+ text: async () => JSON.stringify({
265
+ access_token: 'access-1',
266
+ refresh_token: 'refresh-1',
267
+ user_id: 'user-1'
268
+ })
269
+ })
270
+ .mockResolvedValueOnce({
271
+ ok: true,
272
+ text: async () => JSON.stringify({ access_token: 'access-1b' })
273
+ })
274
+ .mockResolvedValueOnce({
275
+ ok: true,
276
+ text: async () => JSON.stringify({ status: 'ok' })
277
+ })
278
+ .mockResolvedValueOnce({
279
+ ok: true,
280
+ text: async () => JSON.stringify({
281
+ access_token: 'access-2',
282
+ refresh_token: 'refresh-2',
283
+ user_id: 'user-2'
284
+ })
285
+ })
286
+ .mockResolvedValueOnce({
287
+ ok: true,
288
+ text: async () => JSON.stringify({ access_token: 'access-2b' })
289
+ }) as unknown as typeof fetch
290
+
291
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
292
+ const user1 = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
293
+ await user1.logOut()
294
+ const user2 = await app.logIn(Credentials.anonymous())
295
+
296
+ expect(app.currentUser?.id).toBe('user-2')
297
+ expect(Object.keys(app.allUsers)).toEqual(['user-2', 'user-1'])
298
+ expect(app.allUsers['user-1']?.state).toBe('logged-out')
299
+ expect(user2.state).toBe('active')
300
+
301
+ global.fetch = jest.fn().mockResolvedValue({
302
+ ok: true,
303
+ text: async () => JSON.stringify({ access_token: 'access-2c' })
304
+ }) as unknown as typeof fetch
305
+
306
+ const appReloaded = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
307
+ await appReloaded.getProfile().catch(() => undefined)
308
+
309
+ expect(Object.keys(appReloaded.allUsers)).toEqual(['user-2', 'user-1'])
310
+ expect(appReloaded.currentUser?.id).toBe('user-2')
311
+ })
312
+
313
+ it('switchUser changes active session used by app calls', async () => {
314
+ global.fetch = jest
315
+ .fn()
316
+ .mockResolvedValueOnce({
317
+ ok: true,
318
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
319
+ })
320
+ .mockResolvedValueOnce({
321
+ ok: true,
322
+ text: async () => JSON.stringify({ access_token: 'access-1' })
323
+ })
324
+ .mockResolvedValueOnce({
325
+ ok: true,
326
+ text: async () => JSON.stringify({ access_token: 'login-a2', refresh_token: 'refresh-2', user_id: 'user-2' })
327
+ })
328
+ .mockResolvedValueOnce({
329
+ ok: true,
330
+ text: async () => JSON.stringify({ access_token: 'access-2' })
331
+ })
332
+ .mockResolvedValue({
333
+ ok: true,
334
+ text: async () => JSON.stringify({ ok: true })
335
+ }) as unknown as typeof fetch
336
+
337
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
338
+ const user1 = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
339
+ await app.logIn(Credentials.anonymous())
340
+
341
+ await app.callFunction('firstCall', [])
342
+ let request = (global.fetch as jest.Mock).mock.calls[4][1]
343
+ expect(request.headers.Authorization).toBe('Bearer access-2')
344
+
345
+ app.switchUser(user1)
346
+ await app.callFunction('secondCall', [])
347
+ request = (global.fetch as jest.Mock).mock.calls[5][1]
348
+ expect(request.headers.Authorization).toBe('Bearer access-1')
349
+ })
350
+
351
+ it('notifies app listeners on login, switch and logout', async () => {
352
+ global.fetch = jest
353
+ .fn()
354
+ .mockResolvedValueOnce({
355
+ ok: true,
356
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
357
+ })
358
+ .mockResolvedValueOnce({
359
+ ok: true,
360
+ text: async () => JSON.stringify({ access_token: 'access-1' })
361
+ })
362
+ .mockResolvedValueOnce({
363
+ ok: true,
364
+ text: async () => JSON.stringify({ access_token: 'login-a2', refresh_token: 'refresh-2', user_id: 'user-2' })
365
+ })
366
+ .mockResolvedValueOnce({
367
+ ok: true,
368
+ text: async () => JSON.stringify({ access_token: 'access-2' })
369
+ })
370
+ .mockResolvedValueOnce({
371
+ ok: true,
372
+ text: async () => JSON.stringify({ status: 'ok' })
373
+ }) as unknown as typeof fetch
374
+
375
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
376
+ const appListener = jest.fn()
377
+ app.addListener(appListener)
378
+
379
+ const user1 = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
380
+ await app.logIn(Credentials.anonymous())
381
+ app.switchUser(user1)
382
+ await app.logoutUser()
383
+
384
+ expect(appListener).toHaveBeenCalledTimes(4)
385
+ })
386
+
387
+ it('notifies user listeners on token refresh and custom data refresh', async () => {
388
+ global.fetch = jest
389
+ .fn()
390
+ .mockResolvedValueOnce({
391
+ ok: true,
392
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
393
+ })
394
+ .mockResolvedValueOnce({
395
+ ok: true,
396
+ text: async () => JSON.stringify({ access_token: 'access-1' })
397
+ })
398
+ .mockResolvedValueOnce({
399
+ ok: true,
400
+ text: async () => JSON.stringify({ access_token: 'access-2' })
401
+ })
402
+ .mockResolvedValueOnce({
403
+ ok: true,
404
+ text: async () => JSON.stringify({ data: {}, custom_data: { x: 1 }, identities: [] })
405
+ }) as unknown as typeof fetch
406
+
407
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
408
+ const user = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
409
+ const listener = jest.fn()
410
+ user.addListener(listener)
411
+
412
+ await user.refreshAccessToken()
413
+ await user.refreshCustomData()
414
+
415
+ expect(listener).toHaveBeenCalledTimes(2)
416
+ })
417
+
418
+ it('supports removing app and user listeners', async () => {
419
+ global.fetch = jest
420
+ .fn()
421
+ .mockResolvedValueOnce({
422
+ ok: true,
423
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
424
+ })
425
+ .mockResolvedValueOnce({
426
+ ok: true,
427
+ text: async () => JSON.stringify({ access_token: 'access-1' })
428
+ })
429
+ .mockResolvedValueOnce({
430
+ ok: true,
431
+ text: async () => JSON.stringify({ access_token: 'access-2' })
432
+ })
433
+ .mockResolvedValueOnce({
434
+ ok: true,
435
+ text: async () => JSON.stringify({ access_token: 'access-3' })
436
+ })
437
+ .mockResolvedValue({
438
+ ok: true,
439
+ text: async () => JSON.stringify({ access_token: 'access-fallback' })
440
+ }) as unknown as typeof fetch
441
+
442
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
443
+ const appListener = jest.fn()
444
+ app.addListener(appListener)
445
+ app.removeListener(appListener)
446
+
447
+ const user = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
448
+ expect(appListener).not.toHaveBeenCalled()
449
+
450
+ const userListener = jest.fn()
451
+ user.addListener(userListener)
452
+ user.removeAllListeners()
453
+ await user.refreshAccessToken()
454
+
455
+ expect(userListener).not.toHaveBeenCalled()
456
+ })
457
+
458
+ it('emits app listener notifications in deterministic user order', async () => {
459
+ global.fetch = jest
460
+ .fn()
461
+ .mockResolvedValueOnce({
462
+ ok: true,
463
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
464
+ })
465
+ .mockResolvedValueOnce({
466
+ ok: true,
467
+ text: async () => JSON.stringify({ access_token: 'access-1' })
468
+ })
469
+ .mockResolvedValueOnce({
470
+ ok: true,
471
+ text: async () => JSON.stringify({ access_token: 'login-a2', refresh_token: 'refresh-2', user_id: 'user-2' })
472
+ })
473
+ .mockResolvedValueOnce({
474
+ ok: true,
475
+ text: async () => JSON.stringify({ access_token: 'access-2' })
476
+ })
477
+ .mockResolvedValueOnce({
478
+ ok: true,
479
+ text: async () => JSON.stringify({ status: 'ok' })
480
+ }) as unknown as typeof fetch
481
+
482
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
483
+ const events: string[] = []
484
+ app.addListener(() => {
485
+ events.push(app.currentUser?.id ?? 'null')
486
+ })
487
+
488
+ const user1 = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
489
+ await app.logIn(Credentials.anonymous())
490
+ app.switchUser(user1)
491
+ await app.logoutUser()
492
+
493
+ expect(events).toEqual(['user-1', 'user-2', 'user-1', 'user-2'])
494
+ })
495
+
496
+ it('notifies only the target user listener for user-scoped operations', async () => {
497
+ global.fetch = jest
498
+ .fn()
499
+ .mockResolvedValueOnce({
500
+ ok: true,
501
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
502
+ })
503
+ .mockResolvedValueOnce({
504
+ ok: true,
505
+ text: async () => JSON.stringify({ access_token: 'access-1' })
506
+ })
507
+ .mockResolvedValueOnce({
508
+ ok: true,
509
+ text: async () => JSON.stringify({ access_token: 'login-a2', refresh_token: 'refresh-2', user_id: 'user-2' })
510
+ })
511
+ .mockResolvedValueOnce({
512
+ ok: true,
513
+ text: async () => JSON.stringify({ access_token: 'access-2' })
514
+ })
515
+ .mockResolvedValueOnce({
516
+ ok: true,
517
+ text: async () => JSON.stringify({ access_token: 'access-1b' })
518
+ }) as unknown as typeof fetch
519
+
520
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
521
+ const user1 = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
522
+ await app.logIn(Credentials.anonymous())
523
+
524
+ const user1Listener = jest.fn()
525
+ const user2Listener = jest.fn()
526
+ user1.addListener(user1Listener)
527
+ app.currentUser!.addListener(user2Listener)
528
+
529
+ await user1.refreshAccessToken()
530
+
531
+ expect(user1Listener).toHaveBeenCalledTimes(1)
532
+ expect(user2Listener).toHaveBeenCalledTimes(0)
533
+ })
534
+
535
+ it('continues dispatch when a listener throws', async () => {
536
+ global.fetch = jest
537
+ .fn()
538
+ .mockResolvedValueOnce({
539
+ ok: true,
540
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
541
+ })
542
+ .mockResolvedValueOnce({
543
+ ok: true,
544
+ text: async () => JSON.stringify({ access_token: 'access-1' })
545
+ }) as unknown as typeof fetch
546
+
547
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
548
+ const badListener = jest.fn(() => {
549
+ throw new Error('listener failure')
550
+ })
551
+ const goodListener = jest.fn()
552
+ app.addListener(badListener)
553
+ app.addListener(goodListener)
554
+
555
+ await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
556
+
557
+ expect(badListener).toHaveBeenCalledTimes(1)
558
+ expect(goodListener).toHaveBeenCalledTimes(1)
559
+ })
560
+
561
+ it('supports listener removal during dispatch', async () => {
562
+ global.fetch = jest
563
+ .fn()
564
+ .mockImplementation(async () => ({
565
+ ok: true,
566
+ text: async () => JSON.stringify({ access_token: 'access-fallback' })
567
+ }))
568
+ .mockResolvedValueOnce({
569
+ ok: true,
570
+ text: async () => JSON.stringify({ access_token: 'login-a1', refresh_token: 'refresh-1', user_id: 'user-1' })
571
+ })
572
+ .mockResolvedValueOnce({
573
+ ok: true,
574
+ text: async () => JSON.stringify({ access_token: 'access-1' })
575
+ })
576
+ .mockResolvedValueOnce({
577
+ ok: true,
578
+ text: async () => JSON.stringify({ access_token: 'access-2' })
579
+ }) as unknown as typeof fetch
580
+
581
+ const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
582
+ const user = await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
583
+
584
+ const onceListener = jest.fn(() => {
585
+ user.removeListener(onceListener)
586
+ })
587
+ const stableListener = jest.fn()
588
+ user.addListener(onceListener)
589
+ user.addListener(stableListener)
590
+
591
+ await user.refreshAccessToken()
592
+ await user.refreshAccessToken()
593
+
594
+ expect(onceListener).toHaveBeenCalledTimes(1)
595
+ expect(stableListener).toHaveBeenCalledTimes(2)
596
+ })
597
+ })