@etus/bhono-app 0.1.6 → 0.1.7

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 (61) hide show
  1. package/package.json +3 -2
  2. package/templates/base/.claude/commands/check-skill-rules.md +112 -29
  3. package/templates/base/.claude/commands/linear/implement-issue.md +383 -55
  4. package/templates/base/.claude/commands/ship.md +77 -13
  5. package/templates/base/.claude/hooks/package-lock.json +0 -419
  6. package/templates/base/.claude/hooks/skill-activation-prompt.ts +185 -113
  7. package/templates/base/.claude/hooks/skill-tool-guard.sh +6 -0
  8. package/templates/base/.claude/hooks/skill-tool-guard.ts +198 -0
  9. package/templates/base/.claude/scripts/validate-skill-rules.sh +55 -32
  10. package/templates/base/.claude/settings.json +18 -11
  11. package/templates/base/.claude/skills/skill-rules.json +326 -173
  12. package/templates/base/.env.example +3 -0
  13. package/templates/base/README.md +9 -7
  14. package/templates/base/config/eslint.config.js +1 -0
  15. package/templates/base/config/wrangler.json +16 -17
  16. package/templates/base/docs/SETUP-GUIDE.md +566 -0
  17. package/templates/base/docs/architecture/README.md +162 -8
  18. package/templates/base/docs/architecture/api-catalog.md +575 -0
  19. package/templates/base/docs/architecture/c4-component.md +309 -0
  20. package/templates/base/docs/architecture/c4-container.md +183 -0
  21. package/templates/base/docs/architecture/c4-context.md +106 -0
  22. package/templates/base/docs/architecture/dependencies.md +327 -0
  23. package/templates/base/docs/architecture/tech-debt.md +184 -0
  24. package/templates/base/package.json +20 -15
  25. package/templates/base/scripts/capture-prod-session.ts +2 -2
  26. package/templates/base/scripts/sync-template.sh +104 -0
  27. package/templates/base/src/server/db/sql.ts +24 -4
  28. package/templates/base/src/server/index.ts +1 -0
  29. package/templates/base/src/server/lib/audited-db.ts +10 -10
  30. package/templates/base/src/server/middleware/account.ts +1 -1
  31. package/templates/base/src/server/middleware/auth.ts +11 -11
  32. package/templates/base/src/server/middleware/rate-limit.ts +3 -6
  33. package/templates/base/src/server/routes/auth/handlers.ts +5 -5
  34. package/templates/base/src/server/routes/auth/test-login.ts +9 -9
  35. package/templates/base/src/server/routes/index.ts +9 -0
  36. package/templates/base/src/server/routes/invitations/handlers.ts +6 -6
  37. package/templates/base/src/server/routes/openapi.ts +1 -1
  38. package/templates/base/src/server/services/accounts.ts +9 -9
  39. package/templates/base/src/server/services/audits.ts +12 -12
  40. package/templates/base/src/server/services/auth.ts +15 -15
  41. package/templates/base/src/server/services/invitations.ts +16 -16
  42. package/templates/base/src/server/services/users.ts +13 -13
  43. package/templates/base/src/shared/types/api.ts +66 -198
  44. package/templates/base/tests/e2e/auth.setup.ts +1 -1
  45. package/templates/base/tests/unit/server/auth/guards.test.ts +1 -1
  46. package/templates/base/tests/unit/server/middleware/auth.test.ts +273 -0
  47. package/templates/base/tests/unit/server/routes/auth/handlers.test.ts +111 -0
  48. package/templates/base/tests/unit/server/routes/users/handlers.test.ts +69 -5
  49. package/templates/base/tests/unit/server/services/accounts.test.ts +148 -0
  50. package/templates/base/tests/unit/server/services/audits.test.ts +219 -0
  51. package/templates/base/tests/unit/server/services/auth.test.ts +480 -3
  52. package/templates/base/tests/unit/server/services/invitations.test.ts +178 -0
  53. package/templates/base/tests/unit/server/services/users.test.ts +363 -8
  54. package/templates/base/tests/unit/shared/schemas.test.ts +1 -1
  55. package/templates/base/vite.config.ts +3 -1
  56. package/templates/base/.github/workflows/test.yml +0 -127
  57. package/templates/base/.husky/pre-push +0 -26
  58. package/templates/base/auth-setup-error.png +0 -0
  59. package/templates/base/pnpm-lock.yaml +0 -8052
  60. package/templates/base/tests/e2e/_auth/.gitkeep +0 -0
  61. package/templates/base/tsconfig.tsbuildinfo +0 -1
