@brika/auth 0.1.1

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 (62) hide show
  1. package/README.md +207 -0
  2. package/package.json +50 -0
  3. package/src/__tests__/AuthClient.test.ts +736 -0
  4. package/src/__tests__/AuthService.test.ts +140 -0
  5. package/src/__tests__/ScopeService.test.ts +156 -0
  6. package/src/__tests__/SessionService.test.ts +311 -0
  7. package/src/__tests__/UserService-avatar.test.ts +277 -0
  8. package/src/__tests__/UserService.test.ts +223 -0
  9. package/src/__tests__/canAccess.test.ts +166 -0
  10. package/src/__tests__/disabledScopes.test.ts +101 -0
  11. package/src/__tests__/middleware.test.ts +190 -0
  12. package/src/__tests__/plugin.test.ts +78 -0
  13. package/src/__tests__/requireSession.test.ts +78 -0
  14. package/src/__tests__/routes-auth.test.ts +248 -0
  15. package/src/__tests__/routes-profile.test.ts +403 -0
  16. package/src/__tests__/routes-scopes.test.ts +64 -0
  17. package/src/__tests__/routes-sessions.test.ts +235 -0
  18. package/src/__tests__/routes-users.test.ts +477 -0
  19. package/src/__tests__/serveImage.test.ts +277 -0
  20. package/src/__tests__/setup.test.ts +270 -0
  21. package/src/__tests__/verifyToken.test.ts +219 -0
  22. package/src/client/AuthClient.ts +312 -0
  23. package/src/client/http-client.ts +84 -0
  24. package/src/client/index.ts +19 -0
  25. package/src/config.ts +82 -0
  26. package/src/constants.ts +10 -0
  27. package/src/index.ts +16 -0
  28. package/src/lib/define-roles.ts +35 -0
  29. package/src/lib/define-scopes.ts +48 -0
  30. package/src/middleware/canAccess.ts +126 -0
  31. package/src/middleware/index.ts +13 -0
  32. package/src/middleware/requireAuth.ts +35 -0
  33. package/src/middleware/requireScope.ts +46 -0
  34. package/src/middleware/verifyToken.ts +52 -0
  35. package/src/plugin.ts +86 -0
  36. package/src/react/AuthProvider.tsx +105 -0
  37. package/src/react/hooks.ts +128 -0
  38. package/src/react/index.ts +51 -0
  39. package/src/react/withScopeGuard.tsx +73 -0
  40. package/src/roles.ts +40 -0
  41. package/src/schemas.ts +112 -0
  42. package/src/scopes.ts +60 -0
  43. package/src/server/index.ts +44 -0
  44. package/src/server/requireSession.ts +44 -0
  45. package/src/server/routes/auth.ts +102 -0
  46. package/src/server/routes/cookie.ts +7 -0
  47. package/src/server/routes/index.ts +32 -0
  48. package/src/server/routes/profile.ts +162 -0
  49. package/src/server/routes/scopes.ts +22 -0
  50. package/src/server/routes/sessions.ts +68 -0
  51. package/src/server/routes/setup.ts +50 -0
  52. package/src/server/routes/users.ts +175 -0
  53. package/src/server/serveImage.ts +91 -0
  54. package/src/services/AuthService.ts +80 -0
  55. package/src/services/ScopeService.ts +94 -0
  56. package/src/services/SessionService.ts +245 -0
  57. package/src/services/UserService.ts +245 -0
  58. package/src/setup.ts +99 -0
  59. package/src/tanstack/index.ts +15 -0
  60. package/src/tanstack/routeBuilder.ts +311 -0
  61. package/src/types.ts +118 -0
  62. package/tsconfig.json +8 -0
