@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
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { Hono } from 'hono'
3
- import { requireRole, requirePermission } from '@server/guards'
3
+ import { requireRole, requirePermission } from '@server/auth/guards'
4
4
  import type { HonoEnv } from '@server/types'
5
5
 
6
6
  describe('guards', () => {
@@ -371,4 +371,277 @@ describe('jwtAuth', () => {
371
371
  isSuperAdmin: false,
372
372
  })
373
373
  })
374
+
375
+ it('handles snake_case column names from DB', async () => {
376
+ vi.mocked(verify).mockResolvedValue({
377
+ sub: 'user-123',
378
+ email: 'test@example.com',
379
+ iat: Date.now(),
380
+ exp: Date.now() + 3600000,
381
+ })
382
+
383
+ // Use snake_case column names as returned by SQLite
384
+ setMockQueryResult(mockDb, ['user-123'], {
385
+ id: 'user-123',
386
+ email: 'test@example.com',
387
+ name: 'Test User',
388
+ status: 'active',
389
+ provider_ids: JSON.stringify(['google']),
390
+ is_super_admin: 1,
391
+ created_at: new Date().toISOString(),
392
+ updated_at: new Date().toISOString(),
393
+ deleted_at: null,
394
+ })
395
+
396
+ const app = createApp()
397
+ const res = await app.request('/test', {
398
+ headers: { Authorization: 'Bearer valid-token' },
399
+ })
400
+ const body = await res.json()
401
+
402
+ expect(res.status).toBe(200)
403
+ expect(body.user.providerIds).toEqual(['google'])
404
+ expect(body.user.isSuperAdmin).toBe(true)
405
+ })
406
+
407
+ it('handles providerIds as JSON string', async () => {
408
+ vi.mocked(verify).mockResolvedValue({
409
+ sub: 'user-123',
410
+ email: 'test@example.com',
411
+ iat: Date.now(),
412
+ exp: Date.now() + 3600000,
413
+ })
414
+
415
+ setMockQueryResult(mockDb, ['user-123'], {
416
+ id: 'user-123',
417
+ email: 'test@example.com',
418
+ name: 'Test User',
419
+ status: 'active',
420
+ providerIds: '["google", "github"]',
421
+ isSuperAdmin: 0,
422
+ createdAt: new Date().toISOString(),
423
+ updatedAt: new Date().toISOString(),
424
+ deletedAt: null,
425
+ })
426
+
427
+ const app = createApp()
428
+ const res = await app.request('/test', {
429
+ headers: { Authorization: 'Bearer valid-token' },
430
+ })
431
+ const body = await res.json()
432
+
433
+ expect(res.status).toBe(200)
434
+ expect(body.user.providerIds).toEqual(['google', 'github'])
435
+ })
436
+
437
+ it('handles invalid JSON in providerIds', async () => {
438
+ vi.mocked(verify).mockResolvedValue({
439
+ sub: 'user-123',
440
+ email: 'test@example.com',
441
+ iat: Date.now(),
442
+ exp: Date.now() + 3600000,
443
+ })
444
+
445
+ setMockQueryResult(mockDb, ['user-123'], {
446
+ id: 'user-123',
447
+ email: 'test@example.com',
448
+ name: 'Test User',
449
+ status: 'active',
450
+ providerIds: 'invalid-json',
451
+ isSuperAdmin: 0,
452
+ createdAt: new Date().toISOString(),
453
+ updatedAt: new Date().toISOString(),
454
+ deletedAt: null,
455
+ })
456
+
457
+ const app = createApp()
458
+ const res = await app.request('/test', {
459
+ headers: { Authorization: 'Bearer valid-token' },
460
+ })
461
+ const body = await res.json()
462
+
463
+ expect(res.status).toBe(200)
464
+ expect(body.user.providerIds).toEqual([])
465
+ })
466
+
467
+ it('handles non-array JSON in providerIds', async () => {
468
+ vi.mocked(verify).mockResolvedValue({
469
+ sub: 'user-123',
470
+ email: 'test@example.com',
471
+ iat: Date.now(),
472
+ exp: Date.now() + 3600000,
473
+ })
474
+
475
+ setMockQueryResult(mockDb, ['user-123'], {
476
+ id: 'user-123',
477
+ email: 'test@example.com',
478
+ name: 'Test User',
479
+ status: 'active',
480
+ providerIds: '{"key": "value"}',
481
+ isSuperAdmin: 0,
482
+ createdAt: new Date().toISOString(),
483
+ updatedAt: new Date().toISOString(),
484
+ deletedAt: null,
485
+ })
486
+
487
+ const app = createApp()
488
+ const res = await app.request('/test', {
489
+ headers: { Authorization: 'Bearer valid-token' },
490
+ })
491
+ const body = await res.json()
492
+
493
+ expect(res.status).toBe(200)
494
+ expect(body.user.providerIds).toEqual([])
495
+ })
496
+
497
+ it('handles null providerIds', async () => {
498
+ vi.mocked(verify).mockResolvedValue({
499
+ sub: 'user-123',
500
+ email: 'test@example.com',
501
+ iat: Date.now(),
502
+ exp: Date.now() + 3600000,
503
+ })
504
+
505
+ setMockQueryResult(mockDb, ['user-123'], {
506
+ id: 'user-123',
507
+ email: 'test@example.com',
508
+ name: 'Test User',
509
+ status: 'active',
510
+ providerIds: null,
511
+ isSuperAdmin: 0,
512
+ createdAt: new Date().toISOString(),
513
+ updatedAt: new Date().toISOString(),
514
+ deletedAt: null,
515
+ })
516
+
517
+ const app = createApp()
518
+ const res = await app.request('/test', {
519
+ headers: { Authorization: 'Bearer valid-token' },
520
+ })
521
+ const body = await res.json()
522
+
523
+ expect(res.status).toBe(200)
524
+ expect(body.user.providerIds).toEqual([])
525
+ })
526
+
527
+ it('handles isSuperAdmin as boolean true', async () => {
528
+ vi.mocked(verify).mockResolvedValue({
529
+ sub: 'user-123',
530
+ email: 'test@example.com',
531
+ iat: Date.now(),
532
+ exp: Date.now() + 3600000,
533
+ })
534
+
535
+ setMockQueryResult(mockDb, ['user-123'], {
536
+ id: 'user-123',
537
+ email: 'test@example.com',
538
+ name: 'Test User',
539
+ status: 'active',
540
+ providerIds: [],
541
+ isSuperAdmin: true,
542
+ createdAt: new Date().toISOString(),
543
+ updatedAt: new Date().toISOString(),
544
+ deletedAt: null,
545
+ })
546
+
547
+ const app = createApp()
548
+ const res = await app.request('/test', {
549
+ headers: { Authorization: 'Bearer valid-token' },
550
+ })
551
+ const body = await res.json()
552
+
553
+ expect(res.status).toBe(200)
554
+ expect(body.user.isSuperAdmin).toBe(true)
555
+ })
556
+
557
+ it('handles isSuperAdmin as string "1"', async () => {
558
+ vi.mocked(verify).mockResolvedValue({
559
+ sub: 'user-123',
560
+ email: 'test@example.com',
561
+ iat: Date.now(),
562
+ exp: Date.now() + 3600000,
563
+ })
564
+
565
+ setMockQueryResult(mockDb, ['user-123'], {
566
+ id: 'user-123',
567
+ email: 'test@example.com',
568
+ name: 'Test User',
569
+ status: 'active',
570
+ providerIds: [],
571
+ isSuperAdmin: '1',
572
+ createdAt: new Date().toISOString(),
573
+ updatedAt: new Date().toISOString(),
574
+ deletedAt: null,
575
+ })
576
+
577
+ const app = createApp()
578
+ const res = await app.request('/test', {
579
+ headers: { Authorization: 'Bearer valid-token' },
580
+ })
581
+ const body = await res.json()
582
+
583
+ expect(res.status).toBe(200)
584
+ expect(body.user.isSuperAdmin).toBe(true)
585
+ })
586
+
587
+ it('handles isSuperAdmin as string "true"', async () => {
588
+ vi.mocked(verify).mockResolvedValue({
589
+ sub: 'user-123',
590
+ email: 'test@example.com',
591
+ iat: Date.now(),
592
+ exp: Date.now() + 3600000,
593
+ })
594
+
595
+ setMockQueryResult(mockDb, ['user-123'], {
596
+ id: 'user-123',
597
+ email: 'test@example.com',
598
+ name: 'Test User',
599
+ status: 'active',
600
+ providerIds: [],
601
+ isSuperAdmin: 'true',
602
+ createdAt: new Date().toISOString(),
603
+ updatedAt: new Date().toISOString(),
604
+ deletedAt: null,
605
+ })
606
+
607
+ const app = createApp()
608
+ const res = await app.request('/test', {
609
+ headers: { Authorization: 'Bearer valid-token' },
610
+ })
611
+ const body = await res.json()
612
+
613
+ expect(res.status).toBe(200)
614
+ expect(body.user.isSuperAdmin).toBe(true)
615
+ })
616
+
617
+ it('handles deletedAt with value', async () => {
618
+ vi.mocked(verify).mockResolvedValue({
619
+ sub: 'user-123',
620
+ email: 'test@example.com',
621
+ iat: Date.now(),
622
+ exp: Date.now() + 3600000,
623
+ })
624
+
625
+ const deletedDate = new Date().toISOString()
626
+ setMockQueryResult(mockDb, ['user-123'], {
627
+ id: 'user-123',
628
+ email: 'test@example.com',
629
+ name: 'Test User',
630
+ status: 'active',
631
+ providerIds: [],
632
+ isSuperAdmin: 0,
633
+ createdAt: new Date().toISOString(),
634
+ updatedAt: new Date().toISOString(),
635
+ deleted_at: deletedDate,
636
+ })
637
+
638
+ const app = createApp()
639
+ const res = await app.request('/test', {
640
+ headers: { Authorization: 'Bearer valid-token' },
641
+ })
642
+ const body = await res.json()
643
+
644
+ expect(res.status).toBe(200)
645
+ expect(body.user.deletedAt).toBe(deletedDate)
646
+ })
374
647
  })
