create-tigra 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -87
  3. package/bin/create-tigra.js +259 -308
  4. package/package.json +49 -41
  5. package/template/_claude/QUICK_REFERENCE.md +193 -0
  6. package/template/_claude/README.md +53 -0
  7. package/template/_claude/commands/create-client.md +881 -0
  8. package/template/_claude/commands/create-server.md +383 -0
  9. package/template/_claude/rules/client/01-project-structure.md +133 -0
  10. package/template/_claude/rules/client/02-components-and-types.md +146 -0
  11. package/template/_claude/rules/client/03-data-and-state.md +156 -0
  12. package/template/_claude/rules/client/04-design-system.md +185 -0
  13. package/template/_claude/rules/client/05-security.md +55 -0
  14. package/template/_claude/rules/client/06-ux-checklist.md +81 -0
  15. package/template/_claude/rules/client/core.md +42 -0
  16. package/template/_claude/rules/global/core.md +77 -0
  17. package/template/_claude/rules/server/core.md +50 -0
  18. package/template/_claude/rules/server/database.md +124 -0
  19. package/template/_claude/rules/server/project-conventions.md +150 -0
  20. package/template/_claude/rules/server/response-handling.md +144 -0
  21. package/template/client/.env.example +5 -0
  22. package/template/client/README.md +36 -0
  23. package/template/client/components.json +23 -0
  24. package/template/client/eslint.config.mjs +18 -0
  25. package/template/client/next.config.ts +34 -0
  26. package/template/client/package.json +44 -0
  27. package/template/client/postcss.config.mjs +7 -0
  28. package/template/client/src/app/(auth)/layout.tsx +18 -0
  29. package/template/client/src/app/(auth)/login/page.tsx +13 -0
  30. package/template/client/src/app/(auth)/register/page.tsx +13 -0
  31. package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
  32. package/template/client/src/app/(main)/layout.tsx +11 -0
  33. package/template/client/src/app/error.tsx +27 -0
  34. package/template/client/src/app/favicon.ico +0 -0
  35. package/template/client/src/app/globals.css +145 -0
  36. package/template/client/src/app/layout.tsx +36 -0
  37. package/template/client/src/app/loading.tsx +11 -0
  38. package/template/client/src/app/not-found.tsx +23 -0
  39. package/template/client/src/app/page.tsx +45 -0
  40. package/template/client/src/app/providers.tsx +43 -0
  41. package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
  42. package/template/client/src/components/common/EmptyState.tsx +31 -0
  43. package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
  44. package/template/client/src/components/common/Pagination.tsx +55 -0
  45. package/template/client/src/components/layout/Footer.tsx +17 -0
  46. package/template/client/src/components/layout/Header.tsx +173 -0
  47. package/template/client/src/components/layout/MainLayout.tsx +18 -0
  48. package/template/client/src/components/ui/alert-dialog.tsx +196 -0
  49. package/template/client/src/components/ui/badge.tsx +48 -0
  50. package/template/client/src/components/ui/button.tsx +64 -0
  51. package/template/client/src/components/ui/card.tsx +92 -0
  52. package/template/client/src/components/ui/input.tsx +21 -0
  53. package/template/client/src/components/ui/label.tsx +24 -0
  54. package/template/client/src/components/ui/select.tsx +190 -0
  55. package/template/client/src/components/ui/skeleton.tsx +13 -0
  56. package/template/client/src/components/ui/table.tsx +116 -0
  57. package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
  58. package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
  59. package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
  60. package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
  61. package/template/client/src/features/auth/services/auth.service.ts +52 -0
  62. package/template/client/src/features/auth/store/authSlice.ts +38 -0
  63. package/template/client/src/features/auth/types/auth.types.ts +32 -0
  64. package/template/client/src/hooks/useDebounce.ts +14 -0
  65. package/template/client/src/hooks/useLocalStorage.ts +55 -0
  66. package/template/client/src/hooks/useMediaQuery.ts +27 -0
  67. package/template/client/src/lib/api/api.types.ts +34 -0
  68. package/template/client/src/lib/api/axios.config.ts +98 -0
  69. package/template/client/src/lib/constants/api-endpoints.ts +18 -0
  70. package/template/client/src/lib/constants/app.constants.ts +12 -0
  71. package/template/client/src/lib/constants/routes.ts +9 -0
  72. package/template/client/src/lib/utils/error.ts +32 -0
  73. package/template/client/src/lib/utils/format.ts +37 -0
  74. package/template/client/src/lib/utils/security.ts +34 -0
  75. package/template/client/src/lib/utils.ts +6 -0
  76. package/template/client/src/middleware.ts +57 -0
  77. package/template/client/src/store/hooks.ts +7 -0
  78. package/template/client/src/store/index.ts +12 -0
  79. package/template/client/src/types/index.ts +3 -0
  80. package/template/client/tsconfig.json +34 -0
  81. package/template/gitignore +34 -0
  82. package/template/server/.dockerignore +66 -0
  83. package/template/server/.env.example +96 -69
  84. package/template/server/.env.production.example +90 -0
  85. package/template/server/Dockerfile +94 -0
  86. package/template/server/docker-compose.yml +82 -111
  87. package/template/server/docs/logging.md +62 -0
  88. package/template/server/eslint.config.mjs +17 -0
  89. package/template/server/package.json +68 -81
  90. package/template/server/phpmyadmin-config.php +26 -0
  91. package/template/server/postman_collection.json +666 -0
  92. package/template/server/prisma/schema.prisma +77 -93
  93. package/template/server/prisma/seed.ts +46 -142
  94. package/template/server/scripts/flush-redis.ts +41 -0
  95. package/template/server/src/app.ts +243 -71
  96. package/template/server/src/config/env.ts +67 -94
  97. package/template/server/src/libs/auth.ts +88 -0
  98. package/template/server/src/libs/cleanup.ts +35 -0
  99. package/template/server/src/libs/cookies.ts +46 -0
  100. package/template/server/src/libs/logger.ts +33 -60
  101. package/template/server/src/libs/monitoring.ts +205 -0
  102. package/template/server/src/libs/password.ts +38 -0
  103. package/template/server/src/libs/prisma.ts +68 -0
  104. package/template/server/src/libs/redis.ts +60 -79
  105. package/template/server/src/libs/requestLogger.ts +66 -0
  106. package/template/server/src/libs/storage/file-storage.service.ts +211 -0
  107. package/template/server/src/libs/storage/file-validator.ts +97 -0
  108. package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
  109. package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
  110. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
  111. package/template/server/src/modules/auth/auth.controller.ts +90 -141
  112. package/template/server/src/modules/auth/auth.repo.ts +120 -218
  113. package/template/server/src/modules/auth/auth.routes.ts +96 -83
  114. package/template/server/src/modules/auth/auth.schemas.ts +35 -137
  115. package/template/server/src/modules/auth/auth.service.ts +286 -329
  116. package/template/server/src/modules/auth/session.repo.ts +110 -0
  117. package/template/server/src/modules/users/users.controller.ts +120 -0
  118. package/template/server/src/modules/users/users.repo.ts +77 -0
  119. package/template/server/src/modules/users/users.routes.ts +89 -0
  120. package/template/server/src/modules/users/users.schemas.ts +21 -0
  121. package/template/server/src/modules/users/users.service.ts +169 -0
  122. package/template/server/src/server.ts +58 -139
  123. package/template/server/src/shared/errors/AppError.ts +21 -0
  124. package/template/server/src/shared/errors/errors.ts +43 -0
  125. package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
  126. package/template/server/src/shared/responses/successResponse.ts +17 -0
  127. package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
  128. package/template/server/src/shared/types/index.ts +26 -0
  129. package/template/server/src/test/setup.ts +74 -38
  130. package/template/server/tsconfig.json +27 -89
  131. package/template/server/uploads/avatars/.gitkeep +1 -0
  132. package/template/server/vitest.config.ts +43 -98
  133. package/template/.agent/rules/client/01-project-structure.md +0 -326
  134. package/template/.agent/rules/client/02-component-patterns.md +0 -249
  135. package/template/.agent/rules/client/03-typescript-rules.md +0 -226
  136. package/template/.agent/rules/client/04-state-management.md +0 -474
  137. package/template/.agent/rules/client/05-api-integration.md +0 -129
  138. package/template/.agent/rules/client/06-forms-validation.md +0 -129
  139. package/template/.agent/rules/client/07-common-patterns.md +0 -150
  140. package/template/.agent/rules/client/08-color-system.md +0 -93
  141. package/template/.agent/rules/client/09-security-rules.md +0 -97
  142. package/template/.agent/rules/client/10-testing-strategy.md +0 -370
  143. package/template/.agent/rules/global/ai-edit-safety.md +0 -38
  144. package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
  145. package/template/.agent/rules/server/02-general-rules.md +0 -111
  146. package/template/.agent/rules/server/03-migrations.md +0 -20
  147. package/template/.agent/rules/server/04-pagination.md +0 -130
  148. package/template/.agent/rules/server/05-project-conventions.md +0 -71
  149. package/template/.agent/rules/server/06-response-handling.md +0 -173
  150. package/template/.agent/rules/server/07-testing-strategy.md +0 -506
  151. package/template/.agent/rules/server/08-observability.md +0 -180
  152. package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
  153. package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
  154. package/template/.agent/rules/server/12-performance-optimization.md +0 -567
  155. package/template/.claude/rules/client-01-project-structure.md +0 -327
  156. package/template/.claude/rules/client-02-component-patterns.md +0 -250
  157. package/template/.claude/rules/client-03-typescript-rules.md +0 -227
  158. package/template/.claude/rules/client-04-state-management.md +0 -475
  159. package/template/.claude/rules/client-05-api-integration.md +0 -130
  160. package/template/.claude/rules/client-06-forms-validation.md +0 -130
  161. package/template/.claude/rules/client-07-common-patterns.md +0 -151
  162. package/template/.claude/rules/client-08-color-system.md +0 -94
  163. package/template/.claude/rules/client-09-security-rules.md +0 -98
  164. package/template/.claude/rules/client-10-testing-strategy.md +0 -371
  165. package/template/.claude/rules/global-ai-edit-safety.md +0 -39
  166. package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
  167. package/template/.claude/rules/server-02-general-rules.md +0 -112
  168. package/template/.claude/rules/server-03-migrations.md +0 -21
  169. package/template/.claude/rules/server-04-pagination.md +0 -131
  170. package/template/.claude/rules/server-05-project-conventions.md +0 -72
  171. package/template/.claude/rules/server-06-response-handling.md +0 -174
  172. package/template/.claude/rules/server-07-testing-strategy.md +0 -507
  173. package/template/.claude/rules/server-08-observability.md +0 -181
  174. package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
  175. package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
  176. package/template/.claude/rules/server-12-performance-optimization.md +0 -568
  177. package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
  178. package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
  179. package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
  180. package/template/.cursor/rules/client-04-state-management.mdc +0 -475
  181. package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
  182. package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
  183. package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
  184. package/template/.cursor/rules/client-08-color-system.mdc +0 -94
  185. package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
  186. package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
  187. package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
  188. package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
  189. package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
  190. package/template/.cursor/rules/server-03-migrations.mdc +0 -21
  191. package/template/.cursor/rules/server-04-pagination.mdc +0 -131
  192. package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
  193. package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
  194. package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
  195. package/template/.cursor/rules/server-08-observability.mdc +0 -181
  196. package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
  197. package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
  198. package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
  199. package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
  200. package/template/CLAUDE.md +0 -207
  201. package/template/server/.tsc-aliasrc.json +0 -13
  202. package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
  203. package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
  204. package/template/server/README.md +0 -183
  205. package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
  206. package/template/server/SECURITY.md +0 -190
  207. package/template/server/Tigra-API.postman_collection.json +0 -733
  208. package/template/server/biome.json +0 -42
  209. package/template/server/scripts/fix-all-imports.ps1 +0 -52
  210. package/template/server/scripts/fix-imports-reference.ps1 +0 -16
  211. package/template/server/scripts/fix-imports.mjs +0 -55
  212. package/template/server/scripts/setup-env.js +0 -50
  213. package/template/server/scripts/wait-for-db.js +0 -60
  214. package/template/server/src/hooks/request-timing.hook.ts +0 -26
  215. package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
  216. package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
  217. package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
  218. package/template/server/src/libs/db.ts +0 -76
  219. package/template/server/src/libs/error-handler.ts +0 -89
  220. package/template/server/src/libs/queue.ts +0 -79
  221. package/template/server/src/modules/admin/admin.controller.ts +0 -122
  222. package/template/server/src/modules/admin/admin.routes.ts +0 -62
  223. package/template/server/src/modules/admin/admin.schemas.ts +0 -35
  224. package/template/server/src/modules/admin/admin.service.ts +0 -167
  225. package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
  226. package/template/server/src/modules/auth/auth.service.test.ts +0 -119
  227. package/template/server/src/modules/auth/auth.types.ts +0 -97
  228. package/template/server/src/modules/resources/resources.controller.ts +0 -218
  229. package/template/server/src/modules/resources/resources.repo.ts +0 -253
  230. package/template/server/src/modules/resources/resources.routes.ts +0 -116
  231. package/template/server/src/modules/resources/resources.schemas.ts +0 -146
  232. package/template/server/src/modules/resources/resources.service.ts +0 -218
  233. package/template/server/src/modules/resources/resources.types.ts +0 -73
  234. package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
  235. package/template/server/src/plugins/security.plugin.ts +0 -21
  236. package/template/server/src/routes/health.routes.ts +0 -31
  237. package/template/server/src/types/fastify.d.ts +0 -36
  238. package/template/server/src/utils/errors.ts +0 -108
  239. package/template/server/src/utils/pagination.ts +0 -120
  240. package/template/server/src/utils/response.ts +0 -110
  241. package/template/server/src/workers/file.worker.ts +0 -106
  242. package/template/server/tsconfig.build.json +0 -30
  243. package/template/server/tsconfig.test.json +0 -22