@@ -18,6 +18,17 @@ vi.mock('@server/lib/audit', () => ({
18
18
  vi.mock('@server/db/sql', () => ({
19
19
  queryOne: vi.fn(),
20
20
  execute: vi.fn(),
21
+ toStringValue: (value: unknown) => {
22
+ if (typeof value === 'string') return value
23
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
24
+ return ''
25
+ },
26
+ toNullableString: (value: unknown) => {
27
+ if (value === null || value === undefined) return null
28
+ if (typeof value === 'string') return value
29
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
30
+ return null
31
+ },
21
32
  }))
22
33
 
23
34
  import { createAccessToken, generateRefreshToken, hashToken } from '@server/lib/tokens'
@@ -101,7 +112,7 @@ describe('authService', () => {
101
112
  email: 'existing@gmail.com',
102
113
  email_verified: true,
103
114
  name: 'Existing User',
104
- picture: null,
115
+ picture: undefined,
105
116
  given_name: 'Existing',
106
117
  family_name: 'User',
107
118
  }
@@ -168,12 +179,478 @@ describe('authService', () => {
168
179
 
169
180
  describe('revokeRefreshToken', () => {
170
181
  it('should revoke refresh token and log logout', async () => {
171
- const env = createMockEnv()
172
-
173
182
  await authService.revokeRefreshToken(db, 'refresh-token', mockAuthContext, 'user-1')
174
183
 
175
184
  expect(execute).toHaveBeenCalled()
176
185
  expect(logAuthEvent).toHaveBeenCalled()
177
186
  })
187
+
188
+ it('should revoke token without logging when userId is null', async () => {
189
+ await authService.revokeRefreshToken(db, 'refresh-token', mockAuthContext, null)
190
+
191
+ expect(execute).toHaveBeenCalled()
192
+ expect(logAuthEvent).not.toHaveBeenCalled()
193
+ })
194
+ })
195
+
196
+ describe('revokeAllUserTokens', () => {
197
+ it('should revoke all tokens for a user', async () => {
198
+ await authService.revokeAllUserTokens(db, 'user-1')
199
+
200
+ expect(execute).toHaveBeenCalled()
201
+ })
202
+ })
203
+
204
+ describe('getCurrentUser', () => {
205
+ it('should return user when found', async () => {
206
+ ;(queryOne as Mock).mockResolvedValueOnce({
207
+ id: 'user-1',
208
+ googleId: 'google-1',
209
+ email: 'user@example.com',
210
+ name: 'User',
211
+ avatarUrl: null,
212
+ status: 'active',
213
+ providerIds: JSON.stringify(['google']),
214
+ isSuperAdmin: 0,
215
+ createdAt: new Date().toISOString(),
216
+ updatedAt: new Date().toISOString(),
217
+ deletedAt: null,
218
+ })
219
+
220
+ const result = await authService.getCurrentUser(db, 'user-1')
221
+
222
+ expect(result.email).toBe('user@example.com')
223
+ })
224
+
225
+ it('should throw UnauthorizedError when user not found', async () => {
226
+ ;(queryOne as Mock).mockResolvedValueOnce(null)
227
+
228
+ await expect(authService.getCurrentUser(db, 'user-999')).rejects.toThrow(UnauthorizedError)
229
+ })
230
+ })
231
+
232
+ describe('findOrCreateUser edge cases', () => {
233
+ it('should not update user when info is unchanged', async () => {
234
+ const env = createMockEnv()
235
+ const googleUser: GoogleUserInfo = {
236
+ sub: 'existing_sub',
237
+ email: 'same@example.com',
238
+ email_verified: true,
239
+ name: 'Same Name',
240
+ picture: 'https://same-picture.jpg',
241
+ given_name: 'Same',
242
+ family_name: 'Name',
243
+ }
244
+
245
+ const userRow = {
246
+ id: 'user-1',
247
+ googleId: googleUser.sub,
248
+ email: googleUser.email,
249
+ name: googleUser.name,
250
+ avatarUrl: googleUser.picture,
251
+ status: 'active',
252
+ providerIds: JSON.stringify(['google']),
253
+ isSuperAdmin: 0,
254
+ createdAt: new Date().toISOString(),
255
+ updatedAt: new Date().toISOString(),
256
+ deletedAt: null,
257
+ }
258
+
259
+ ;(queryOne as Mock).mockResolvedValueOnce(userRow)
260
+
261
+ const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
262
+
263
+ expect(result.isNewUser).toBe(false)
264
+ // Should only call execute for refresh token insert and login log
265
+ expect(execute).toHaveBeenCalled()
266
+ })
267
+
268
+ it('should update user when email changes', async () => {
269
+ const env = createMockEnv()
270
+ const googleUser: GoogleUserInfo = {
271
+ sub: 'existing_sub',
272
+ email: 'new-email@example.com',
273
+ email_verified: true,
274
+ name: 'Same Name',
275
+ picture: 'https://same-picture.jpg',
276
+ given_name: 'Same',
277
+ family_name: 'Name',
278
+ }
279
+
280
+ const userRow = {
281
+ id: 'user-1',
282
+ googleId: googleUser.sub,
283
+ email: 'old@example.com',
284
+ name: googleUser.name,
285
+ avatarUrl: googleUser.picture,
286
+ status: 'active',
287
+ providerIds: JSON.stringify(['google']),
288
+ isSuperAdmin: 0,
289
+ createdAt: new Date().toISOString(),
290
+ updatedAt: new Date().toISOString(),
291
+ deletedAt: null,
292
+ }
293
+
294
+ ;(queryOne as Mock)
295
+ .mockResolvedValueOnce(userRow)
296
+ .mockResolvedValueOnce({ ...userRow, email: googleUser.email })
297
+
298
+ const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
299
+
300
+ expect(result.isNewUser).toBe(false)
301
+ expect(execute).toHaveBeenCalled()
302
+ })
303
+
304
+ it('should handle null picture', async () => {
305
+ const env = createMockEnv()
306
+ const googleUser: GoogleUserInfo = {
307
+ sub: 'new_google_sub',
308
+ email: 'user@example.com',
309
+ email_verified: true,
310
+ name: 'User',
311
+ picture: undefined,
312
+ given_name: 'User',
313
+ family_name: '',
314
+ }
315
+
316
+ const userRow = {
317
+ id: 'user-new',
318
+ googleId: googleUser.sub,
319
+ email: googleUser.email,
320
+ name: googleUser.name,
321
+ avatarUrl: null,
322
+ status: 'active',
323
+ providerIds: JSON.stringify(['google']),
324
+ isSuperAdmin: 0,
325
+ createdAt: new Date().toISOString(),
326
+ updatedAt: new Date().toISOString(),
327
+ deletedAt: null,
328
+ }
329
+
330
+ ;(queryOne as Mock)
331
+ .mockResolvedValueOnce(null)
332
+ .mockResolvedValueOnce(userRow)
333
+
334
+ const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
335
+
336
+ expect(result.isNewUser).toBe(true)
337
+ })
338
+ })
339
+
340
+ describe('refreshAccessToken edge cases', () => {
341
+ it('should throw when user is inactive', async () => {
342
+ const env = createMockEnv()
343
+
344
+ ;(queryOne as Mock)
345
+ .mockResolvedValueOnce({ id: 'token-1', userId: 'user-1', tokenHash: 'hashed-token' })
346
+ .mockResolvedValueOnce({
347
+ id: 'user-1',
348
+ googleId: 'google-1',
349
+ email: 'user@example.com',
350
+ name: 'User',
351
+ avatarUrl: null,
352
+ status: 'inactive',
353
+ providerIds: JSON.stringify(['google']),
354
+ isSuperAdmin: 0,
355
+ createdAt: new Date().toISOString(),
356
+ updatedAt: new Date().toISOString(),
357
+ deletedAt: null,
358
+ })
359
+
360
+ await expect(
361
+ authService.refreshAccessToken(db, env, 'refresh-token', mockAuthContext)
362
+ ).rejects.toThrow('User not found or inactive')
363
+ })
364
+
365
+ it('should throw when user not found after token found', async () => {
366
+ const env = createMockEnv()
367
+
368
+ ;(queryOne as Mock)
369
+ .mockResolvedValueOnce({ id: 'token-1', userId: 'user-1', tokenHash: 'hashed-token' })
370
+ .mockResolvedValueOnce(null)
371
+
372
+ await expect(
373
+ authService.refreshAccessToken(db, env, 'refresh-token', mockAuthContext)
374
+ ).rejects.toThrow('User not found or inactive')
375
+ })
376
+ })
377
+
378
+ describe('mapUserRow edge cases', () => {
379
+ it('should handle isSuperAdmin as string "1"', async () => {
380
+ ;(queryOne as Mock).mockResolvedValueOnce({
381
+ id: 'user-1',
382
+ googleId: 'google-1',
383
+ email: 'user@example.com',
384
+ name: 'User',
385
+ avatarUrl: null,
386
+ status: 'active',
387
+ providerIds: JSON.stringify(['google']),
388
+ isSuperAdmin: '1',
389
+ createdAt: new Date().toISOString(),
390
+ updatedAt: new Date().toISOString(),
391
+ deletedAt: null,
392
+ })
393
+
394
+ const result = await authService.getCurrentUser(db, 'user-1')
395
+
396
+ expect(result.isSuperAdmin).toBe(true)
397
+ })
398
+
399
+ it('should handle isSuperAdmin as string "true"', async () => {
400
+ ;(queryOne as Mock).mockResolvedValueOnce({
401
+ id: 'user-1',
402
+ googleId: 'google-1',
403
+ email: 'user@example.com',
404
+ name: 'User',
405
+ avatarUrl: null,
406
+ status: 'active',
407
+ providerIds: JSON.stringify(['google']),
408
+ isSuperAdmin: 'true',
409
+ createdAt: new Date().toISOString(),
410
+ updatedAt: new Date().toISOString(),
411
+ deletedAt: null,
412
+ })
413
+
414
+ const result = await authService.getCurrentUser(db, 'user-1')
415
+
416
+ expect(result.isSuperAdmin).toBe(true)
417
+ })
418
+
419
+ it('should handle isSuperAdmin as boolean true', async () => {
420
+ ;(queryOne as Mock).mockResolvedValueOnce({
421
+ id: 'user-1',
422
+ googleId: 'google-1',
423
+ email: 'user@example.com',
424
+ name: 'User',
425
+ avatarUrl: null,
426
+ status: 'active',
427
+ providerIds: JSON.stringify(['google']),
428
+ isSuperAdmin: true,
429
+ createdAt: new Date().toISOString(),
430
+ updatedAt: new Date().toISOString(),
431
+ deletedAt: null,
432
+ })
433
+
434
+ const result = await authService.getCurrentUser(db, 'user-1')
435
+
436
+ expect(result.isSuperAdmin).toBe(true)
437
+ })
438
+
439
+ it('should handle providerIds as array', async () => {
440
+ ;(queryOne as Mock).mockResolvedValueOnce({
441
+ id: 'user-1',
442
+ googleId: 'google-1',
443
+ email: 'user@example.com',
444
+ name: 'User',
445
+ avatarUrl: null,
446
+ status: 'active',
447
+ providerIds: ['google', 'github'],
448
+ isSuperAdmin: 0,
449
+ createdAt: new Date().toISOString(),
450
+ updatedAt: new Date().toISOString(),
451
+ deletedAt: null,
452
+ })
453
+
454
+ const result = await authService.getCurrentUser(db, 'user-1')
455
+
456
+ expect(result.providerIds).toEqual(['google', 'github'])
457
+ })
458
+
459
+ it('should handle invalid JSON in providerIds', async () => {
460
+ ;(queryOne as Mock).mockResolvedValueOnce({
461
+ id: 'user-1',
462
+ googleId: 'google-1',
463
+ email: 'user@example.com',
464
+ name: 'User',
465
+ avatarUrl: null,
466
+ status: 'active',
467
+ providerIds: 'not-valid-json',
468
+ isSuperAdmin: 0,
469
+ createdAt: new Date().toISOString(),
470
+ updatedAt: new Date().toISOString(),
471
+ deletedAt: null,
472
+ })
473
+
474
+ const result = await authService.getCurrentUser(db, 'user-1')
475
+
476
+ expect(result.providerIds).toEqual([])
477
+ })
478
+
479
+ it('should handle non-array JSON in providerIds', async () => {
480
+ ;(queryOne as Mock).mockResolvedValueOnce({
481
+ id: 'user-1',
482
+ googleId: 'google-1',
483
+ email: 'user@example.com',
484
+ name: 'User',
485
+ avatarUrl: null,
486
+ status: 'active',
487
+ providerIds: JSON.stringify({ type: 'object' }),
488
+ isSuperAdmin: 0,
489
+ createdAt: new Date().toISOString(),
490
+ updatedAt: new Date().toISOString(),
491
+ deletedAt: null,
492
+ })
493
+
494
+ const result = await authService.getCurrentUser(db, 'user-1')
495
+
496
+ expect(result.providerIds).toEqual([])
497
+ })
498
+
499
+ it('should handle null providerIds', async () => {
500
+ ;(queryOne as Mock).mockResolvedValueOnce({
501
+ id: 'user-1',
502
+ googleId: 'google-1',
503
+ email: 'user@example.com',
504
+ name: 'User',
505
+ avatarUrl: null,
506
+ status: 'active',
507
+ providerIds: null,
508
+ isSuperAdmin: 0,
509
+ createdAt: new Date().toISOString(),
510
+ updatedAt: new Date().toISOString(),
511
+ deletedAt: null,
512
+ })
513
+
514
+ const result = await authService.getCurrentUser(db, 'user-1')
515
+
516
+ expect(result.providerIds).toEqual([])
517
+ })
518
+
519
+ it('should handle avatarUrl present', async () => {
520
+ ;(queryOne as Mock).mockResolvedValueOnce({
521
+ id: 'user-1',
522
+ googleId: 'google-1',
523
+ email: 'user@example.com',
524
+ name: 'User',
525
+ avatarUrl: 'https://example.com/avatar.jpg',
526
+ status: 'active',
527
+ providerIds: JSON.stringify(['google']),
528
+ isSuperAdmin: 0,
529
+ createdAt: new Date().toISOString(),
530
+ updatedAt: new Date().toISOString(),
531
+ deletedAt: null,
532
+ })
533
+
534
+ const result = await authService.getCurrentUser(db, 'user-1')
535
+
536
+ expect(result).toBeDefined()
537
+ })
538
+
539
+ it('should handle deletedAt present', async () => {
540
+ ;(queryOne as Mock).mockResolvedValueOnce({
541
+ id: 'user-1',
542
+ googleId: 'google-1',
543
+ email: 'user@example.com',
544
+ name: 'User',
545
+ avatarUrl: null,
546
+ status: 'active',
547
+ providerIds: JSON.stringify(['google']),
548
+ isSuperAdmin: 0,
549
+ createdAt: new Date().toISOString(),
550
+ updatedAt: new Date().toISOString(),
551
+ deletedAt: new Date().toISOString(),
552
+ })
553
+
554
+ const result = await authService.getCurrentUser(db, 'user-1')
555
+
556
+ expect(result.deletedAt).not.toBeNull()
557
+ })
558
+ })
559
+
560
+ describe('findOrCreateUser failure cases', () => {
561
+ it('should throw when user creation fails', async () => {
562
+ const env = createMockEnv()
563
+ const googleUser: GoogleUserInfo = {
564
+ sub: 'new_google_sub',
565
+ email: 'user@example.com',
566
+ email_verified: true,
567
+ name: 'User',
568
+ picture: undefined,
569
+ given_name: 'User',
570
+ family_name: '',
571
+ }
572
+
573
+ // First call: no existing user found
574
+ // Second call: insertUserSql returns null (user creation fails)
575
+ ;(queryOne as Mock)
576
+ .mockResolvedValueOnce(null)
577
+ .mockResolvedValueOnce(null)
578
+
579
+ await expect(
580
+ authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
581
+ ).rejects.toThrow('Failed to create user')
582
+ })
583
+
584
+ it('should update user when name changes', async () => {
585
+ const env = createMockEnv()
586
+ const googleUser: GoogleUserInfo = {
587
+ sub: 'existing_sub',
588
+ email: 'same@example.com',
589
+ email_verified: true,
590
+ name: 'New Name',
591
+ picture: 'https://same-picture.jpg',
592
+ given_name: 'New',
593
+ family_name: 'Name',
594
+ }
595
+
596
+ const userRow = {
597
+ id: 'user-1',
598
+ googleId: googleUser.sub,
599
+ email: googleUser.email,
600
+ name: 'Old Name',
601
+ avatarUrl: googleUser.picture,
602
+ status: 'active',
603
+ providerIds: JSON.stringify(['google']),
604
+ isSuperAdmin: 0,
605
+ createdAt: new Date().toISOString(),
606
+ updatedAt: new Date().toISOString(),
607
+ deletedAt: null,
608
+ }
609
+
610
+ ;(queryOne as Mock)
611
+ .mockResolvedValueOnce(userRow)
612
+ .mockResolvedValueOnce({ ...userRow, name: googleUser.name })
613
+
614
+ const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
615
+
616
+ expect(result.isNewUser).toBe(false)
617
+ expect(execute).toHaveBeenCalled()
618
+ })
619
+
620
+ it('should update user when avatar changes', async () => {
621
+ const env = createMockEnv()
622
+ const googleUser: GoogleUserInfo = {
623
+ sub: 'existing_sub',
624
+ email: 'same@example.com',
625
+ email_verified: true,
626
+ name: 'Same Name',
627
+ picture: 'https://new-picture.jpg',
628
+ given_name: 'Same',
629
+ family_name: 'Name',
630
+ }
631
+
632
+ const userRow = {
633
+ id: 'user-1',
634
+ googleId: googleUser.sub,
635
+ email: googleUser.email,
636
+ name: googleUser.name,
637
+ avatarUrl: 'https://old-picture.jpg',
638
+ status: 'active',
639
+ providerIds: JSON.stringify(['google']),
640
+ isSuperAdmin: 0,
641
+ createdAt: new Date().toISOString(),
642
+ updatedAt: new Date().toISOString(),
643
+ deletedAt: null,
644
+ }
645
+
646
+ ;(queryOne as Mock)
647
+ .mockResolvedValueOnce(userRow)
648
+ .mockResolvedValueOnce({ ...userRow, avatarUrl: googleUser.picture })
649
+
650
+ const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
651
+
652
+ expect(result.isNewUser).toBe(false)
653
+ expect(execute).toHaveBeenCalled()
654
+ })
178
655
  })
179
656
  })
@@ -17,6 +17,17 @@ vi.mock('@server/db/sql', () => ({
17
17
  queryOne: vi.fn(),
18
18
  queryAll: vi.fn(),
19
19
  execute: vi.fn(),
20
+ toStringValue: (value: unknown) => {
21
+ if (typeof value === 'string') return value
22
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
23
+ return ''
24
+ },
25
+ toNullableString: (value: unknown) => {
26
+ if (value === null || value === undefined) return null
27
+ if (typeof value === 'string') return value
28
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
29
+ return null
30
+ },
20
31
  }))
21
32
 
22
33
  import { sendInvitationEmail } from '@server/lib/email'
@@ -60,6 +71,60 @@ describe('invitationsService', () => {
60
71
  })
61
72
 
62
73
  describe('create', () => {
74
+ it('should throw ForbiddenError when user has no role', async () => {
75
+ const env = createMockEnv()
76
+ const ctxWithoutRole = createMockContext({ userRole: undefined })
77
+
78
+ await expect(
79
+ invitationsService.create(db, env, ctxWithoutRole, {
80
+ email: 'invite@example.com',
81
+ role: 'VIEWER',
82
+ })
83
+ ).rejects.toThrow('User must have a role in this account to invite others')
84
+ })
85
+
86
+ it('should throw ForbiddenError when assigning higher role than own', async () => {
87
+ const env = createMockEnv()
88
+ const ctxAsViewer = createMockContext({ userRole: 'VIEWER' })
89
+
90
+ await expect(
91
+ invitationsService.create(db, env, ctxAsViewer, {
92
+ email: 'invite@example.com',
93
+ role: 'ADMIN',
94
+ })
95
+ ).rejects.toThrow('Cannot assign a role higher than your own')
96
+ })
97
+
98
+ it('should throw ConflictError when user is already a member', async () => {
99
+ const env = createMockEnv()
100
+
101
+ ;(queryOne as Mock).mockResolvedValueOnce({ ok: 1 })
102
+
103
+ await expect(
104
+ invitationsService.create(db, env, ctx, {
105
+ email: 'member@example.com',
106
+ role: 'VIEWER',
107
+ })
108
+ ).rejects.toThrow('User is already a member of this account')
109
+ })
110
+
111
+ it('should throw Error when account not found', async () => {
112
+ const env = createMockEnv()
113
+
114
+ ;(queryOne as Mock)
115
+ .mockResolvedValueOnce(null)
116
+ .mockResolvedValueOnce(null)
117
+ .mockResolvedValueOnce(null)
118
+ .mockResolvedValueOnce(null)
119
+
120
+ await expect(
121
+ invitationsService.create(db, env, ctx, {
122
+ email: 'invite@example.com',
123
+ role: 'VIEWER',
124
+ })
125
+ ).rejects.toThrow('Account not found')
126
+ })
127
+
63
128
  it('should link existing user instead of creating invitation', async () => {
64
129
  const env = createMockEnv()
65
130
  const existingUser = createUserFixture({ id: 'user-1', email: 'user@example.com' })
@@ -161,5 +226,118 @@ describe('invitationsService', () => {
161
226
  expect(execute).toHaveBeenCalled()
162
227
  expect(logAuthEvent).toHaveBeenCalled()
163
228
  })
229
+
230
+ it('should throw NotFoundError when invitation not found', async () => {
231
+ ;(queryOne as Mock).mockResolvedValueOnce(null)
232
+
233
+ await expect(
234
+ invitationsService.accept(db, 'inv-999', 'user-1', { transactionId: 'tx' })
235
+ ).rejects.toThrow(NotFoundError)
236
+ })
237
+
238
+ it('should handle snake_case column names', async () => {
239
+ ;(queryOne as Mock).mockResolvedValueOnce({ id: 'inv-1', account_id: 'account-456', role: 'EDITOR' })
240
+
241
+ await invitationsService.accept(db, 'inv-1', 'user-1', { transactionId: 'tx' })
242
+
243
+ expect(execute).toHaveBeenCalled()
244
+ })
245
+ })
246
+
247
+ describe('getByToken', () => {
248
+ it('should return null when token not found', async () => {
249
+ ;(queryOne as Mock).mockResolvedValueOnce(null)
250
+
251
+ const result = await invitationsService.getByToken(db, 'invalid-token')
252
+
253
+ expect(result).toBeNull()
254
+ })
255
+
256
+ it('should return null when invitation already accepted', async () => {
257
+ ;(queryOne as Mock).mockResolvedValueOnce({
258
+ id: 'inv-1',
259
+ accountId: 'account-123',
260
+ email: 'test@example.com',
261
+ role: 'VIEWER',
262
+ acceptedAt: new Date().toISOString(),
263
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
264
+ accountName: 'Test Account',
265
+ })
266
+
267
+ const result = await invitationsService.getByToken(db, 'accepted-token')
268
+
269
+ expect(result).toBeNull()
270
+ })
271
+
272
+ it('should return null when invitation expired', async () => {
273
+ ;(queryOne as Mock).mockResolvedValueOnce({
274
+ id: 'inv-1',
275
+ accountId: 'account-123',
276
+ email: 'test@example.com',
277
+ role: 'VIEWER',
278
+ acceptedAt: null,
279
+ expiresAt: new Date(Date.now() - 86400000).toISOString(),
280
+ accountName: 'Test Account',
281
+ })
282
+
283
+ const result = await invitationsService.getByToken(db, 'expired-token')
284
+
285
+ expect(result).toBeNull()
286
+ })
287
+
288
+ it('should return invitation when valid', async () => {
289
+ ;(queryOne as Mock).mockResolvedValueOnce({
290
+ id: 'inv-1',
291
+ accountId: 'account-123',
292
+ email: 'test@example.com',
293
+ role: 'VIEWER',
294
+ acceptedAt: null,
295
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
296
+ accountName: 'Test Account',
297
+ })
298
+
299
+ const result = await invitationsService.getByToken(db, 'valid-token')
300
+
301
+ expect(result).not.toBeNull()
302
+ expect(result?.email).toBe('test@example.com')
303
+ })
304
+
305
+ it('should handle snake_case column names from DB', async () => {
306
+ ;(queryOne as Mock).mockResolvedValueOnce({
307
+ id: 'inv-1',
308
+ account_id: 'account-123',
309
+ email: 'test@example.com',
310
+ role: 'VIEWER',
311
+ accepted_at: null,
312
+ expires_at: new Date(Date.now() + 86400000).toISOString(),
313
+ account_name: 'Test Account',
314
+ })
315
+
316
+ const result = await invitationsService.getByToken(db, 'valid-token')
317
+
318
+ expect(result).not.toBeNull()
319
+ expect(result?.accountId).toBe('account-123')
320
+ })
321
+ })
322
+
323
+ describe('list with snake_case mapping', () => {
324
+ it('should handle snake_case column names', async () => {
325
+ ;(queryAll as Mock).mockResolvedValueOnce([
326
+ {
327
+ id: 'inv-1',
328
+ email: 'invite@example.com',
329
+ role: 'VIEWER',
330
+ invited_by_id: 'user-1',
331
+ inviter_name: 'Inviter',
332
+ expires_at: new Date().toISOString(),
333
+ created_at: new Date().toISOString(),
334
+ },
335
+ ])
336
+
337
+ const result = await invitationsService.list(db, ctx)
338
+
339
+ expect(result).toHaveLength(1)
340
+ expect(result[0].invitedBy.id).toBe('user-1')
341
+ })
164
342
  })
165
343
  })