@@ -167,6 +167,20 @@ describe('Auth Routes', () => {
167
167
  expect(body).toContain('Invalid state parameter')
168
168
  })
169
169
 
170
+ it('returns 400 when oauth_state cookie is invalid JSON', async () => {
171
+ const app = createApp()
172
+ const res = await app.request('/auth/callback?code=test-code&state=test-state', {
173
+ method: 'GET',
174
+ headers: {
175
+ Cookie: 'oauth_state=invalid-json-string',
176
+ },
177
+ })
178
+
179
+ expect(res.status).toBe(400)
180
+ const body = await res.text()
181
+ expect(body).toContain('Invalid OAuth state cookie')
182
+ })
183
+
170
184
  it('creates session on successful callback', async () => {
171
185
  const testUser = createUserFixture({ id: 'user-123', email: 'test@example.com' })
172
186
 
@@ -207,6 +221,103 @@ describe('Auth Routes', () => {
207
221
  expect(res.status).toBe(302)
208
222
  expect(res.headers.get('Location')).toContain('/dashboard')
209
223
  })
224
+
225
+ it('accepts pending invitation during callback', async () => {
226
+ const testUser = createUserFixture({ id: 'user-123', email: 'test@example.com' })
227
+
228
+ vi.mocked(exchangeCodeForTokens).mockResolvedValue({
229
+ access_token: 'test-access-token',
230
+ id_token: 'test-id-token',
231
+ refresh_token: 'test-refresh-token',
232
+ expires_in: 3600,
233
+ token_type: 'Bearer',
234
+ })
235
+
236
+ vi.mocked(decodeIdToken).mockReturnValue({
237
+ sub: 'google-123',
238
+ email: 'test@example.com',
239
+ name: 'Test User',
240
+ picture: 'https://example.com/avatar.jpg',
241
+ email_verified: true,
242
+ })
243
+
244
+ vi.mocked(authService.findOrCreateUser).mockResolvedValue({
245
+ user: testUser,
246
+ isNew: false,
247
+ })
248
+
249
+ vi.mocked(invitationsService.getByToken).mockResolvedValue({
250
+ id: 'invite-123',
251
+ token: 'valid-invite-token',
252
+ email: 'test@example.com',
253
+ role: 'VIEWER',
254
+ accountId: 'account-1',
255
+ status: 'pending',
256
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
257
+ createdAt: new Date().toISOString(),
258
+ updatedAt: new Date().toISOString(),
259
+ })
260
+
261
+ vi.mocked(invitationsService.accept).mockResolvedValue()
262
+
263
+ const app = createApp()
264
+ const res = await app.request('/auth/callback?code=test-code&state=test-state', {
265
+ method: 'GET',
266
+ headers: {
267
+ Cookie: 'oauth_state=' + encodeURIComponent(JSON.stringify({
268
+ codeVerifier: 'test-verifier',
269
+ state: 'test-state',
270
+ redirect: null,
271
+ })) + '; pending_invitation=valid-invite-token',
272
+ },
273
+ redirect: 'manual',
274
+ })
275
+
276
+ expect(res.status).toBe(302)
277
+ expect(invitationsService.getByToken).toHaveBeenCalledWith(expect.anything(), 'valid-invite-token')
278
+ expect(invitationsService.accept).toHaveBeenCalled()
279
+ })
280
+
281
+ it('uses custom redirect from oauth state', async () => {
282
+ const testUser = createUserFixture({ id: 'user-123', email: 'test@example.com' })
283
+
284
+ vi.mocked(exchangeCodeForTokens).mockResolvedValue({
285
+ access_token: 'test-access-token',
286
+ id_token: 'test-id-token',
287
+ refresh_token: 'test-refresh-token',
288
+ expires_in: 3600,
289
+ token_type: 'Bearer',
290
+ })
291
+
292
+ vi.mocked(decodeIdToken).mockReturnValue({
293
+ sub: 'google-123',
294
+ email: 'test@example.com',
295
+ name: 'Test User',
296
+ picture: 'https://example.com/avatar.jpg',
297
+ email_verified: true,
298
+ })
299
+
300
+ vi.mocked(authService.findOrCreateUser).mockResolvedValue({
301
+ user: testUser,
302
+ isNew: false,
303
+ })
304
+
305
+ const app = createApp()
306
+ const res = await app.request('/auth/callback?code=test-code&state=test-state', {
307
+ method: 'GET',
308
+ headers: {
309
+ Cookie: 'oauth_state=' + encodeURIComponent(JSON.stringify({
310
+ codeVerifier: 'test-verifier',
311
+ state: 'test-state',
312
+ redirect: '/settings',
313
+ })),
314
+ },
315
+ redirect: 'manual',
316
+ })
317
+
318
+ expect(res.status).toBe(302)
319
+ expect(res.headers.get('Location')).toContain('/settings')
320
+ })
210
321
  })