@@ -0,0 +1,365 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as authService from '../auth.service.js';
3
+ import * as authRepo from '../auth.repo.js';
4
+ import * as authLib from '@libs/auth.js';
5
+ import { hashPassword, verifyPassword } from '@libs/password.js';
6
+ import { ConflictError, UnauthorizedError, NotFoundError } from '@shared/errors/errors.js';
7
+ import { testUsers, testRefreshToken, resetMocks } from '@/test/setup.js';
8
+ import { sessionRepository } from '../session.repo.js';
9
+
10
+ // Mock dependencies
11
+ vi.mock('../auth.repo.js');
12
+ vi.mock('@libs/auth.js');
13
+ vi.mock('@libs/password.js');
14
+ vi.mock('../session.repo.js');
15
+
16
+ describe('Auth Service', () => {
17
+ beforeEach(() => {
18
+ resetMocks();
19
+ vi.clearAllMocks();
20
+ });
21
+
22
+ describe('register', () => {
23
+ const validRegisterInput = {
24
+ email: 'newuser@example.com',
25
+ password: 'Password123!',
26
+ firstName: 'New',
27
+ lastName: 'User',
28
+ };
29
+
30
+ it('should successfully register a new user', async () => {
31
+ // Arrange
32
+ const hashedPassword = '$2a$12$hashedpassword';
33
+ const createdUser = {
34
+ ...testUsers.validUser,
35
+ email: validRegisterInput.email,
36
+ password: hashedPassword,
37
+ };
38
+ const accessToken = 'mock-access-token';
39
+ const refreshToken = 'mock-refresh-token';
40
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
41
+
42
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
43
+ vi.mocked(hashPassword).mockResolvedValue(hashedPassword);
44
+ vi.mocked(authRepo.createUser).mockResolvedValue(createdUser);
45
+ vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
46
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
47
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
48
+ vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
49
+
50
+ // Act
51
+ const result = await authService.register(validRegisterInput);
52
+
53
+ // Assert
54
+ expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validRegisterInput.email);
55
+ expect(hashPassword).toHaveBeenCalledWith(validRegisterInput.password);
56
+ expect(authRepo.createUser).toHaveBeenCalledWith({
57
+ email: validRegisterInput.email,
58
+ password: hashedPassword,
59
+ firstName: validRegisterInput.firstName,
60
+ lastName: validRegisterInput.lastName,
61
+ });
62
+ expect(authLib.signAccessToken).toHaveBeenCalledWith({
63
+ userId: createdUser.id,
64
+ role: createdUser.role,
65
+ });
66
+ expect(authLib.generateRefreshToken).toHaveBeenCalled();
67
+ expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
68
+ token: refreshToken,
69
+ userId: createdUser.id,
70
+ expiresAt,
71
+ });
72
+ expect(result).toEqual({
73
+ user: expect.objectContaining({
74
+ id: createdUser.id,
75
+ email: createdUser.email,
76
+ firstName: createdUser.firstName,
77
+ lastName: createdUser.lastName,
78
+ role: createdUser.role,
79
+ }),
80
+ accessToken,
81
+ refreshToken,
82
+ });
83
+ expect(result.user).not.toHaveProperty('password');
84
+ });
85
+
86
+ it('should throw ConflictError if email already exists', async () => {
87
+ // Arrange
88
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
89
+
90
+ // Act & Assert
91
+ await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
92
+ await expect(authService.register(validRegisterInput)).rejects.toThrow('Email already registered');
93
+ expect(hashPassword).not.toHaveBeenCalled();
94
+ expect(authRepo.createUser).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ describe('login', () => {
99
+ const validLoginInput = {
100
+ email: 'test@example.com',
101
+ password: 'Password123!',
102
+ };
103
+
104
+ it('should successfully login with valid credentials', async () => {
105
+ // Arrange
106
+ const accessToken = 'mock-access-token';
107
+ const refreshToken = 'mock-refresh-token';
108
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
109
+
110
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
111
+ vi.mocked(verifyPassword).mockResolvedValue({ valid: true, needsRehash: false });
112
+ vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
113
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
114
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
115
+ vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
116
+
117
+ // Act
118
+ const result = await authService.login(validLoginInput);
119
+
120
+ // Assert
121
+ expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validLoginInput.email);
122
+ expect(verifyPassword).toHaveBeenCalledWith(validLoginInput.password, testUsers.validUser.password);
123
+ expect(result).toEqual({
124
+ user: expect.objectContaining({
125
+ id: testUsers.validUser.id,
126
+ email: testUsers.validUser.email,
127
+ }),
128
+ accessToken,
129
+ refreshToken,
130
+ });
131
+ expect(result.user).not.toHaveProperty('password');
132
+ });
133
+
134
+ it('should throw UnauthorizedError if user not found', async () => {
135
+ // Arrange
136
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
137
+
138
+ // Act & Assert
139
+ await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
140
+ await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
141
+ expect(verifyPassword).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('should throw UnauthorizedError if account is disabled', async () => {
145
+ // Arrange
146
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.inactiveUser);
147
+
148
+ // Act & Assert
149
+ await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
150
+ await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
151
+ expect(verifyPassword).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('should throw UnauthorizedError if password is invalid', async () => {
155
+ // Arrange
156
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
157
+ vi.mocked(verifyPassword).mockResolvedValue({ valid: false, needsRehash: false });
158
+
159
+ // Act & Assert
160
+ await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
161
+ await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
162
+ });
163
+ });
164
+
165
+ describe('refresh', () => {
166
+ const validRefreshToken = 'valid-refresh-token';
167
+
168
+ it('should successfully refresh tokens with valid refresh token', async () => {
169
+ // Arrange
170
+ const newAccessToken = 'new-access-token';
171
+ const newRefreshToken = 'new-refresh-token';
172
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
173
+ const storedToken = {
174
+ ...testRefreshToken,
175
+ token: validRefreshToken,
176
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // expires in 1 day
177
+ };
178
+
179
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
180
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
181
+ vi.mocked(authLib.signAccessToken).mockReturnValue(newAccessToken);
182
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue(newRefreshToken);
183
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
184
+ vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(true);
185
+
186
+ // Act
187
+ const result = await authService.refresh(validRefreshToken);
188
+
189
+ // Assert
190
+ expect(authRepo.findRefreshToken).toHaveBeenCalledWith(validRefreshToken);
191
+ expect(authRepo.findUserById).toHaveBeenCalledWith(storedToken.userId);
192
+ expect(authLib.signAccessToken).toHaveBeenCalledWith({
193
+ userId: testUsers.validUser.id,
194
+ role: testUsers.validUser.role,
195
+ });
196
+ expect(authRepo.rotateRefreshToken).toHaveBeenCalledWith(validRefreshToken, {
197
+ token: newRefreshToken,
198
+ userId: testUsers.validUser.id,
199
+ expiresAt,
200
+ });
201
+ expect(result).toEqual({
202
+ accessToken: newAccessToken,
203
+ refreshToken: newRefreshToken,
204
+ });
205
+ });
206
+
207
+ it('should throw UnauthorizedError if refresh token not found', async () => {
208
+ // Arrange
209
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(null);
210
+
211
+ // Act & Assert
212
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
213
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow('Invalid refresh token');
214
+ });
215
+
216
+ it('should throw UnauthorizedError if refresh token expired', async () => {
217
+ // Arrange
218
+ const expiredToken = {
219
+ ...testRefreshToken,
220
+ expiresAt: new Date(Date.now() - 1000), // expired 1 second ago
221
+ };
222
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(expiredToken);
223
+ vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
224
+
225
+ // Act & Assert
226
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
227
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow('Refresh token expired');
228
+ expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(validRefreshToken);
229
+ });
230
+
231
+ it('should throw UnauthorizedError if user not found', async () => {
232
+ // Arrange
233
+ const storedToken = {
234
+ ...testRefreshToken,
235
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
236
+ };
237
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
238
+ vi.mocked(authRepo.findUserById).mockResolvedValue(null);
239
+
240
+ // Act & Assert
241
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
242
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
243
+ });
244
+
245
+ it('should throw UnauthorizedError if user is disabled', async () => {
246
+ // Arrange
247
+ const storedToken = {
248
+ ...testRefreshToken,
249
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
250
+ };
251
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
252
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.inactiveUser);
253
+
254
+ // Act & Assert
255
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
256
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
257
+ });
258
+ });
259
+
260
+ describe('logout', () => {
261
+ it('should delete refresh token and matching session', async () => {
262
+ // Arrange
263
+ const refreshToken = 'valid-refresh-token';
264
+ const tokenCreatedAt = new Date('2024-06-01T12:00:00Z');
265
+ const storedToken = {
266
+ ...testRefreshToken,
267
+ token: refreshToken,
268
+ createdAt: tokenCreatedAt,
269
+ };
270
+ const matchingSession = {
271
+ id: 'session-1',
272
+ userId: testUsers.validUser.id,
273
+ deviceInfo: 'test-agent',
274
+ ipAddress: '127.0.0.1',
275
+ lastActiveAt: new Date(),
276
+ expiresAt: new Date(Date.now() + 86400000),
277
+ createdAt: new Date('2024-06-01T12:00:00.100Z'), // within 5s of token
278
+ };
279
+
280
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
281
+ vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
282
+ vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([matchingSession]);
283
+ vi.mocked(sessionRepository.deleteSession).mockResolvedValue(undefined as never);
284
+
285
+ // Act
286
+ await authService.logout(refreshToken);
287
+
288
+ // Assert
289
+ expect(authRepo.findRefreshToken).toHaveBeenCalledWith(refreshToken);
290
+ expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
291
+ expect(sessionRepository.getUserSessions).toHaveBeenCalledWith(storedToken.userId);
292
+ expect(sessionRepository.deleteSession).toHaveBeenCalledWith(matchingSession.id);
293
+ });
294
+
295
+ it('should not delete session if no matching createdAt found', async () => {
296
+ // Arrange
297
+ const refreshToken = 'valid-refresh-token';
298
+ const storedToken = {
299
+ ...testRefreshToken,
300
+ token: refreshToken,
301
+ createdAt: new Date('2024-06-01T12:00:00Z'),
302
+ };
303
+ const unmatchedSession = {
304
+ id: 'session-2',
305
+ userId: testUsers.validUser.id,
306
+ deviceInfo: null,
307
+ ipAddress: null,
308
+ lastActiveAt: new Date(),
309
+ expiresAt: new Date(Date.now() + 86400000),
310
+ createdAt: new Date('2024-05-01T00:00:00Z'), // way off — no match
311
+ };
312
+
313
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
314
+ vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
315
+ vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([unmatchedSession]);
316
+
317
+ // Act
318
+ await authService.logout(refreshToken);
319
+
320
+ // Assert
321
+ expect(sessionRepository.deleteSession).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it('should not throw error if token deletion fails', async () => {
325
+ // Arrange
326
+ const refreshToken = 'invalid-refresh-token';
327
+ vi.mocked(authRepo.findRefreshToken).mockRejectedValue(new Error('DB error'));
328
+
329
+ // Act & Assert
330
+ await expect(authService.logout(refreshToken)).resolves.not.toThrow();
331
+ });
332
+ });
333
+
334
+ describe('getCurrentUser', () => {
335
+ it('should return sanitized user data', async () => {
336
+ // Arrange
337
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
338
+
339
+ // Act
340
+ const result = await authService.getCurrentUser(testUsers.validUser.id);
341
+
342
+ // Assert
343
+ expect(authRepo.findUserById).toHaveBeenCalledWith(testUsers.validUser.id);
344
+ expect(result).toEqual(
345
+ expect.objectContaining({
346
+ id: testUsers.validUser.id,
347
+ email: testUsers.validUser.email,
348
+ firstName: testUsers.validUser.firstName,
349
+ lastName: testUsers.validUser.lastName,
350
+ role: testUsers.validUser.role,
351
+ }),
352
+ );
353
+ expect(result).not.toHaveProperty('password');
354
+ });
355
+
356
+ it('should throw NotFoundError if user not found', async () => {
357
+ // Arrange
358
+ vi.mocked(authRepo.findUserById).mockResolvedValue(null);
359
+
360
+ // Act & Assert
361
+ await expect(authService.getCurrentUser('invalid-user-id')).rejects.toThrow(NotFoundError);
362
+ await expect(authService.getCurrentUser('invalid-user-id')).rejects.toThrow('User not found');
363
+ });
364
+ });
365
+ });
@@ -1,141 +1,90 @@
1
- /**
2
- * Authentication Controller
3
- *
4
- * HTTP request handlers for authentication endpoints.
5
- * Controllers should ONLY handle HTTP concerns, no business logic.
6
- *
7
- * @see /mnt/project/02-general-rules.md
8
- * @see /mnt/project/06-response-handling.md
9
- */
10
-
11
- import type { FastifyRequest, FastifyReply } from 'fastify';
12
- import { successResponse } from '../../utils/response.js';
13
- import { NotFoundError } from '../../utils/errors.js';
14
-
15
- import * as authService from './auth.service.js';
16
- import * as authRepo from './auth.repo.js';
17
- import {
18
- RegisterSchema,
19
- LoginSchema,
20
- RefreshTokenSchema,
21
- } from './auth.schemas.js';
22
- import type { RegisterInput, LoginInput, RefreshTokenInput } from './auth.schemas.js';
23
-
24
- /**
25
- * Register a new user
26
- *
27
- * @route POST /auth/register
28
- * @access Public
29
- */
30
- export async function register(
31
- request: FastifyRequest<{ Body: RegisterInput }>,
32
- reply: FastifyReply
33
- ): Promise<FastifyReply> {
34
- // Validate request body
35
- const body = RegisterSchema.parse(request.body);
36
-
37
- // Call service
38
- const authResponse = await authService.register(body);
39
-
40
- // Return success response
41
- return reply.status(201).send(
42
- successResponse('User registered successfully', authResponse)
43
- );
44
- }
45
-
46
- /**
47
- * Login user
48
- *
49
- * @route POST /auth/login
50
- * @access Public
51
- */
52
- export async function login(
53
- request: FastifyRequest<{ Body: LoginInput }>,
54
- reply: FastifyReply
55
- ): Promise<FastifyReply> {
56
- // Validate request body
57
- const body = LoginSchema.parse(request.body);
58
-
59
- // Call service
60
- const authResponse = await authService.login(body);
61
-
62
- // Return success response
63
- return reply.status(200).send(
64
- successResponse('Login successful', authResponse)
65
- );
66
- }
67
-
68
- /**
69
- * Refresh access token
70
- *
71
- * @route POST /auth/refresh
72
- * @access Public
73
- */
74
- export async function refreshTokens(
75
- request: FastifyRequest<{ Body: RefreshTokenInput }>,
76
- reply: FastifyReply
77
- ): Promise<FastifyReply> {
78
- // Validate request body
79
- const body = RefreshTokenSchema.parse(request.body);
80
-
81
- // Call service
82
- const tokens = await authService.refreshTokens(body.refreshToken);
83
-
84
- // Return success response
85
- return reply.status(200).send(
86
- successResponse('Tokens refreshed successfully', tokens)
87
- );
88
- }
89
-
90
- /**
91
- * Logout user
92
- *
93
- * @route POST /auth/logout
94
- * @access Public
95
- */
96
- export async function logout(
97
- request: FastifyRequest<{ Body: RefreshTokenInput }>,
98
- reply: FastifyReply
99
- ): Promise<FastifyReply> {
100
- // Validate request body
101
- const body = RefreshTokenSchema.parse(request.body);
102
-
103
- // Call service
104
- await authService.logout(body.refreshToken);
105
-
106
- // Return success response
107
- return reply.status(200).send(
108
- successResponse('Logout successful', null)
109
- );
110
- }
111
-
112
- /**
113
- * Get current authenticated user
114
- *
115
- * @route GET /auth/me
116
- * @access Private (requires authentication)
117
- */
118
- export async function getMe(
119
- request: FastifyRequest,
120
- reply: FastifyReply
121
- ): Promise<FastifyReply> {
122
- // Get user ID from authenticated request
123
- // Note: request.user is set by authentication middleware
124
- const userId = (request.user as any)?.userId;
125
-
126
- if (!userId) {
127
- throw new NotFoundError('User not found');
128
- }
129
-
130
- // Find user by ID
131
- const user = await authRepo.findUserById(userId);
132
-
133
- if (!user) {
134
- throw new NotFoundError('User not found');
135
- }
136
-
137
- // Return success response
138
- return reply.status(200).send(
139
- successResponse('User retrieved successfully', user)
140
- );
141
- }
1
+ import type { FastifyRequest, FastifyReply } from 'fastify';
2
+ import { successResponse } from '@shared/responses/successResponse.js';
3
+ import { UnauthorizedError } from '@shared/errors/errors.js';
4
+ import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
5
+ import type {
6
+ RegisterInput,
7
+ LoginInput,
8
+ } from './auth.schemas.js';
9
+ import * as authService from './auth.service.js';
10
+
11
+ export async function register(
12
+ request: FastifyRequest<{ Body: RegisterInput }>,
13
+ reply: FastifyReply,
14
+ ): Promise<void> {
15
+ const deviceInfo = request.headers['user-agent'];
16
+ const ipAddress = request.ip;
17
+
18
+ const result = await authService.register(request.body, deviceInfo, ipAddress);
19
+
20
+ setAuthCookies(reply, result.accessToken, result.refreshToken);
21
+ reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
22
+ }
23
+
24
+ export async function login(
25
+ request: FastifyRequest<{ Body: LoginInput }>,
26
+ reply: FastifyReply,
27
+ ): Promise<void> {
28
+ const deviceInfo = request.headers['user-agent'];
29
+ const ipAddress = request.ip;
30
+
31
+ const result = await authService.login(request.body, deviceInfo, ipAddress);
32
+
33
+ setAuthCookies(reply, result.accessToken, result.refreshToken);
34
+ reply.send(successResponse('Logged in successfully', { user: result.user }));
35
+ }
36
+
37
+ export async function refresh(
38
+ request: FastifyRequest,
39
+ reply: FastifyReply,
40
+ ): Promise<void> {
41
+ const refreshToken = request.cookies.refresh_token;
42
+ if (!refreshToken) {
43
+ throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
44
+ }
45
+
46
+ const tokens = await authService.refresh(refreshToken);
47
+
48
+ setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
49
+ reply.send(successResponse('Token refreshed successfully', null));
50
+ }
51
+
52
+ export async function logout(
53
+ request: FastifyRequest,
54
+ reply: FastifyReply,
55
+ ): Promise<void> {
56
+ const refreshToken = request.cookies.refresh_token;
57
+ if (refreshToken) {
58
+ await authService.logout(refreshToken);
59
+ }
60
+
61
+ clearAuthCookies(reply);
62
+ reply.send(successResponse('Logged out successfully', null));
63
+ }
64
+
65
+ export async function me(
66
+ request: FastifyRequest,
67
+ reply: FastifyReply,
68
+ ): Promise<void> {
69
+ const user = await authService.getCurrentUser(request.user.userId);
70
+ reply.send(successResponse('Current user retrieved', user));
71
+ }
72
+
73
+ export async function getSessions(
74
+ request: FastifyRequest,
75
+ reply: FastifyReply,
76
+ ): Promise<void> {
77
+ const sessions = await authService.getUserSessions(request.user.userId);
78
+ reply.send(successResponse('User sessions retrieved', sessions));
79
+ }
80
+
81
+ export async function logoutAllSessions(
82
+ request: FastifyRequest,
83
+ reply: FastifyReply,
84
+ ): Promise<void> {
85
+ const count = await authService.logoutAllSessions(request.user.userId);
86
+ clearAuthCookies(reply);
87
+ reply.send(
88
+ successResponse(`Successfully logged out from ${count} session(s)`, { count }),
89
+ );
90
+ }