@@ -0,0 +1,736 @@
1
+ /**
2
+ * @brika/auth - AuthClient Tests
3
+ *
4
+ * Tests for the AuthClient class that makes fetch() calls to the auth API.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'bun:test';
8
+ import { AuthClient, createAuthClient, getAuthClient } from '../client/AuthClient';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Mock fetch
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const mockFetch = vi.fn();
15
+ let originalFetch: typeof globalThis.fetch;
16
+
17
+ beforeEach(() => {
18
+ originalFetch = globalThis.fetch;
19
+ globalThis.fetch = Object.assign(mockFetch, {
20
+ preconnect: () => {},
21
+ });
22
+ mockFetch.mockReset();
23
+ });
24
+
25
+ afterEach(() => {
26
+ globalThis.fetch = originalFetch;
27
+ });
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function fetchCall(index: number) {
34
+ const call = mockFetch.mock.calls[index];
35
+ if (!call) {
36
+ throw new Error(`Expected fetch call at index ${index}`);
37
+ }
38
+ return call;
39
+ }
40
+
41
+ function jsonResponse(body: unknown, status = 200): Response {
42
+ return new Response(JSON.stringify(body), {
43
+ status,
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ },
47
+ });
48
+ }
49
+
50
+ const mockUser = {
51
+ id: 'user-1',
52
+ email: 'test@example.com',
53
+ name: 'Test User',
54
+ role: 'user',
55
+ avatarHash: null,
56
+ createdAt: '2025-01-01T00:00:00Z',
57
+ updatedAt: '2025-01-01T00:00:00Z',
58
+ };
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Constructor
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('AuthClient', () => {
65
+ describe('constructor', () => {
66
+ it('should use provided apiUrl', () => {
67
+ const client = new AuthClient({
68
+ apiUrl: 'http://custom:9000',
69
+ });
70
+ // Verify by building an avatar URL (exposes the apiUrl)
71
+ const url = client.avatarUrl({
72
+ id: 'user-1',
73
+ });
74
+ expect(url).toStartWith('http://custom:9000');
75
+ });
76
+
77
+ it('should default to localhost:3001 when no window', () => {
78
+ const client = new AuthClient();
79
+ const url = client.avatarUrl({
80
+ id: 'user-1',
81
+ });
82
+ expect(url).toStartWith('http://localhost:3001');
83
+ });
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // login
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('login', () => {
91
+ it('should POST credentials and return session', async () => {
92
+ // login() makes 2 calls: POST /login then GET /session
93
+ mockFetch.mockResolvedValueOnce(
94
+ jsonResponse({
95
+ user: mockUser,
96
+ })
97
+ );
98
+ mockFetch.mockResolvedValueOnce(
99
+ jsonResponse({
100
+ user: mockUser,
101
+ scopes: ['workflow:read'],
102
+ })
103
+ );
104
+
105
+ const client = new AuthClient({
106
+ apiUrl: 'http://test',
107
+ });
108
+ const session = await client.login('test@example.com', 'password123');
109
+
110
+ expect(session.user).toEqual(mockUser);
111
+ expect(session.scopes).toEqual(['workflow:read']);
112
+ expect(mockFetch).toHaveBeenCalledTimes(2);
113
+
114
+ const [url, opts] = fetchCall(0);
115
+ expect(url).toBe('http://test/api/auth/login');
116
+ expect(opts.method).toBe('POST');
117
+ expect(opts.credentials).toBe('include');
118
+ expect(JSON.parse(opts.body)).toEqual({
119
+ email: 'test@example.com',
120
+ password: 'password123',
121
+ });
122
+
123
+ const [sessionUrl] = fetchCall(1);
124
+ expect(sessionUrl).toBe('http://test/api/auth/session');
125
+ });
126
+
127
+ it('should throw on failed login with 401', async () => {
128
+ mockFetch.mockResolvedValueOnce(
129
+ jsonResponse(
130
+ {
131
+ error: 'Invalid credentials',
132
+ },
133
+ 401
134
+ )
135
+ );
136
+
137
+ const client = new AuthClient({
138
+ apiUrl: 'http://test',
139
+ });
140
+ await expect(client.login('bad@example.com', 'wrong')).rejects.toThrow('Invalid credentials');
141
+ });
142
+
143
+ it('should throw on failed login with non-ok response', async () => {
144
+ mockFetch.mockResolvedValueOnce(jsonResponse({}, 400));
145
+
146
+ const client = new AuthClient({
147
+ apiUrl: 'http://test',
148
+ });
149
+ await expect(client.login('x@x.com', 'x')).rejects.toThrow('Request failed');
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // logout
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe('logout', () => {
158
+ it('should POST to logout endpoint', async () => {
159
+ mockFetch.mockResolvedValueOnce(
160
+ new Response(null, {
161
+ status: 204,
162
+ })
163
+ );
164
+
165
+ const client = new AuthClient({
166
+ apiUrl: 'http://test',
167
+ });
168
+ await client.logout();
169
+
170
+ const [url, opts] = fetchCall(0);
171
+ expect(url).toBe('http://test/api/auth/logout');
172
+ expect(opts.method).toBe('POST');
173
+ expect(opts.credentials).toBe('include');
174
+ });
175
+
176
+ it('should silently fail on network error', async () => {
177
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
178
+
179
+ const client = new AuthClient({
180
+ apiUrl: 'http://test',
181
+ });
182
+ // Should not throw
183
+ await client.logout();
184
+ });
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // getSession
189
+ // ---------------------------------------------------------------------------
190
+
191
+ describe('getSession', () => {
192
+ it('should return session on success', async () => {
193
+ mockFetch.mockResolvedValueOnce(
194
+ jsonResponse({
195
+ user: mockUser,
196
+ scopes: ['workflow:read'],
197
+ })
198
+ );
199
+
200
+ const client = new AuthClient({
201
+ apiUrl: 'http://test',
202
+ });
203
+ const session = await client.getSession();
204
+
205
+ expect(session).not.toBeNull();
206
+ expect(session?.user).toEqual(mockUser);
207
+ expect(session?.scopes).toEqual(['workflow:read']);
208
+
209
+ const [url, opts] = fetchCall(0);
210
+ expect(url).toBe('http://test/api/auth/session');
211
+ expect(opts.credentials).toBe('include');
212
+ });
213
+
214
+ it('should return null on non-ok response', async () => {
215
+ mockFetch.mockResolvedValueOnce(
216
+ new Response(null, {
217
+ status: 401,
218
+ })
219
+ );
220
+
221
+ const client = new AuthClient({
222
+ apiUrl: 'http://test',
223
+ });
224
+ const session = await client.getSession();
225
+ expect(session).toBeNull();
226
+ });
227
+
228
+ it('should return null on network error', async () => {
229
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
230
+
231
+ const client = new AuthClient({
232
+ apiUrl: 'http://test',
233
+ });
234
+ const session = await client.getSession();
235
+ expect(session).toBeNull();
236
+ });
237
+ });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // updateProfile
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe('updateProfile', () => {
244
+ it('should PUT profile updates and return session', async () => {
245
+ const updatedUser = {
246
+ ...mockUser,
247
+ name: 'New Name',
248
+ };
249
+ mockFetch.mockResolvedValueOnce(
250
+ jsonResponse({
251
+ user: updatedUser,
252
+ })
253
+ );
254
+
255
+ const client = new AuthClient({
256
+ apiUrl: 'http://test',
257
+ });
258
+ const result = await client.updateProfile({
259
+ name: 'New Name',
260
+ });
261
+
262
+ expect(result).toEqual({
263
+ user: updatedUser,
264
+ });
265
+
266
+ const [url, opts] = fetchCall(0);
267
+ expect(url).toBe('http://test/api/auth/profile');
268
+ expect(opts.method).toBe('PUT');
269
+ expect(opts.credentials).toBe('include');
270
+ expect(JSON.parse(opts.body)).toEqual({
271
+ name: 'New Name',
272
+ });
273
+ });
274
+
275
+ it('should throw on 401', async () => {
276
+ mockFetch.mockResolvedValueOnce(
277
+ new Response(null, {
278
+ status: 401,
279
+ })
280
+ );
281
+
282
+ const client = new AuthClient({
283
+ apiUrl: 'http://test',
284
+ });
285
+ await expect(
286
+ client.updateProfile({
287
+ name: 'X',
288
+ })
289
+ ).rejects.toThrow('Unauthorized');
290
+ });
291
+ });
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // uploadAvatar
295
+ // ---------------------------------------------------------------------------
296
+
297
+ describe('uploadAvatar', () => {
298
+ it('should PUT blob and return avatar hash', async () => {
299
+ mockFetch.mockResolvedValueOnce(
300
+ jsonResponse({
301
+ ok: true,
302
+ avatarHash: 'abc123',
303
+ })
304
+ );
305
+
306
+ const blob = new Blob(['fake-image'], {
307
+ type: 'image/png',
308
+ });
309
+ const client = new AuthClient({
310
+ apiUrl: 'http://test',
311
+ });
312
+ const hash = await client.uploadAvatar(blob);
313
+
314
+ expect(hash).toBe('abc123');
315
+
316
+ const [url, opts] = fetchCall(0);
317
+ expect(url).toBe('http://test/api/auth/profile/avatar');
318
+ expect(opts.method).toBe('PUT');
319
+ expect(opts.credentials).toBe('include');
320
+ expect(opts.body).toBe(blob);
321
+ });
322
+
323
+ it('should throw on upload failure with server error', async () => {
324
+ mockFetch.mockResolvedValueOnce(
325
+ jsonResponse(
326
+ {
327
+ error: 'File too large',
328
+ },
329
+ 413
330
+ )
331
+ );
332
+
333
+ const blob = new Blob(['big-image']);
334
+ const client = new AuthClient({
335
+ apiUrl: 'http://test',
336
+ });
337
+ await expect(client.uploadAvatar(blob)).rejects.toThrow('File too large');
338
+ });
339
+
340
+ it('should throw generic message when no error field', async () => {
341
+ mockFetch.mockResolvedValueOnce(jsonResponse({}, 500));
342
+
343
+ const blob = new Blob(['x']);
344
+ const client = new AuthClient({
345
+ apiUrl: 'http://test',
346
+ });
347
+ await expect(client.uploadAvatar(blob)).rejects.toThrow('Request failed');
348
+ });
349
+ });
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // removeAvatar
353
+ // ---------------------------------------------------------------------------
354
+
355
+ describe('removeAvatar', () => {
356
+ it('should DELETE avatar', async () => {
357
+ mockFetch.mockResolvedValueOnce(
358
+ jsonResponse({
359
+ ok: true,
360
+ })
361
+ );
362
+
363
+ const client = new AuthClient({
364
+ apiUrl: 'http://test',
365
+ });
366
+ await client.removeAvatar();
367
+
368
+ const [url, opts] = fetchCall(0);
369
+ expect(url).toBe('http://test/api/auth/profile/avatar');
370
+ expect(opts.method).toBe('DELETE');
371
+ expect(opts.credentials).toBe('include');
372
+ });
373
+
374
+ it('should throw on 401', async () => {
375
+ mockFetch.mockResolvedValueOnce(
376
+ new Response(null, {
377
+ status: 401,
378
+ })
379
+ );
380
+
381
+ const client = new AuthClient({
382
+ apiUrl: 'http://test',
383
+ });
384
+ await expect(client.removeAvatar()).rejects.toThrow('Unauthorized');
385
+ });
386
+ });
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // avatarUrl
390
+ // ---------------------------------------------------------------------------
391
+
392
+ describe('avatarUrl', () => {
393
+ const user = {
394
+ id: 'user-1',
395
+ avatarHash: null as string | null,
396
+ };
397
+
398
+ it('should build basic avatar URL', () => {
399
+ const client = new AuthClient({
400
+ apiUrl: 'http://test',
401
+ });
402
+ const url = client.avatarUrl(user);
403
+ expect(url).toBe('http://test/api/auth/avatar/user-1');
404
+ });
405
+
406
+ it('should include size as s param (size * dpr)', () => {
407
+ const client = new AuthClient({
408
+ apiUrl: 'http://test',
409
+ });
410
+ const url = client.avatarUrl(user, {
411
+ size: 128,
412
+ dpr: 1,
413
+ });
414
+ expect(url).toContain('s=128');
415
+ });
416
+
417
+ it('should multiply size by dpr', () => {
418
+ const client = new AuthClient({
419
+ apiUrl: 'http://test',
420
+ });
421
+ const url = client.avatarUrl(user, {
422
+ size: 64,
423
+ dpr: 2,
424
+ });
425
+ expect(url).toContain('s=128');
426
+ });
427
+
428
+ it('should not include s param when no size given', () => {
429
+ const client = new AuthClient({
430
+ apiUrl: 'http://test',
431
+ });
432
+ const url = client.avatarUrl(user, {
433
+ dpr: 2,
434
+ });
435
+ expect(url).not.toContain('s=');
436
+ });
437
+
438
+ it('should include hash param as v for cache busting', () => {
439
+ const client = new AuthClient({
440
+ apiUrl: 'http://test',
441
+ });
442
+ const url = client.avatarUrl({
443
+ id: 'user-1',
444
+ avatarHash: 'deadbeef',
445
+ });
446
+ expect(url).toContain('v=deadbeef');
447
+ });
448
+
449
+ it('should not include v param when avatarHash is null', () => {
450
+ const client = new AuthClient({
451
+ apiUrl: 'http://test',
452
+ });
453
+ const url = client.avatarUrl({
454
+ id: 'user-1',
455
+ avatarHash: null,
456
+ });
457
+ expect(url).not.toContain('v=');
458
+ });
459
+
460
+ it('should combine multiple params', () => {
461
+ const client = new AuthClient({
462
+ apiUrl: 'http://test',
463
+ });
464
+ const url = client.avatarUrl(
465
+ {
466
+ id: 'user-1',
467
+ avatarHash: 'abc',
468
+ },
469
+ {
470
+ size: 64,
471
+ dpr: 2,
472
+ }
473
+ );
474
+ expect(url).toContain('s=128');
475
+ expect(url).toContain('v=abc');
476
+ expect(url).toStartWith('http://test/api/auth/avatar/user-1?');
477
+ });
478
+
479
+ it('should have no query string when no options and no avatarHash', () => {
480
+ const client = new AuthClient({
481
+ apiUrl: 'http://test',
482
+ });
483
+ const url = client.avatarUrl(user);
484
+ expect(url).not.toContain('?');
485
+ });
486
+ });
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // listSessions
490
+ // ---------------------------------------------------------------------------
491
+
492
+ describe('listSessions', () => {
493
+ it('should return sessions array', async () => {
494
+ const sessions = [
495
+ {
496
+ id: 's1',
497
+ ip: '127.0.0.1',
498
+ userAgent: 'Chrome',
499
+ createdAt: 1,
500
+ lastSeenAt: 2,
501
+ current: true,
502
+ },
503
+ {
504
+ id: 's2',
505
+ ip: null,
506
+ userAgent: null,
507
+ createdAt: 3,
508
+ lastSeenAt: 4,
509
+ current: false,
510
+ },
511
+ ];
512
+ mockFetch.mockResolvedValueOnce(
513
+ jsonResponse({
514
+ sessions,
515
+ })
516
+ );
517
+
518
+ const client = new AuthClient({
519
+ apiUrl: 'http://test',
520
+ });
521
+ const result = await client.listSessions();
522
+
523
+ expect(result).toEqual(sessions);
524
+ expect(result).toHaveLength(2);
525
+
526
+ const [url] = fetchCall(0);
527
+ expect(url).toBe('http://test/api/auth/sessions');
528
+ });
529
+ });
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // revokeSession
533
+ // ---------------------------------------------------------------------------
534
+
535
+ describe('revokeSession', () => {
536
+ it('should DELETE a specific session', async () => {
537
+ mockFetch.mockResolvedValueOnce(
538
+ jsonResponse({
539
+ ok: true,
540
+ })
541
+ );
542
+
543
+ const client = new AuthClient({
544
+ apiUrl: 'http://test',
545
+ });
546
+ await client.revokeSession('sess-123');
547
+
548
+ const [url, opts] = fetchCall(0);
549
+ expect(url).toBe('http://test/api/auth/sessions/sess-123');
550
+ expect(opts.method).toBe('DELETE');
551
+ expect(opts.credentials).toBe('include');
552
+ });
553
+ });
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // changePassword
557
+ // ---------------------------------------------------------------------------
558
+
559
+ describe('changePassword', () => {
560
+ it('should PUT password change', async () => {
561
+ mockFetch.mockResolvedValueOnce(
562
+ jsonResponse({
563
+ ok: true,
564
+ })
565
+ );
566
+
567
+ const client = new AuthClient({
568
+ apiUrl: 'http://test',
569
+ });
570
+ await client.changePassword('oldPass1!', 'newPass2@');
571
+
572
+ const [url, opts] = fetchCall(0);
573
+ expect(url).toBe('http://test/api/auth/profile/password');
574
+ expect(opts.method).toBe('PUT');
575
+ expect(JSON.parse(opts.body)).toEqual({
576
+ currentPassword: 'oldPass1!',
577
+ newPassword: 'newPass2@',
578
+ });
579
+ });
580
+
581
+ it('should throw on failure', async () => {
582
+ mockFetch.mockResolvedValueOnce(
583
+ jsonResponse(
584
+ {
585
+ error: 'Current password is incorrect',
586
+ },
587
+ 400
588
+ )
589
+ );
590
+
591
+ const client = new AuthClient({
592
+ apiUrl: 'http://test',
593
+ });
594
+ await expect(client.changePassword('wrong', 'new')).rejects.toThrow(
595
+ 'Current password is incorrect'
596
+ );
597
+ });
598
+ });
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // revokeAllSessions
602
+ // ---------------------------------------------------------------------------
603
+
604
+ describe('revokeAllSessions', () => {
605
+ it('should DELETE all sessions', async () => {
606
+ mockFetch.mockResolvedValueOnce(
607
+ jsonResponse({
608
+ ok: true,
609
+ })
610
+ );
611
+
612
+ const client = new AuthClient({
613
+ apiUrl: 'http://test',
614
+ });
615
+ await client.revokeAllSessions();
616
+
617
+ const [url, opts] = fetchCall(0);
618
+ expect(url).toBe('http://test/api/auth/sessions');
619
+ expect(opts.method).toBe('DELETE');
620
+ expect(opts.credentials).toBe('include');
621
+ });
622
+ });
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // request (generic)
626
+ // ---------------------------------------------------------------------------
627
+
628
+ describe('request', () => {
629
+ it('should throw Unauthorized on 401', async () => {
630
+ mockFetch.mockResolvedValueOnce(
631
+ new Response(null, {
632
+ status: 401,
633
+ })
634
+ );
635
+
636
+ const client = new AuthClient({
637
+ apiUrl: 'http://test',
638
+ });
639
+ await expect(client.request('/api/anything')).rejects.toThrow('Unauthorized');
640
+ });
641
+
642
+ it('should throw server error message on non-ok response', async () => {
643
+ mockFetch.mockResolvedValueOnce(
644
+ jsonResponse(
645
+ {
646
+ error: 'Not found',
647
+ },
648
+ 404
649
+ )
650
+ );
651
+
652
+ const client = new AuthClient({
653
+ apiUrl: 'http://test',
654
+ });
655
+ await expect(client.request('/api/missing')).rejects.toThrow('Not found');
656
+ });
657
+
658
+ it('should throw generic message when error field is missing', async () => {
659
+ mockFetch.mockResolvedValueOnce(jsonResponse({}, 500));
660
+
661
+ const client = new AuthClient({
662
+ apiUrl: 'http://test',
663
+ });
664
+ await expect(client.request('/api/broken')).rejects.toThrow('Request failed');
665
+ });
666
+
667
+ it('should return parsed JSON on success', async () => {
668
+ mockFetch.mockResolvedValueOnce(
669
+ jsonResponse({
670
+ data: 'hello',
671
+ })
672
+ );
673
+
674
+ const client = new AuthClient({
675
+ apiUrl: 'http://test',
676
+ });
677
+ const result = await client.request<{
678
+ data: string;
679
+ }>('/api/test');
680
+ expect(result).toEqual({
681
+ data: 'hello',
682
+ });
683
+ });
684
+
685
+ it('should always include credentials', async () => {
686
+ mockFetch.mockResolvedValueOnce(
687
+ jsonResponse({
688
+ ok: true,
689
+ })
690
+ );
691
+
692
+ const client = new AuthClient({
693
+ apiUrl: 'http://test',
694
+ });
695
+ await client.request('/api/test', {
696
+ method: 'PATCH',
697
+ });
698
+
699
+ const [, opts] = fetchCall(0);
700
+ expect(opts.credentials).toBe('include');
701
+ expect(opts.method).toBe('PATCH');
702
+ });
703
+ });
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // getAuthClient / createAuthClient
707
+ // ---------------------------------------------------------------------------
708
+
709
+ describe('getAuthClient', () => {
710
+ it('should return the same singleton instance', () => {
711
+ // Note: getAuthClient uses a module-level singleton. We call createAuthClient
712
+ // to avoid polluting across tests, but we can still test the factory.
713
+ const a = createAuthClient({
714
+ apiUrl: 'http://a',
715
+ });
716
+ const b = createAuthClient({
717
+ apiUrl: 'http://b',
718
+ });
719
+ expect(a).not.toBe(b); // createAuthClient always returns a new instance
720
+ });
721
+ });
722
+
723
+ describe('createAuthClient', () => {
724
+ it('should create new instances each time', () => {
725
+ const a = createAuthClient({
726
+ apiUrl: 'http://test',
727
+ });
728
+ const b = createAuthClient({
729
+ apiUrl: 'http://test',
730
+ });
731
+ expect(a).not.toBe(b);
732
+ expect(a).toBeInstanceOf(AuthClient);
733
+ expect(b).toBeInstanceOf(AuthClient);
734
+ });
735
+ });
736
+ });