create-blitzpack 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 (259) hide show
  1. package/dist/index.js +452 -0
  2. package/package.json +57 -0
  3. package/template/.dockerignore +59 -0
  4. package/template/.github/workflows/ci.yml +157 -0
  5. package/template/.husky/pre-commit +1 -0
  6. package/template/.husky/pre-push +1 -0
  7. package/template/.lintstagedrc.cjs +4 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/.prettierrc +9 -0
  10. package/template/.vscode/settings.json +13 -0
  11. package/template/CLAUDE.md +175 -0
  12. package/template/CONTRIBUTING.md +32 -0
  13. package/template/Dockerfile +90 -0
  14. package/template/GETTING_STARTED.md +35 -0
  15. package/template/LICENSE +21 -0
  16. package/template/README.md +116 -0
  17. package/template/apps/api/.dockerignore +51 -0
  18. package/template/apps/api/.env.local.example +62 -0
  19. package/template/apps/api/emails/account-deleted-email.tsx +69 -0
  20. package/template/apps/api/emails/components/email-layout.tsx +154 -0
  21. package/template/apps/api/emails/config.ts +22 -0
  22. package/template/apps/api/emails/password-changed-email.tsx +88 -0
  23. package/template/apps/api/emails/password-reset-email.tsx +86 -0
  24. package/template/apps/api/emails/verification-email.tsx +85 -0
  25. package/template/apps/api/emails/welcome-email.tsx +70 -0
  26. package/template/apps/api/package.json +84 -0
  27. package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +13 -0
  28. package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +67 -0
  29. package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +5 -0
  30. package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +7 -0
  31. package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +2 -0
  32. package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +26 -0
  33. package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +2 -0
  34. package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +10 -0
  35. package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +29 -0
  36. package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +8 -0
  37. package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +11 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +116 -0
  40. package/template/apps/api/prisma/seed.ts +159 -0
  41. package/template/apps/api/prisma.config.ts +14 -0
  42. package/template/apps/api/src/app.ts +377 -0
  43. package/template/apps/api/src/common/logger.service.ts +227 -0
  44. package/template/apps/api/src/config/env.ts +60 -0
  45. package/template/apps/api/src/config/rate-limit.ts +29 -0
  46. package/template/apps/api/src/hooks/auth.ts +122 -0
  47. package/template/apps/api/src/plugins/auth.ts +198 -0
  48. package/template/apps/api/src/plugins/database.ts +45 -0
  49. package/template/apps/api/src/plugins/logger.ts +33 -0
  50. package/template/apps/api/src/plugins/multipart.ts +16 -0
  51. package/template/apps/api/src/plugins/scalar.ts +20 -0
  52. package/template/apps/api/src/plugins/schedule.ts +52 -0
  53. package/template/apps/api/src/plugins/services.ts +66 -0
  54. package/template/apps/api/src/plugins/swagger.ts +56 -0
  55. package/template/apps/api/src/routes/accounts.ts +91 -0
  56. package/template/apps/api/src/routes/admin-sessions.ts +92 -0
  57. package/template/apps/api/src/routes/metrics.ts +71 -0
  58. package/template/apps/api/src/routes/password.ts +46 -0
  59. package/template/apps/api/src/routes/sessions.ts +53 -0
  60. package/template/apps/api/src/routes/stats.ts +38 -0
  61. package/template/apps/api/src/routes/uploads-serve.ts +27 -0
  62. package/template/apps/api/src/routes/uploads.ts +154 -0
  63. package/template/apps/api/src/routes/users.ts +114 -0
  64. package/template/apps/api/src/routes/verification.ts +90 -0
  65. package/template/apps/api/src/server.ts +34 -0
  66. package/template/apps/api/src/services/accounts.service.ts +125 -0
  67. package/template/apps/api/src/services/authorization.service.ts +162 -0
  68. package/template/apps/api/src/services/email.service.ts +170 -0
  69. package/template/apps/api/src/services/file-storage.service.ts +267 -0
  70. package/template/apps/api/src/services/metrics.service.ts +175 -0
  71. package/template/apps/api/src/services/password.service.ts +56 -0
  72. package/template/apps/api/src/services/sessions.service.spec.ts +134 -0
  73. package/template/apps/api/src/services/sessions.service.ts +276 -0
  74. package/template/apps/api/src/services/stats.service.ts +273 -0
  75. package/template/apps/api/src/services/uploads.service.ts +163 -0
  76. package/template/apps/api/src/services/users.service.spec.ts +249 -0
  77. package/template/apps/api/src/services/users.service.ts +198 -0
  78. package/template/apps/api/src/utils/file-validation.ts +108 -0
  79. package/template/apps/api/start.sh +33 -0
  80. package/template/apps/api/test/helpers/fastify-app.ts +24 -0
  81. package/template/apps/api/test/helpers/mock-authorization.ts +16 -0
  82. package/template/apps/api/test/helpers/mock-logger.ts +28 -0
  83. package/template/apps/api/test/helpers/mock-prisma.ts +30 -0
  84. package/template/apps/api/test/helpers/test-db.ts +125 -0
  85. package/template/apps/api/test/integration/auth-flow.integration.spec.ts +449 -0
  86. package/template/apps/api/test/integration/password.integration.spec.ts +427 -0
  87. package/template/apps/api/test/integration/rate-limit.integration.spec.ts +51 -0
  88. package/template/apps/api/test/integration/sessions.integration.spec.ts +445 -0
  89. package/template/apps/api/test/integration/users.integration.spec.ts +211 -0
  90. package/template/apps/api/test/setup.ts +31 -0
  91. package/template/apps/api/tsconfig.json +26 -0
  92. package/template/apps/api/vitest.config.ts +35 -0
  93. package/template/apps/web/.env.local.example +11 -0
  94. package/template/apps/web/components.json +24 -0
  95. package/template/apps/web/next.config.ts +22 -0
  96. package/template/apps/web/package.json +56 -0
  97. package/template/apps/web/postcss.config.js +5 -0
  98. package/template/apps/web/public/apple-icon.png +0 -0
  99. package/template/apps/web/public/icon.png +0 -0
  100. package/template/apps/web/public/robots.txt +3 -0
  101. package/template/apps/web/src/app/(admin)/admin/layout.tsx +222 -0
  102. package/template/apps/web/src/app/(admin)/admin/page.tsx +157 -0
  103. package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +18 -0
  104. package/template/apps/web/src/app/(admin)/admin/users/page.tsx +20 -0
  105. package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +177 -0
  106. package/template/apps/web/src/app/(auth)/login/page.tsx +159 -0
  107. package/template/apps/web/src/app/(auth)/reset-password/page.tsx +245 -0
  108. package/template/apps/web/src/app/(auth)/signup/page.tsx +153 -0
  109. package/template/apps/web/src/app/dashboard/change-password/page.tsx +255 -0
  110. package/template/apps/web/src/app/dashboard/page.tsx +296 -0
  111. package/template/apps/web/src/app/error.tsx +32 -0
  112. package/template/apps/web/src/app/examples/file-upload/page.tsx +200 -0
  113. package/template/apps/web/src/app/favicon.ico +0 -0
  114. package/template/apps/web/src/app/global-error.tsx +96 -0
  115. package/template/apps/web/src/app/globals.css +22 -0
  116. package/template/apps/web/src/app/icon.png +0 -0
  117. package/template/apps/web/src/app/layout.tsx +34 -0
  118. package/template/apps/web/src/app/not-found.tsx +28 -0
  119. package/template/apps/web/src/app/page.tsx +192 -0
  120. package/template/apps/web/src/components/admin/activity-feed.tsx +101 -0
  121. package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +114 -0
  122. package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +124 -0
  123. package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +511 -0
  124. package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +102 -0
  125. package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +90 -0
  126. package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +108 -0
  127. package/template/apps/web/src/components/admin/health-indicator.tsx +175 -0
  128. package/template/apps/web/src/components/admin/refresh-control.tsx +90 -0
  129. package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +79 -0
  130. package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +74 -0
  131. package/template/apps/web/src/components/admin/sessions-management-table.tsx +372 -0
  132. package/template/apps/web/src/components/admin/stat-card.tsx +137 -0
  133. package/template/apps/web/src/components/admin/user-create-dialog.tsx +152 -0
  134. package/template/apps/web/src/components/admin/user-delete-dialog.tsx +73 -0
  135. package/template/apps/web/src/components/admin/user-edit-dialog.tsx +170 -0
  136. package/template/apps/web/src/components/admin/users-management-table.tsx +285 -0
  137. package/template/apps/web/src/components/auth/email-verification-banner.tsx +85 -0
  138. package/template/apps/web/src/components/auth/github-button.tsx +40 -0
  139. package/template/apps/web/src/components/auth/google-button.tsx +54 -0
  140. package/template/apps/web/src/components/auth/protected-route.tsx +66 -0
  141. package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +31 -0
  142. package/template/apps/web/src/components/auth/with-auth.tsx +30 -0
  143. package/template/apps/web/src/components/error/error-card.tsx +47 -0
  144. package/template/apps/web/src/components/error/forbidden.tsx +25 -0
  145. package/template/apps/web/src/components/landing/command-block.tsx +64 -0
  146. package/template/apps/web/src/components/landing/feature-card.tsx +60 -0
  147. package/template/apps/web/src/components/landing/included-feature-card.tsx +63 -0
  148. package/template/apps/web/src/components/landing/logo.tsx +41 -0
  149. package/template/apps/web/src/components/landing/tech-badge.tsx +11 -0
  150. package/template/apps/web/src/components/layout/auth-nav.tsx +58 -0
  151. package/template/apps/web/src/components/layout/footer.tsx +3 -0
  152. package/template/apps/web/src/config/landing-data.ts +152 -0
  153. package/template/apps/web/src/config/site.ts +5 -0
  154. package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +181 -0
  155. package/template/apps/web/src/hooks/api/use-admin-sessions.ts +75 -0
  156. package/template/apps/web/src/hooks/api/use-admin-stats.ts +33 -0
  157. package/template/apps/web/src/hooks/api/use-sessions.ts +52 -0
  158. package/template/apps/web/src/hooks/api/use-uploads.ts +156 -0
  159. package/template/apps/web/src/hooks/api/use-users.ts +149 -0
  160. package/template/apps/web/src/hooks/use-mobile.ts +21 -0
  161. package/template/apps/web/src/hooks/use-realtime-metrics.ts +120 -0
  162. package/template/apps/web/src/lib/__tests__/utils.test.ts +29 -0
  163. package/template/apps/web/src/lib/api.ts +151 -0
  164. package/template/apps/web/src/lib/auth.ts +13 -0
  165. package/template/apps/web/src/lib/env.ts +52 -0
  166. package/template/apps/web/src/lib/form-utils.ts +11 -0
  167. package/template/apps/web/src/lib/utils.ts +1 -0
  168. package/template/apps/web/src/providers.tsx +34 -0
  169. package/template/apps/web/src/store/atoms.ts +15 -0
  170. package/template/apps/web/src/test/helpers/test-utils.tsx +44 -0
  171. package/template/apps/web/src/test/setup.ts +8 -0
  172. package/template/apps/web/tailwind.config.ts +5 -0
  173. package/template/apps/web/tsconfig.json +26 -0
  174. package/template/apps/web/vitest.config.ts +32 -0
  175. package/template/assets/logo-512.png +0 -0
  176. package/template/assets/logo.svg +4 -0
  177. package/template/docker-compose.prod.yml +66 -0
  178. package/template/docker-compose.yml +36 -0
  179. package/template/eslint.config.ts +119 -0
  180. package/template/package.json +77 -0
  181. package/template/packages/tailwind-config/package.json +9 -0
  182. package/template/packages/tailwind-config/theme.css +179 -0
  183. package/template/packages/types/package.json +29 -0
  184. package/template/packages/types/src/__tests__/schemas.test.ts +255 -0
  185. package/template/packages/types/src/api-response.ts +53 -0
  186. package/template/packages/types/src/health-check.ts +11 -0
  187. package/template/packages/types/src/pagination.ts +41 -0
  188. package/template/packages/types/src/role.ts +5 -0
  189. package/template/packages/types/src/session.ts +48 -0
  190. package/template/packages/types/src/stats.ts +113 -0
  191. package/template/packages/types/src/upload.ts +51 -0
  192. package/template/packages/types/src/user.ts +36 -0
  193. package/template/packages/types/tsconfig.json +5 -0
  194. package/template/packages/types/vitest.config.ts +21 -0
  195. package/template/packages/ui/components.json +21 -0
  196. package/template/packages/ui/package.json +108 -0
  197. package/template/packages/ui/src/__tests__/button.test.tsx +70 -0
  198. package/template/packages/ui/src/alert-dialog.tsx +141 -0
  199. package/template/packages/ui/src/alert.tsx +66 -0
  200. package/template/packages/ui/src/animated-theme-toggler.tsx +167 -0
  201. package/template/packages/ui/src/avatar.tsx +53 -0
  202. package/template/packages/ui/src/badge.tsx +36 -0
  203. package/template/packages/ui/src/button.tsx +84 -0
  204. package/template/packages/ui/src/card.tsx +92 -0
  205. package/template/packages/ui/src/checkbox.tsx +32 -0
  206. package/template/packages/ui/src/data-table/data-table-column-header.tsx +68 -0
  207. package/template/packages/ui/src/data-table/data-table-pagination.tsx +99 -0
  208. package/template/packages/ui/src/data-table/data-table-toolbar.tsx +55 -0
  209. package/template/packages/ui/src/data-table/data-table-view-options.tsx +63 -0
  210. package/template/packages/ui/src/data-table/data-table.tsx +167 -0
  211. package/template/packages/ui/src/dialog.tsx +143 -0
  212. package/template/packages/ui/src/dropdown-menu.tsx +257 -0
  213. package/template/packages/ui/src/empty-state.tsx +52 -0
  214. package/template/packages/ui/src/file-upload-input.tsx +202 -0
  215. package/template/packages/ui/src/form.tsx +168 -0
  216. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  217. package/template/packages/ui/src/icons/brand-icons.tsx +16 -0
  218. package/template/packages/ui/src/input.tsx +21 -0
  219. package/template/packages/ui/src/label.tsx +24 -0
  220. package/template/packages/ui/src/lib/utils.ts +6 -0
  221. package/template/packages/ui/src/password-input.tsx +102 -0
  222. package/template/packages/ui/src/popover.tsx +48 -0
  223. package/template/packages/ui/src/radio-group.tsx +45 -0
  224. package/template/packages/ui/src/scroll-area.tsx +58 -0
  225. package/template/packages/ui/src/select.tsx +187 -0
  226. package/template/packages/ui/src/separator.tsx +28 -0
  227. package/template/packages/ui/src/sheet.tsx +139 -0
  228. package/template/packages/ui/src/sidebar.tsx +726 -0
  229. package/template/packages/ui/src/skeleton-variants.tsx +87 -0
  230. package/template/packages/ui/src/skeleton.tsx +13 -0
  231. package/template/packages/ui/src/slider.tsx +63 -0
  232. package/template/packages/ui/src/sonner.tsx +25 -0
  233. package/template/packages/ui/src/spinner.tsx +16 -0
  234. package/template/packages/ui/src/switch.tsx +31 -0
  235. package/template/packages/ui/src/table.tsx +116 -0
  236. package/template/packages/ui/src/tabs.tsx +66 -0
  237. package/template/packages/ui/src/textarea.tsx +18 -0
  238. package/template/packages/ui/src/tooltip.tsx +61 -0
  239. package/template/packages/ui/src/user-avatar.tsx +97 -0
  240. package/template/packages/ui/test-config.js +3 -0
  241. package/template/packages/ui/tsconfig.json +12 -0
  242. package/template/packages/ui/turbo.json +18 -0
  243. package/template/packages/ui/vitest.config.ts +17 -0
  244. package/template/packages/ui/vitest.setup.ts +1 -0
  245. package/template/packages/utils/package.json +23 -0
  246. package/template/packages/utils/src/__tests__/utils.test.ts +223 -0
  247. package/template/packages/utils/src/array.ts +18 -0
  248. package/template/packages/utils/src/async.ts +3 -0
  249. package/template/packages/utils/src/date.ts +77 -0
  250. package/template/packages/utils/src/errors.ts +73 -0
  251. package/template/packages/utils/src/number.ts +11 -0
  252. package/template/packages/utils/src/string.ts +13 -0
  253. package/template/packages/utils/tsconfig.json +5 -0
  254. package/template/packages/utils/vitest.config.ts +21 -0
  255. package/template/pnpm-workspace.yaml +4 -0
  256. package/template/tsconfig.base.json +32 -0
  257. package/template/turbo.json +133 -0
  258. package/template/vitest.shared.ts +26 -0
  259. package/template/vitest.workspace.ts +9 -0