211
322
 
212
323
  describe('POST /auth/logout', () => {
@@ -7,9 +7,9 @@ import { createMockEnv } from '@tests/helpers/server'
7
7
  import {
8
8
  createUserFixture,
9
9
  createAccountFixture,
10
- createDeletedUserFixture,
11
10
  } from '@tests/fixtures/server'
12
11
  import { NotFoundError } from '@server/lib/errors'
12
+ import type { Role } from '@server/auth/roles'
13
13
 
14
14
  // Test UUIDs
15
15
  const TEST_USER_ID = '550e8400-e29b-41d4-a716-446655440001'
@@ -33,7 +33,6 @@ vi.mock('@server/services', () => ({
33
33
  import { usersService } from '@server/services'
34
34
 
35
35
  describe('Users Routes', () => {
36
- let app: Hono<HonoEnv>
37
36
  let mockEnv: ReturnType<typeof createMockEnv>
38
37
  let mockDb: any
39
38
  let testUser: ReturnType<typeof createUserFixture>
@@ -60,12 +59,10 @@ describe('Users Routes', () => {
60
59
  delete: vi.fn().mockReturnThis(),
61
60
  execute: vi.fn().mockResolvedValue({ rows: [] }),
62
61
  }
63
-
64
- app = new Hono<HonoEnv>()
65
62
  })
66
63
 
67
64
  // Helper to setup authenticated app with specific role
68
- function setupAuthenticatedApp(userRole = 'VIEWER', isSuperAdmin = false) {
65
+ function setupAuthenticatedApp(userRole: Role = 'VIEWER', isSuperAdmin = false) {
69
66
  const authenticatedApp = new Hono<HonoEnv>()
70
67
 
71
68
  authenticatedApp.use('*', async (c, next) => {
@@ -523,4 +520,71 @@ describe('Users Routes', () => {
523
520
  // with body may not work correctly in the Hono test environment.
524
521
  // This endpoint is tested via E2E tests instead.
525
522
  // Role-based access control is tested via the requireRole middleware.
523
+
524
+ describe('Missing context error handling', () => {
525
+ it('GET /users/:id throws when context is missing', async () => {
526
+ const unauthApp = setupUnauthenticatedApp()
527
+
528
+ const res = await unauthApp.request(`/users/${TEST_USER_ID}`, {
529
+ method: 'GET',
530
+ })
531
+
532
+ expect(res.status).toBe(500)
533
+ expect(usersService.findById).not.toHaveBeenCalled()
534
+ })
535
+
536
+ it('PATCH /users/:id returns 401 when not authenticated', async () => {
537
+ const unauthApp = setupUnauthenticatedApp()
538
+
539
+ const res = await unauthApp.request(`/users/${TEST_USER_ID}`, {
540
+ method: 'PATCH',
541
+ headers: {
542
+ 'Content-Type': 'application/json',
543
+ },
544
+ body: JSON.stringify({ name: 'Updated' }),
545
+ })
546
+
547
+ expect(res.status).toBe(401)
548
+ expect(usersService.update).not.toHaveBeenCalled()
549
+ })
550
+
551
+ it('DELETE /users/:id returns 401 when not authenticated', async () => {
552
+ const unauthApp = setupUnauthenticatedApp()
553
+
554
+ const res = await unauthApp.request(`/users/${TEST_USER_ID}`, {
555
+ method: 'DELETE',
556
+ })
557
+
558
+ expect(res.status).toBe(401)
559
+ expect(usersService.delete).not.toHaveBeenCalled()
560
+ })
561
+
562
+ it('POST /users/:id/restore returns 401 when not authenticated', async () => {
563
+ const unauthApp = setupUnauthenticatedApp()
564
+
565
+ const res = await unauthApp.request(`/users/${TEST_USER_ID}/restore`, {
566
+ method: 'POST',
567
+ })
568
+
569
+ expect(res.status).toBe(401)
570
+ expect(usersService.restore).not.toHaveBeenCalled()
571
+ })
572
+
573
+ it('POST /users/accounts returns 401 when not authenticated', async () => {
574
+ const unauthApp = setupUnauthenticatedApp()
575
+
576
+ const res = await unauthApp.request('/users/accounts', {
577
+ method: 'POST',
578
+ headers: {
579
+ 'Content-Type': 'application/json',
580
+ },
581
+ body: JSON.stringify([
582
+ { userId: TEST_USER_ID, accountId: TEST_ACCOUNT_ID, role: 'VIEWER' },
583
+ ]),
584
+ })
585
+
586
+ expect(res.status).toBe(401)
587
+ expect(usersService.createUserAccounts).not.toHaveBeenCalled()
588
+ })
589
+ })
526
590
  })
@@ -20,6 +20,17 @@ vi.mock('@server/db/sql', () => ({
20
20
  queryOne: vi.fn(),
21
21
  queryAll: vi.fn(),
22
22
  execute: vi.fn(),
23
+ toStringValue: (value: unknown) => {
24
+ if (typeof value === 'string') return value
25
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
26
+ return ''
27
+ },
28
+ toNullableString: (value: unknown) => {
29
+ if (value === null || value === undefined) return null
30
+ if (typeof value === 'string') return value
31
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
32
+ return null
33
+ },
23
34
  }))
24
35
 
25
36
  import { auditedInsert, auditedUpdate, auditedDelete } from '@server/lib/audited-db'
@@ -104,6 +115,26 @@ describe('accountsService', () => {
104
115
 
105
116
  expect((queryOne as Mock).mock.calls[0][2]).toContain(ctx.user.id)
106
117
  })
118
+
119
+ it('should filter by query when provided', async () => {
120
+ ;(queryOne as Mock).mockResolvedValueOnce({ count: 1 })
121
+ ;(queryAll as Mock).mockResolvedValueOnce([])
122
+
123
+ await accountsService.findAll(db, superAdminCtx, { ...defaultPagination, query: 'test' })
124
+
125
+ // Query should include LIKE clauses for name and domain
126
+ expect((queryOne as Mock).mock.calls[0][2]).toContain('%test%')
127
+ })
128
+
129
+ it('should handle null count result', async () => {
130
+ ;(queryOne as Mock).mockResolvedValueOnce(null)
131
+ ;(queryAll as Mock).mockResolvedValueOnce([])
132
+
133
+ const result = await accountsService.findAll(db, superAdminCtx, defaultPagination)
134
+
135
+ expect(result.data).toHaveLength(0)
136
+ expect(result.meta.totalItems).toBe(0)
137
+ })
107
138
  })
108
139
 
109
140
  describe('findById', () => {
@@ -140,6 +171,22 @@ describe('accountsService', () => {
140
171
 
141
172
  await expect(accountsService.findById(db, ctx, account.id)).rejects.toThrow(NotFoundError)
142
173
  })
174
+
175
+ it('should allow non-super-admin with valid membership', async () => {
176
+ const account = createAccountFixture({ id: 'account-1' })
177
+ ;(queryOne as Mock)
178
+ .mockResolvedValueOnce({
179
+ ...account,
180
+ created_at: account.createdAt,
181
+ updated_at: account.updatedAt,
182
+ deleted_at: account.deletedAt,
183
+ })
184
+ .mockResolvedValueOnce({ ok: 1 })
185
+
186
+ const result = await accountsService.findById(db, ctx, account.id)
187
+
188
+ expect(result.id).toBe(account.id)
189
+ })
143
190
  })
144
191
 
145
192
  describe('create', () => {
@@ -166,6 +213,27 @@ describe('accountsService', () => {
166
213
  expect(result.name).toBe('New Account')
167
214
  expect(auditedInsert).toHaveBeenCalled()
168
215
  })
216
+
217
+ it('should create account with domain when no conflict', async () => {
218
+ ;(queryOne as Mock).mockResolvedValueOnce(null)
219
+
220
+ const account = createAccountFixture({ id: 'account-1', name: 'New Account', domain: 'unique.com' })
221
+ ;(auditedInsert as Mock).mockResolvedValueOnce([
222
+ { ...account, created_at: account.createdAt, updated_at: account.updatedAt, deleted_at: account.deletedAt },
223
+ ])
224
+
225
+ const result = await accountsService.create(db, superAdminCtx, { name: 'New Account', domain: 'unique.com' })
226
+
227
+ expect(result.domain).toBe('unique.com')
228
+ })
229
+
230
+ it('should throw when auditedInsert returns empty array', async () => {
231
+ ;(auditedInsert as Mock).mockResolvedValueOnce([])
232
+
233
+ await expect(
234
+ accountsService.create(db, superAdminCtx, { name: 'New Account' })
235
+ ).rejects.toThrow('Failed to create account')
236
+ })
169
237
  })
170
238
 
171
239
  describe('update', () => {
@@ -209,6 +277,67 @@ describe('accountsService', () => {
209
277
  expect(result.name).toBe('Updated')
210
278
  expect(auditedUpdate).toHaveBeenCalled()
211
279
  })
280
+
281
+ it('should update with domain when no conflict', async () => {
282
+ const account = createAccountFixture({ id: 'account-1' })
283
+ ;(queryOne as Mock)
284
+ .mockResolvedValueOnce({
285
+ ...account,
286
+ created_at: account.createdAt,
287
+ updated_at: account.updatedAt,
288
+ deleted_at: account.deletedAt,
289
+ })
290
+ .mockResolvedValueOnce(null)
291
+
292
+ const updated = { ...account, domain: 'new.com' }
293
+ ;(auditedUpdate as Mock).mockResolvedValueOnce([
294
+ { ...updated, created_at: updated.createdAt, updated_at: updated.updatedAt, deleted_at: updated.deletedAt },
295
+ ])
296
+
297
+ const result = await accountsService.update(db, superAdminCtx, account.id, { domain: 'new.com' })
298
+
299
+ expect(result.domain).toBe('new.com')
300
+ })
301
+
302
+ it('should update description and domain', async () => {
303
+ const account = createAccountFixture({ id: 'account-1' })
304
+ ;(queryOne as Mock)
305
+ .mockResolvedValueOnce({
306
+ ...account,
307
+ created_at: account.createdAt,
308
+ updated_at: account.updatedAt,
309
+ deleted_at: account.deletedAt,
310
+ })
311
+ .mockResolvedValueOnce(null)
312
+
313
+ const updated = { ...account, description: 'New desc', domain: 'new.com' }
314
+ ;(auditedUpdate as Mock).mockResolvedValueOnce([
315
+ { ...updated, created_at: updated.createdAt, updated_at: updated.updatedAt, deleted_at: updated.deletedAt },
316
+ ])
317
+
318
+ const result = await accountsService.update(db, superAdminCtx, account.id, {
319
+ description: 'New desc',
320
+ domain: 'new.com',
321
+ })
322
+
323
+ expect(result.description).toBe('New desc')
324
+ expect(result.domain).toBe('new.com')
325
+ })
326
+
327
+ it('should throw when auditedUpdate returns empty array', async () => {
328
+ const account = createAccountFixture({ id: 'account-1' })
329
+ ;(queryOne as Mock).mockResolvedValueOnce({
330
+ ...account,
331
+ created_at: account.createdAt,
332
+ updated_at: account.updatedAt,
333
+ deleted_at: account.deletedAt,
334
+ })
335
+ ;(auditedUpdate as Mock).mockResolvedValueOnce([])
336
+
337
+ await expect(
338
+ accountsService.update(db, superAdminCtx, account.id, { name: 'Updated' })
339
+ ).rejects.toThrow('Failed to update account')
340
+ })
212
341
  })
213
342
 
214
343
  describe('delete', () => {
@@ -254,5 +383,24 @@ describe('accountsService', () => {
254
383
 
255
384
  expect(result.deletedAt).toBeNull()
256
385
  })
386
+
387
+ it('should throw NotFoundError when account not found or not deleted', async () => {
388
+ ;(queryOne as Mock).mockResolvedValueOnce(null)
389
+
390
+ await expect(accountsService.restore(db, superAdminCtx, 'not-deleted')).rejects.toThrow(NotFoundError)
391
+ })
392
+
393
+ it('should throw when auditedUpdate returns empty array', async () => {
394
+ const deleted = createDeletedAccountFixture({ id: 'account-1' })
395
+ ;(queryOne as Mock).mockResolvedValueOnce({
396
+ ...deleted,
397
+ created_at: deleted.createdAt,
398
+ updated_at: deleted.updatedAt,
399
+ deleted_at: deleted.deletedAt,
400
+ })
401
+ ;(auditedUpdate as Mock).mockResolvedValueOnce([])
402
+
403
+ await expect(accountsService.restore(db, superAdminCtx, deleted.id)).rejects.toThrow(NotFoundError)
404
+ })
257
405
  })
258
406
  })