@@ -0,0 +1,445 @@
1
+ import { NotFoundError } from '@repo/packages-utils/errors';
2
+ import { createMockLogger } from '@test/helpers/mock-logger';
3
+ import { getTestPrisma, resetTestDatabase } from '@test/helpers/test-db';
4
+ import { beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import type { LoggerService } from '@/common/logger.service';
7
+ import { SessionsService } from '@/services/sessions.service';
8
+
9
+ describe('Sessions Service Integration Tests', () => {
10
+ let service: SessionsService;
11
+ let logger: LoggerService;
12
+
13
+ beforeEach(async () => {
14
+ await resetTestDatabase();
15
+
16
+ logger = createMockLogger();
17
+ const prisma = getTestPrisma();
18
+ service = new SessionsService(prisma);
19
+ });
20
+
21
+ describe('getUserSessions', () => {
22
+ it('should return all active sessions for a user', async () => {
23
+ const prisma = getTestPrisma();
24
+
25
+ // Create test user
26
+ const user = await prisma.user.create({
27
+ data: {
28
+ email: 'sessions@test.com',
29
+ name: 'Sessions User',
30
+ },
31
+ });
32
+
33
+ // Create multiple sessions
34
+ await prisma.session.createMany({
35
+ data: [
36
+ {
37
+ userId: user.id,
38
+ token: 'session-1',
39
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
40
+ ipAddress: '192.168.1.1',
41
+ userAgent: 'Chrome Desktop',
42
+ },
43
+ {
44
+ userId: user.id,
45
+ token: 'session-2',
46
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
47
+ ipAddress: '192.168.1.2',
48
+ userAgent: 'Safari Mobile',
49
+ },
50
+ {
51
+ userId: user.id,
52
+ token: 'session-3',
53
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
54
+ ipAddress: '10.0.0.1',
55
+ userAgent: 'Firefox Desktop',
56
+ },
57
+ ],
58
+ });
59
+
60
+ const sessions = await service.getUserSessions(user.id);
61
+
62
+ expect(sessions).toHaveLength(3);
63
+ expect(sessions[0].ipAddress).toBeDefined();
64
+ expect(sessions[0].userAgent).toBeDefined();
65
+ expect(sessions[0].expiresAt).toBeInstanceOf(Date);
66
+ });
67
+
68
+ it('should exclude expired sessions', async () => {
69
+ const prisma = getTestPrisma();
70
+
71
+ const user = await prisma.user.create({
72
+ data: {
73
+ email: 'expired@test.com',
74
+ name: 'Expired User',
75
+ },
76
+ });
77
+
78
+ // Create active and expired sessions
79
+ await prisma.session.createMany({
80
+ data: [
81
+ {
82
+ userId: user.id,
83
+ token: 'active-session',
84
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
85
+ },
86
+ {
87
+ userId: user.id,
88
+ token: 'expired-session-1',
89
+ expiresAt: new Date(Date.now() - 1000),
90
+ },
91
+ {
92
+ userId: user.id,
93
+ token: 'expired-session-2',
94
+ expiresAt: new Date(Date.now() - 5000),
95
+ },
96
+ ],
97
+ });
98
+
99
+ const sessions = await service.getUserSessions(user.id);
100
+
101
+ expect(sessions).toHaveLength(1);
102
+ expect(sessions[0].expiresAt.getTime()).toBeGreaterThan(Date.now());
103
+ });
104
+
105
+ it('should return sessions ordered by most recently updated', async () => {
106
+ const prisma = getTestPrisma();
107
+
108
+ const user = await prisma.user.create({
109
+ data: {
110
+ email: 'ordered@test.com',
111
+ name: 'Ordered User',
112
+ },
113
+ });
114
+
115
+ const now = Date.now();
116
+
117
+ // Create sessions with different update times
118
+ const session1 = await prisma.session.create({
119
+ data: {
120
+ userId: user.id,
121
+ token: 'oldest',
122
+ expiresAt: new Date(now + 7 * 24 * 60 * 60 * 1000),
123
+ updatedAt: new Date(now - 3000),
124
+ },
125
+ });
126
+
127
+ const session2 = await prisma.session.create({
128
+ data: {
129
+ userId: user.id,
130
+ token: 'middle',
131
+ expiresAt: new Date(now + 7 * 24 * 60 * 60 * 1000),
132
+ updatedAt: new Date(now - 2000),
133
+ },
134
+ });
135
+
136
+ const session3 = await prisma.session.create({
137
+ data: {
138
+ userId: user.id,
139
+ token: 'newest',
140
+ expiresAt: new Date(now + 7 * 24 * 60 * 60 * 1000),
141
+ updatedAt: new Date(now - 1000),
142
+ },
143
+ });
144
+
145
+ const sessions = await service.getUserSessions(user.id);
146
+
147
+ expect(sessions).toHaveLength(3);
148
+ expect(sessions[0].id).toBe(session3.id); // Newest first
149
+ expect(sessions[1].id).toBe(session2.id);
150
+ expect(sessions[2].id).toBe(session1.id);
151
+ });
152
+
153
+ it('should return empty array for user with no sessions', async () => {
154
+ const prisma = getTestPrisma();
155
+
156
+ const user = await prisma.user.create({
157
+ data: {
158
+ email: 'nosessions@test.com',
159
+ name: 'No Sessions User',
160
+ },
161
+ });
162
+
163
+ const sessions = await service.getUserSessions(user.id);
164
+
165
+ expect(sessions).toHaveLength(0);
166
+ });
167
+ });
168
+
169
+ describe('revokeSession', () => {
170
+ it('should delete a specific session', async () => {
171
+ const prisma = getTestPrisma();
172
+
173
+ const user = await prisma.user.create({
174
+ data: {
175
+ email: 'revoke@test.com',
176
+ name: 'Revoke User',
177
+ },
178
+ });
179
+
180
+ const session = await prisma.session.create({
181
+ data: {
182
+ userId: user.id,
183
+ token: 'to-revoke',
184
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
185
+ },
186
+ });
187
+
188
+ await service.revokeSession(user.id, session.id);
189
+
190
+ const deletedSession = await prisma.session.findUnique({
191
+ where: { id: session.id },
192
+ });
193
+
194
+ expect(deletedSession).toBeNull();
195
+ });
196
+
197
+ it('should throw NotFoundError when session does not exist', async () => {
198
+ const prisma = getTestPrisma();
199
+
200
+ const user = await prisma.user.create({
201
+ data: {
202
+ email: 'notfound@test.com',
203
+ name: 'Not Found User',
204
+ },
205
+ });
206
+
207
+ await expect(
208
+ service.revokeSession(user.id, 'non-existent-session-id')
209
+ ).rejects.toThrow(NotFoundError);
210
+ });
211
+
212
+ it('should throw NotFoundError when session belongs to different user', async () => {
213
+ const prisma = getTestPrisma();
214
+
215
+ const user1 = await prisma.user.create({
216
+ data: {
217
+ email: 'user1@test.com',
218
+ name: 'User 1',
219
+ },
220
+ });
221
+
222
+ const user2 = await prisma.user.create({
223
+ data: {
224
+ email: 'user2@test.com',
225
+ name: 'User 2',
226
+ },
227
+ });
228
+
229
+ const user2Session = await prisma.session.create({
230
+ data: {
231
+ userId: user2.id,
232
+ token: 'user2-session',
233
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
234
+ },
235
+ });
236
+
237
+ // User1 tries to revoke User2's session
238
+ await expect(
239
+ service.revokeSession(user1.id, user2Session.id)
240
+ ).rejects.toThrow(NotFoundError);
241
+ });
242
+
243
+ it('should only delete the specified session, not others', async () => {
244
+ const prisma = getTestPrisma();
245
+
246
+ const user = await prisma.user.create({
247
+ data: {
248
+ email: 'multiple@test.com',
249
+ name: 'Multiple Sessions User',
250
+ },
251
+ });
252
+
253
+ const session1 = await prisma.session.create({
254
+ data: {
255
+ userId: user.id,
256
+ token: 'keep-this',
257
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
258
+ },
259
+ });
260
+
261
+ const session2 = await prisma.session.create({
262
+ data: {
263
+ userId: user.id,
264
+ token: 'delete-this',
265
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
266
+ },
267
+ });
268
+
269
+ await service.revokeSession(user.id, session2.id);
270
+
271
+ const remainingSessions = await prisma.session.findMany({
272
+ where: { userId: user.id },
273
+ });
274
+
275
+ expect(remainingSessions).toHaveLength(1);
276
+ expect(remainingSessions[0].id).toBe(session1.id);
277
+ });
278
+ });
279
+
280
+ describe('revokeAllSessions', () => {
281
+ it('should delete all sessions except current one', async () => {
282
+ const prisma = getTestPrisma();
283
+
284
+ const user = await prisma.user.create({
285
+ data: {
286
+ email: 'revokeall@test.com',
287
+ name: 'Revoke All User',
288
+ },
289
+ });
290
+
291
+ const currentSession = await prisma.session.create({
292
+ data: {
293
+ userId: user.id,
294
+ token: 'current',
295
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
296
+ },
297
+ });
298
+
299
+ await prisma.session.createMany({
300
+ data: [
301
+ {
302
+ userId: user.id,
303
+ token: 'other-1',
304
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
305
+ },
306
+ {
307
+ userId: user.id,
308
+ token: 'other-2',
309
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
310
+ },
311
+ ],
312
+ });
313
+
314
+ await service.revokeAllSessions(user.id, currentSession.id);
315
+
316
+ const remainingSessions = await prisma.session.findMany({
317
+ where: { userId: user.id },
318
+ });
319
+
320
+ expect(remainingSessions).toHaveLength(1);
321
+ expect(remainingSessions[0].id).toBe(currentSession.id);
322
+ });
323
+
324
+ it('should delete all sessions when no current session provided', async () => {
325
+ const prisma = getTestPrisma();
326
+
327
+ const user = await prisma.user.create({
328
+ data: {
329
+ email: 'deleteall@test.com',
330
+ name: 'Delete All User',
331
+ },
332
+ });
333
+
334
+ await prisma.session.createMany({
335
+ data: [
336
+ {
337
+ userId: user.id,
338
+ token: 'session-1',
339
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
340
+ },
341
+ {
342
+ userId: user.id,
343
+ token: 'session-2',
344
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
345
+ },
346
+ {
347
+ userId: user.id,
348
+ token: 'session-3',
349
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
350
+ },
351
+ ],
352
+ });
353
+
354
+ await service.revokeAllSessions(user.id);
355
+
356
+ const remainingSessions = await prisma.session.findMany({
357
+ where: { userId: user.id },
358
+ });
359
+
360
+ expect(remainingSessions).toHaveLength(0);
361
+ });
362
+ });
363
+
364
+ describe('revokeAllUserSessions', () => {
365
+ it('should delete all sessions for a user', async () => {
366
+ const prisma = getTestPrisma();
367
+
368
+ const user = await prisma.user.create({
369
+ data: {
370
+ email: 'total-revoke@test.com',
371
+ name: 'Total Revoke User',
372
+ },
373
+ });
374
+
375
+ await prisma.session.createMany({
376
+ data: [
377
+ {
378
+ userId: user.id,
379
+ token: 'session-1',
380
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
381
+ },
382
+ {
383
+ userId: user.id,
384
+ token: 'session-2',
385
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
386
+ },
387
+ ],
388
+ });
389
+
390
+ await service.revokeAllUserSessions(user.id);
391
+
392
+ const sessions = await prisma.session.findMany({
393
+ where: { userId: user.id },
394
+ });
395
+
396
+ expect(sessions).toHaveLength(0);
397
+ });
398
+
399
+ it('should not affect other users sessions', async () => {
400
+ const prisma = getTestPrisma();
401
+
402
+ const user1 = await prisma.user.create({
403
+ data: {
404
+ email: 'user1-revoke@test.com',
405
+ name: 'User 1',
406
+ },
407
+ });
408
+
409
+ const user2 = await prisma.user.create({
410
+ data: {
411
+ email: 'user2-keep@test.com',
412
+ name: 'User 2',
413
+ },
414
+ });
415
+
416
+ await prisma.session.create({
417
+ data: {
418
+ userId: user1.id,
419
+ token: 'user1-session',
420
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
421
+ },
422
+ });
423
+
424
+ await prisma.session.create({
425
+ data: {
426
+ userId: user2.id,
427
+ token: 'user2-session',
428
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
429
+ },
430
+ });
431
+
432
+ await service.revokeAllUserSessions(user1.id);
433
+
434
+ const user1Sessions = await prisma.session.findMany({
435
+ where: { userId: user1.id },
436
+ });
437
+ const user2Sessions = await prisma.session.findMany({
438
+ where: { userId: user2.id },
439
+ });
440
+
441
+ expect(user1Sessions).toHaveLength(0);
442
+ expect(user2Sessions).toHaveLength(1);
443
+ });
444
+ });
445
+ });
@@ -0,0 +1,211 @@
1
+ import { createMockAuthorizationService } from '@test/helpers/mock-authorization';
2
+ import { createMockLogger } from '@test/helpers/mock-logger';
3
+ import { getTestPrisma, resetTestDatabase } from '@test/helpers/test-db';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import type { LoggerService } from '@/common/logger.service';
7
+ import type { AuthorizationService } from '@/services/authorization.service';
8
+ import { UsersService } from '@/services/users.service';
9
+
10
+ /**
11
+ * Integration tests for UsersService
12
+ * These tests use a real PostgreSQL database (app_dev_test)
13
+ * The database is automatically created and migrated in test/setup.ts
14
+ */
15
+ describe('UsersService Integration Tests', () => {
16
+ let service: UsersService;
17
+ let logger: LoggerService;
18
+ let authorizationService: AuthorizationService;
19
+
20
+ beforeEach(async () => {
21
+ // Reset database between tests
22
+ await resetTestDatabase();
23
+
24
+ // Create service with real Prisma client
25
+ logger = createMockLogger();
26
+ authorizationService = createMockAuthorizationService();
27
+ const prisma = getTestPrisma();
28
+ service = new UsersService(prisma, logger, authorizationService);
29
+ });
30
+
31
+ afterEach(async () => {
32
+ // Additional cleanup if needed
33
+ });
34
+
35
+ describe('User CRUD Operations', () => {
36
+ it('should create and retrieve a user', async () => {
37
+ // Create user
38
+ const createdUser = await service.createUser({
39
+ email: 'integration@test.com',
40
+ name: 'Integration Test User',
41
+ role: 'user',
42
+ });
43
+
44
+ expect(createdUser.id).toBeDefined();
45
+ expect(createdUser.email).toBe('integration@test.com');
46
+ expect(createdUser.name).toBe('Integration Test User');
47
+ expect(createdUser.role).toBe('user');
48
+
49
+ // Retrieve user by ID
50
+ const retrievedUser = await service.getUserById(createdUser.id);
51
+
52
+ expect(retrievedUser).toBeDefined();
53
+ expect(retrievedUser?.id).toBe(createdUser.id);
54
+ expect(retrievedUser?.email).toBe('integration@test.com');
55
+ });
56
+
57
+ it('should update an existing user', async () => {
58
+ // Create user
59
+ const user = await service.createUser({
60
+ email: 'update@test.com',
61
+ name: 'Original Name',
62
+ role: 'user',
63
+ });
64
+
65
+ // Update user
66
+ const updatedUser = await service.updateUser(
67
+ 'actor-id',
68
+ 'super_admin',
69
+ user.id,
70
+ {
71
+ name: 'Updated Name',
72
+ }
73
+ );
74
+
75
+ expect(updatedUser).toBeDefined();
76
+ expect(updatedUser?.name).toBe('Updated Name');
77
+ expect(updatedUser?.email).toBe('update@test.com'); // Email unchanged
78
+
79
+ // Verify update persisted
80
+ const retrievedUser = await service.getUserById(user.id);
81
+ expect(retrievedUser?.name).toBe('Updated Name');
82
+ });
83
+
84
+ it('should delete a user', async () => {
85
+ // Create user
86
+ const user = await service.createUser({
87
+ email: 'delete@test.com',
88
+ name: 'To Be Deleted',
89
+ role: 'user',
90
+ });
91
+
92
+ // Delete user
93
+ await service.deleteUser('actor-id', 'super_admin', user.id);
94
+
95
+ // Verify user is deleted (should throw NotFoundError)
96
+ await expect(service.getUserById(user.id)).rejects.toThrow(
97
+ 'User not found'
98
+ );
99
+ });
100
+
101
+ it('should throw NotFoundError when updating non-existent user', async () => {
102
+ await expect(
103
+ service.updateUser('actor-id', 'super_admin', 'non-existent-id', {
104
+ name: 'New Name',
105
+ })
106
+ ).rejects.toThrow('User not found');
107
+ });
108
+
109
+ it('should throw NotFoundError when deleting non-existent user', async () => {
110
+ await expect(
111
+ service.deleteUser('actor-id', 'super_admin', 'non-existent-id')
112
+ ).rejects.toThrow('User not found');
113
+ });
114
+ });
115
+
116
+ describe('User Pagination and Filtering', () => {
117
+ beforeEach(async () => {
118
+ // Create test users
119
+ await service.createUser({
120
+ email: 'alice@test.com',
121
+ name: 'Alice Smith',
122
+ role: 'user',
123
+ });
124
+ await service.createUser({
125
+ email: 'bob@test.com',
126
+ name: 'Bob Johnson',
127
+ role: 'admin',
128
+ });
129
+ await service.createUser({
130
+ email: 'charlie@test.com',
131
+ name: 'Charlie Brown',
132
+ role: 'user',
133
+ });
134
+ });
135
+
136
+ it('should return paginated users', async () => {
137
+ const result = await service.getUsers({
138
+ page: 1,
139
+ limit: 10,
140
+ sortBy: 'createdAt',
141
+ sortOrder: 'desc',
142
+ });
143
+
144
+ expect(result.data).toHaveLength(3);
145
+ expect(result.pagination.total).toBe(3);
146
+ expect(result.pagination.totalPages).toBe(1);
147
+ expect(result.pagination.page).toBe(1);
148
+ expect(result.pagination.limit).toBe(10);
149
+ });
150
+
151
+ it('should handle pagination correctly', async () => {
152
+ const result = await service.getUsers({
153
+ page: 1,
154
+ limit: 2,
155
+ sortBy: 'createdAt',
156
+ sortOrder: 'desc',
157
+ });
158
+
159
+ expect(result.data).toHaveLength(2);
160
+ expect(result.pagination.total).toBe(3);
161
+ expect(result.pagination.totalPages).toBe(2);
162
+ expect(result.pagination.page).toBe(1);
163
+ expect(result.pagination.limit).toBe(2);
164
+ });
165
+
166
+ it('should filter users by search query', async () => {
167
+ const result = await service.getUsers({
168
+ page: 1,
169
+ limit: 10,
170
+ sortBy: 'createdAt',
171
+ sortOrder: 'desc',
172
+ search: 'alice',
173
+ });
174
+
175
+ expect(result.data).toHaveLength(1);
176
+ expect(result.data[0].email).toBe('alice@test.com');
177
+ });
178
+
179
+ it('should sort users by email', async () => {
180
+ const result = await service.getUsers({
181
+ page: 1,
182
+ limit: 10,
183
+ sortBy: 'email',
184
+ sortOrder: 'asc',
185
+ });
186
+
187
+ expect(result.data[0].email).toBe('alice@test.com');
188
+ expect(result.data[1].email).toBe('bob@test.com');
189
+ expect(result.data[2].email).toBe('charlie@test.com');
190
+ });
191
+ });
192
+
193
+ describe('Database Constraints', () => {
194
+ it('should enforce unique email constraint', async () => {
195
+ await service.createUser({
196
+ email: 'unique@test.com',
197
+ name: 'First User',
198
+ role: 'user',
199
+ });
200
+
201
+ // Attempting to create another user with same email should fail
202
+ await expect(
203
+ service.createUser({
204
+ email: 'unique@test.com',
205
+ name: 'Second User',
206
+ role: 'user',
207
+ })
208
+ ).rejects.toThrow();
209
+ });
210
+ });
211
+ });
@@ -0,0 +1,31 @@
1
+ import { afterAll, beforeAll } from 'vitest';
2
+
3
+ import { cleanupTestDatabase, setupTestDatabase } from './helpers/test-db';
4
+
5
+ beforeAll(async () => {
6
+ // Set test environment variables before database setup
7
+ process.env.NODE_ENV = 'test';
8
+
9
+ // Set minimal required env vars for tests
10
+ // These are only needed for test infrastructure, not for actual test assertions
11
+ process.env.BETTER_AUTH_SECRET =
12
+ process.env.BETTER_AUTH_SECRET || 'test-secret-minimum-32-characters-long';
13
+ process.env.BETTER_AUTH_URL =
14
+ process.env.BETTER_AUTH_URL || 'http://localhost:8080';
15
+ process.env.API_URL = process.env.API_URL || 'http://localhost:8080';
16
+ process.env.FRONTEND_URL =
17
+ process.env.FRONTEND_URL || 'http://localhost:3000';
18
+ process.env.COOKIE_SECRET = process.env.COOKIE_SECRET || 'test-cookie-secret';
19
+ process.env.PORT = process.env.PORT || '8080';
20
+ process.env.LOG_LEVEL = process.env.LOG_LEVEL || 'minimal';
21
+
22
+ // DATABASE_URL is set in test-db.ts based on your .env.local
23
+
24
+ // Setup test database (runs migrations)
25
+ await setupTestDatabase();
26
+ });
27
+
28
+ afterAll(async () => {
29
+ // Cleanup test database
30
+ await cleanupTestDatabase();
31
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "removeComments": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "esModuleInterop": true,
10
+ "target": "ES2022",
11
+ "lib": ["ES2022", "DOM"],
12
+ "sourceMap": true,
13
+ "outDir": "./dist",
14
+ "baseUrl": "./",
15
+ "incremental": true,
16
+ "skipLibCheck": true,
17
+ "resolveJsonModule": true,
18
+ "jsx": "react",
19
+ "paths": {
20
+ "@/*": ["src/*"],
21
+ "@test/*": ["test/*"]
22
+ }
23
+ },
24
+ "include": ["src/**/*", "test/**/*", "emails/**/*"],
25
+ "exclude": ["node_modules", "dist", "src/generated"]
26
+ }