create-forgeon 0.3.14 → 0.3.15

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.
@@ -2,847 +2,906 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
- import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import { addModule } from './executor.mjs';
8
- import { scanIntegrations, syncIntegrations } from './sync-integrations.mjs';
9
- import { scaffoldProject } from '../core/scaffold.mjs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { addModule } from './executor.mjs';
8
+ import { scanIntegrations, syncIntegrations } from './sync-integrations.mjs';
9
+ import { scaffoldProject } from '../core/scaffold.mjs';
10
10
 
11
11
  function mkTmp(prefix) {
12
12
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13
13
  }
14
14
 
15
- function createMinimalForgeonProject(targetRoot) {
16
- fs.mkdirSync(path.join(targetRoot, 'apps', 'api'), { recursive: true });
17
- fs.writeFileSync(path.join(targetRoot, 'package.json'), '{"name":"demo"}\n', 'utf8');
18
- fs.writeFileSync(path.join(targetRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8');
19
- }
20
-
21
- function assertDbPrismaWiring(projectRoot) {
22
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
23
- assert.match(appModule, /dbPrismaConfig/);
24
- assert.match(appModule, /dbPrismaEnvSchema/);
25
- assert.match(appModule, /DbPrismaModule/);
26
-
27
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
28
- assert.match(apiPackage, /@forgeon\/db-prisma/);
29
-
30
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
31
- assert.match(apiDockerfile, /COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json/);
32
- assert.match(apiDockerfile, /COPY packages\/db-prisma packages\/db-prisma/);
33
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/db-prisma build/);
34
-
35
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
36
- assert.match(compose, /DATABASE_URL: \$\{DATABASE_URL\}/);
37
-
38
- const healthController = fs.readFileSync(
39
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
40
- 'utf8',
41
- );
42
- assert.match(healthController, /PrismaService/);
43
- }
44
-
45
- function assertRateLimitWiring(projectRoot) {
46
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
47
- assert.match(appModule, /rateLimitConfig/);
48
- assert.match(appModule, /rateLimitEnvSchema/);
49
- assert.match(appModule, /ForgeonRateLimitModule/);
50
-
51
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
52
- assert.match(apiPackage, /@forgeon\/rate-limit/);
53
- assert.match(apiPackage, /pnpm --filter @forgeon\/rate-limit build/);
54
-
55
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
56
- assert.match(apiDockerfile, /COPY packages\/rate-limit\/package\.json packages\/rate-limit\/package\.json/);
57
- assert.match(apiDockerfile, /COPY packages\/rate-limit packages\/rate-limit/);
58
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rate-limit build/);
59
-
60
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
61
- assert.match(compose, /THROTTLE_ENABLED: \$\{THROTTLE_ENABLED\}/);
62
- assert.match(compose, /THROTTLE_LIMIT: \$\{THROTTLE_LIMIT\}/);
63
-
64
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
65
- assert.match(apiEnv, /THROTTLE_ENABLED=true/);
66
- assert.match(apiEnv, /THROTTLE_TTL=10/);
67
- assert.match(apiEnv, /THROTTLE_LIMIT=3/);
68
-
69
- const healthController = fs.readFileSync(
70
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
71
- 'utf8',
72
- );
73
- assert.match(healthController, /import \{ Header \} from '@nestjs\/common';/);
74
- assert.match(healthController, /@Header\('Cache-Control', 'no-store, no-cache, must-revalidate'\)/);
75
- assert.match(healthController, /@Get\('rate-limit'\)/);
76
- assert.match(healthController, /TOO_MANY_REQUESTS/);
77
-
78
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
79
- assert.match(appTsx, /cache: 'no-store'/);
80
- assert.match(appTsx, /Check rate limit \(click repeatedly\)/);
81
- assert.match(appTsx, /Rate limit probe response/);
82
-
83
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
84
- assert.match(readme, /## Rate Limit Module/);
85
- assert.match(readme, /installs independently/i);
86
- assert.match(readme, /no optional integration sync is required/i);
87
- }
88
-
89
- function assertQueueWiring(projectRoot) {
90
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
91
- assert.match(appModule, /queueConfig/);
92
- assert.match(appModule, /queueEnvSchema/);
93
- assert.match(appModule, /ForgeonQueueModule/);
94
-
95
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
96
- assert.match(apiPackage, /@forgeon\/queue/);
97
- assert.match(apiPackage, /pnpm --filter @forgeon\/queue build/);
98
-
99
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
100
- assert.match(apiDockerfile, /COPY packages\/queue\/package\.json packages\/queue\/package\.json/);
101
- assert.match(apiDockerfile, /COPY packages\/queue packages\/queue/);
102
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/queue build/);
103
-
104
- const healthController = fs.readFileSync(
105
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
106
- 'utf8',
107
- );
108
- assert.match(healthController, /QueueService/);
109
- assert.match(healthController, /@Get\('queue'\)/);
110
- assert.match(healthController, /queueService\.getProbeStatus/);
111
-
112
- const webApp = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
113
- assert.match(webApp, /Check queue health/);
114
- assert.match(webApp, /Queue probe response/);
115
-
116
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
117
- assert.match(apiEnv, /QUEUE_ENABLED=true/);
118
- assert.match(apiEnv, /QUEUE_REDIS_URL=redis:\/\/localhost:6379/);
119
- assert.match(apiEnv, /QUEUE_DEFAULT_ATTEMPTS=3/);
120
- assert.match(apiEnv, /QUEUE_DEFAULT_BACKOFF_MS=1000/);
121
-
122
- const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
123
- assert.match(dockerEnv, /QUEUE_REDIS_URL=redis:\/\/redis:6379/);
124
-
125
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
126
- assert.match(compose, /^\s{2}redis:\s*$/m);
127
- assert.match(compose, /QUEUE_ENABLED: \$\{QUEUE_ENABLED\}/);
128
- assert.match(compose, /depends_on:\n\s+redis:\n\s+condition: service_healthy/);
129
-
130
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
131
- assert.match(readme, /## Queue Module/);
132
- assert.match(readme, /runtime baseline backed by Redis/i);
133
- }
134
-
135
- function assertSchedulerWiring(projectRoot) {
136
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
137
- assert.match(appModule, /schedulerConfig/);
138
- assert.match(appModule, /schedulerEnvSchema/);
139
- assert.match(appModule, /ForgeonSchedulerModule/);
140
-
141
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
142
- assert.match(apiPackage, /@forgeon\/scheduler/);
143
- assert.match(apiPackage, /pnpm --filter @forgeon\/scheduler build/);
144
-
145
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
146
- assert.match(apiDockerfile, /COPY packages\/scheduler\/package\.json packages\/scheduler\/package\.json/);
147
- assert.match(apiDockerfile, /COPY packages\/scheduler packages\/scheduler/);
148
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/scheduler build/);
149
-
150
- const healthController = fs.readFileSync(
151
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
152
- 'utf8',
153
- );
154
- assert.match(healthController, /ForgeonSchedulerService/);
155
- assert.match(healthController, /@Get\('scheduler'\)/);
156
- assert.match(healthController, /schedulerService\.getProbeStatus/);
157
-
158
- const webApp = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
159
- assert.match(webApp, /Check scheduler health/);
160
- assert.match(webApp, /Scheduler probe response/);
161
-
162
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
163
- assert.match(apiEnv, /SCHEDULER_ENABLED=true/);
164
- assert.match(apiEnv, /SCHEDULER_TIMEZONE=UTC/);
165
- assert.match(apiEnv, /SCHEDULER_HEARTBEAT_CRON=\*\/5 \* \* \* \*/);
166
-
167
- const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
168
- assert.match(dockerEnv, /SCHEDULER_TIMEZONE=UTC/);
169
-
170
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
171
- assert.match(compose, /SCHEDULER_ENABLED: \$\{SCHEDULER_ENABLED\}/);
172
- assert.match(compose, /SCHEDULER_HEARTBEAT_CRON: \$\{SCHEDULER_HEARTBEAT_CRON\}/);
173
- assert.doesNotMatch(compose, /^\s{2}scheduler:\s*$/m);
174
-
175
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
176
- assert.match(readme, /## Scheduler Module/);
177
- assert.match(readme, /cron-based orchestration/i);
178
- }
179
-
180
- function assertRbacWiring(projectRoot) {
181
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
182
- assert.match(appModule, /ForgeonRbacModule/);
183
-
184
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
185
- assert.match(apiPackage, /@forgeon\/rbac/);
186
- assert.match(apiPackage, /pnpm --filter @forgeon\/rbac build/);
187
-
188
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
189
- assert.match(apiDockerfile, /COPY packages\/rbac\/package\.json packages\/rbac\/package\.json/);
190
- assert.match(apiDockerfile, /COPY packages\/rbac packages\/rbac/);
191
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rbac build/);
192
-
193
- const healthController = fs.readFileSync(
194
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
195
- 'utf8',
196
- );
197
- assert.match(healthController, /UseGuards/);
198
- assert.match(healthController, /ForgeonRbacGuard/);
199
- assert.match(healthController, /@Get\('rbac'\)/);
200
- assert.match(healthController, /@Permissions\('health\.rbac'\)/);
201
-
202
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
203
- assert.match(appTsx, /Check RBAC access/);
204
- assert.match(appTsx, /RBAC probe response/);
205
- assert.match(appTsx, /x-forgeon-permissions/);
206
-
207
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
208
- assert.match(readme, /## RBAC \/ Permissions Module/);
209
- assert.match(readme, /installs independently/i);
210
- assert.match(readme, /jwt-auth.*optional/i);
211
- }
212
-
213
- function assertFilesWiring(projectRoot, expectedStorageDriver = 'local') {
214
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
215
- assert.match(appModule, /filesConfig/);
216
- assert.match(appModule, /filesEnvSchema/);
217
- assert.match(appModule, /ForgeonFilesModule/);
218
-
219
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
220
- assert.match(apiPackage, /@forgeon\/files/);
221
- assert.match(apiPackage, /pnpm --filter @forgeon\/files build/);
222
-
223
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
224
- assert.match(apiDockerfile, /COPY packages\/files\/package\.json packages\/files\/package\.json/);
225
- assert.match(apiDockerfile, /COPY packages\/files packages\/files/);
226
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files build/);
227
-
228
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
229
- assert.match(apiEnv, /FILES_ENABLED=true/);
230
- assert.match(apiEnv, new RegExp(`FILES_STORAGE_DRIVER=${expectedStorageDriver}`));
231
- assert.match(apiEnv, /FILES_PUBLIC_BASE_PATH=\/files/);
232
- assert.match(apiEnv, /FILES_MAX_FILE_SIZE_BYTES=10485760/);
233
- assert.match(apiEnv, /FILES_ALLOWED_MIME_PREFIXES=image\/,application\/pdf,text\//);
234
-
235
- const healthController = fs.readFileSync(
236
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
237
- 'utf8',
238
- );
239
- assert.match(healthController, /@Post\('files'\)/);
240
- assert.match(healthController, /@Get\('files-variants'\)/);
241
- assert.match(healthController, /filesService\.createProbeRecord/);
242
- assert.match(healthController, /filesService\.getVariantsProbeStatus/);
243
- assert.match(healthController, /filesService\.deleteByPublicId/);
244
-
245
- const filesController = fs.readFileSync(
246
- path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
247
- 'utf8',
248
- );
249
- assert.match(filesController, /@Query\('variant'\) variantQuery\?: string/);
250
- assert.match(filesController, /parseVariant\(variantQuery\)/);
251
- assert.match(filesController, /@Delete\(':publicId'\)/);
252
-
253
- const filesService = fs.readFileSync(
254
- path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
255
- 'utf8',
256
- );
257
- assert.match(filesService, /getOrCreateBlob/);
258
- assert.match(filesService, /cleanupReferencedBlobs/);
259
- assert.match(filesService, /isUniqueConstraintError/);
260
- assert.match(filesService, /fileBlob\.deleteMany/);
261
- assert.match(filesService, /variants:\s*\{[\s\S]*?none:\s*\{[\s\S]*?\}/);
262
- assert.match(filesService, /prisma\.fileBlob/);
263
-
264
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
265
- assert.match(appTsx, /Check files probe \(create metadata\)/);
266
- assert.match(appTsx, /Check files variants capability/);
267
- assert.match(appTsx, /Files probe response/);
268
- assert.match(appTsx, /Files variants probe response/);
269
-
270
- const schema = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'prisma', 'schema.prisma'), 'utf8');
271
- assert.match(schema, /model FileRecord \{/);
272
- assert.match(schema, /variants\s+FileVariant\[\]/);
273
- assert.match(schema, /model FileVariant \{/);
274
- assert.match(schema, /model FileBlob \{/);
275
- assert.match(schema, /blobId\s+String/);
276
- assert.match(schema, /@@unique\(\[hash,\s*size,\s*mimeType,\s*storageDriver\]\)/);
277
- assert.match(schema, /@@unique\(\[fileId,\s*variantKey\]\)/);
278
- assert.match(schema, /publicId\s+String\s+@unique/);
279
- assert.match(schema, /@@index\(\[ownerType,\s*ownerId,\s*createdAt\]\)/);
280
-
281
- const migration = path.join(
282
- projectRoot,
283
- 'apps',
284
- 'api',
285
- 'prisma',
286
- 'migrations',
287
- '20260306_files_file_record',
288
- 'migration.sql',
289
- );
290
- assert.equal(fs.existsSync(migration), true);
291
-
292
- const variantMigration = path.join(
293
- projectRoot,
294
- 'apps',
295
- 'api',
296
- 'prisma',
297
- 'migrations',
298
- '20260306_files_file_variant',
299
- 'migration.sql',
300
- );
301
- assert.equal(fs.existsSync(variantMigration), true);
302
- }
303
-
304
- function assertFilesLocalWiring(projectRoot) {
305
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
306
- assert.match(appModule, /filesLocalConfig/);
307
- assert.match(appModule, /filesLocalEnvSchemaZod/);
308
- assert.match(appModule, /FilesLocalConfigModule/);
309
-
310
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
311
- assert.match(apiPackage, /@forgeon\/files-local/);
312
- assert.match(apiPackage, /pnpm --filter @forgeon\/files-local build/);
313
-
314
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
315
- assert.match(apiDockerfile, /COPY packages\/files-local\/package\.json packages\/files-local\/package\.json/);
316
- assert.match(apiDockerfile, /COPY packages\/files-local packages\/files-local/);
317
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-local build/);
318
-
319
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
320
- assert.match(apiEnv, /FILES_LOCAL_ROOT=storage\/uploads/);
321
-
322
- const gitignore = fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8');
323
- assert.match(gitignore, /storage\//);
324
-
325
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
326
- assert.match(compose, /files_data:\/app\/storage/);
327
- assert.match(compose, /^\s{2}files_data:\s*$/m);
328
- }
329
-
330
- function assertFilesS3Wiring(projectRoot) {
331
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
332
- assert.match(appModule, /filesS3Config/);
333
- assert.match(appModule, /filesS3EnvSchemaZod/);
334
- assert.match(appModule, /FilesS3ConfigModule/);
335
-
336
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
337
- assert.match(apiPackage, /@forgeon\/files-s3/);
338
- assert.match(apiPackage, /pnpm --filter @forgeon\/files-s3 build/);
339
-
340
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
341
- assert.match(apiDockerfile, /COPY packages\/files-s3\/package\.json packages\/files-s3\/package\.json/);
342
- assert.match(apiDockerfile, /COPY packages\/files-s3 packages\/files-s3/);
343
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-s3 build/);
344
-
345
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
346
- assert.match(apiEnv, /FILES_STORAGE_DRIVER=s3/);
347
- assert.match(apiEnv, /FILES_S3_PROVIDER_PRESET=minio/);
348
- assert.match(apiEnv, /FILES_S3_BUCKET=forgeon-files/);
349
- assert.match(apiEnv, /FILES_S3_REGION=/);
350
- assert.match(apiEnv, /FILES_S3_ENDPOINT=/);
351
- assert.match(apiEnv, /FILES_S3_FORCE_PATH_STYLE=/);
352
- assert.match(apiEnv, /FILES_S3_MAX_ATTEMPTS=3/);
353
-
354
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
355
- assert.match(compose, /FILES_S3_PROVIDER_PRESET: \$\{FILES_S3_PROVIDER_PRESET\}/);
356
- assert.match(compose, /FILES_S3_MAX_ATTEMPTS: \$\{FILES_S3_MAX_ATTEMPTS\}/);
357
-
358
- const filesS3Package = fs.readFileSync(
359
- path.join(projectRoot, 'packages', 'files-s3', 'package.json'),
360
- 'utf8',
361
- );
362
- assert.match(filesS3Package, /@aws-sdk\/client-s3/);
363
- }
364
-
365
- function assertFilesAccessWiring(projectRoot) {
366
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
367
- assert.match(appModule, /ForgeonFilesAccessModule/);
368
-
369
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
370
- assert.match(apiPackage, /@forgeon\/files-access/);
371
- assert.match(apiPackage, /pnpm --filter @forgeon\/files-access build/);
372
- assert.equal(
373
- apiPackage.indexOf('pnpm --filter @forgeon/files-access build') <
374
- apiPackage.indexOf('pnpm --filter @forgeon/files build'),
375
- true,
376
- );
377
-
378
- const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
379
- assert.match(filesPackage, /@forgeon\/files-access/);
380
-
381
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
382
- assert.match(
383
- apiDockerfile,
384
- /COPY packages\/files-access\/package\.json packages\/files-access\/package\.json/,
385
- );
386
- assert.match(apiDockerfile, /COPY packages\/files-access packages\/files-access/);
387
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-access build/);
388
- assert.equal(
389
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-access build') <
390
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
391
- true,
392
- );
393
-
394
- const filesController = fs.readFileSync(
395
- path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
396
- 'utf8',
397
- );
398
- assert.match(filesController, /extractFilesAccessSubject/);
399
- assert.match(filesController, /filesAccessService\.assertCanRead/);
400
- assert.match(filesController, /filesAccessService\.assertCanDelete/);
401
- assert.match(filesController, /@Req\(\) req: any/);
402
- assert.match(filesController, /openDownload\(publicId,\s*variant\)/);
403
-
404
- const healthController = fs.readFileSync(
405
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
406
- 'utf8',
407
- );
408
- assert.match(healthController, /@Get\('files-access'\)/);
409
- assert.match(healthController, /extractFilesAccessSubject/);
410
- assert.match(healthController, /filesAccessService\.canRead/);
411
-
412
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
413
- assert.match(appTsx, /Check files access/);
414
- assert.match(appTsx, /Files access probe response/);
415
- assert.match(appTsx, /x-forgeon-user-id/);
416
-
417
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
418
- assert.match(readme, /## Files Access Module/);
419
- assert.match(readme, /resource-level authorization/i);
420
- }
421
-
422
- function assertFilesQuotasWiring(projectRoot) {
423
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
424
- assert.match(appModule, /filesQuotasConfig/);
425
- assert.match(appModule, /filesQuotasEnvSchema/);
426
- assert.match(appModule, /ForgeonFilesQuotasModule/);
427
-
428
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
429
- assert.match(apiPackage, /@forgeon\/files-quotas/);
430
- assert.match(apiPackage, /pnpm --filter @forgeon\/files-quotas build/);
431
- assert.equal(
432
- apiPackage.indexOf('pnpm --filter @forgeon/files-quotas build') <
433
- apiPackage.indexOf('pnpm --filter @forgeon/files build'),
434
- true,
435
- );
436
-
437
- const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
438
- assert.match(filesPackage, /@forgeon\/files-quotas/);
439
-
440
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
441
- assert.match(
442
- apiDockerfile,
443
- /COPY packages\/files-quotas\/package\.json packages\/files-quotas\/package\.json/,
444
- );
445
- assert.match(apiDockerfile, /COPY packages\/files-quotas packages\/files-quotas/);
446
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-quotas build/);
447
- assert.equal(
448
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-quotas build') <
449
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
450
- true,
451
- );
452
-
453
- const filesController = fs.readFileSync(
454
- path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
455
- 'utf8',
456
- );
457
- assert.match(filesController, /FilesQuotasService/);
458
- assert.match(filesController, /filesQuotasService\.assertUploadAllowed/);
459
-
460
- const healthController = fs.readFileSync(
461
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
462
- 'utf8',
463
- );
464
- assert.match(healthController, /@Get\('files-quotas'\)/);
465
- assert.match(healthController, /filesQuotasService\.getProbeStatus/);
466
-
467
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
468
- assert.match(appTsx, /Check files quotas/);
469
- assert.match(appTsx, /Files quotas probe response/);
470
-
471
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
472
- assert.match(apiEnv, /FILES_QUOTAS_ENABLED=true/);
473
- assert.match(apiEnv, /FILES_QUOTA_MAX_FILES_PER_OWNER=100/);
474
- assert.match(apiEnv, /FILES_QUOTA_MAX_BYTES_PER_OWNER=104857600/);
475
-
476
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
477
- assert.match(compose, /FILES_QUOTAS_ENABLED: \$\{FILES_QUOTAS_ENABLED\}/);
478
-
479
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
480
- assert.match(readme, /## Files Quotas Module/);
481
- assert.match(readme, /owner-based limits/i);
482
- }
483
-
484
- function assertFilesImageWiring(projectRoot) {
485
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
486
- assert.match(appModule, /filesImageConfig/);
487
- assert.match(appModule, /filesImageEnvSchema/);
488
- assert.match(appModule, /ForgeonFilesImageModule/);
489
-
490
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
491
- assert.match(apiPackage, /@forgeon\/files-image/);
492
- assert.match(apiPackage, /pnpm --filter @forgeon\/files-image build/);
493
- assert.equal(
494
- apiPackage.indexOf('pnpm --filter @forgeon/files-image build') <
495
- apiPackage.indexOf('pnpm --filter @forgeon/files build'),
496
- true,
497
- );
498
-
499
- const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
500
- assert.match(filesPackage, /@forgeon\/files-image/);
501
-
502
- const filesModule = fs.readFileSync(
503
- path.join(projectRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts'),
504
- 'utf8',
505
- );
506
- assert.match(filesModule, /ForgeonFilesImageModule/);
507
-
508
- const filesService = fs.readFileSync(
509
- path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
510
- 'utf8',
511
- );
512
- assert.match(filesService, /FilesImageService/);
513
- assert.match(filesService, /filesImageService\.sanitizeForStorage/);
514
- assert.match(filesService, /sanitizeForStorage\({/);
515
- assert.match(filesService, /auditContext: input\.auditContext/);
516
-
517
- const filesController = fs.readFileSync(
518
- path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
519
- 'utf8',
520
- );
521
- assert.match(filesController, /@Req\(\) req: any/);
522
- assert.match(filesController, /requestId:/);
523
-
524
- const filesTypes = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'src', 'files.types.ts'), 'utf8');
525
- assert.match(filesTypes, /auditContext\?: \{/);
526
-
527
- const filesImageService = fs.readFileSync(
528
- path.join(projectRoot, 'packages', 'files-image', 'src', 'files-image.service.ts'),
529
- 'utf8',
530
- );
531
- assert.match(filesImageService, /loadFileTypeModule/);
532
- assert.match(filesImageService, /new Function\('specifier', 'return import\(specifier\)'\)/);
533
-
534
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
535
- assert.match(
536
- apiDockerfile,
537
- /COPY packages\/files-image\/package\.json packages\/files-image\/package\.json/,
538
- );
539
- assert.match(apiDockerfile, /COPY packages\/files-image packages\/files-image/);
540
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-image build/);
541
- assert.equal(
542
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-image build') <
543
- apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
544
- true,
545
- );
546
-
547
- const healthController = fs.readFileSync(
548
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
549
- 'utf8',
550
- );
551
- assert.match(healthController, /@Get\('files-image'\)/);
552
- assert.match(healthController, /filesImageService\.getProbeStatus/);
553
-
554
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
555
- assert.match(appTsx, /Check files image sanitize/);
556
- assert.match(appTsx, /Files image probe response/);
557
-
558
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
559
- assert.match(apiEnv, /FILES_IMAGE_ENABLED=true/);
560
- assert.match(apiEnv, /FILES_IMAGE_STRIP_METADATA=true/);
561
- assert.match(apiEnv, /FILES_IMAGE_MAX_WIDTH=4096/);
562
- assert.match(apiEnv, /FILES_IMAGE_MAX_HEIGHT=4096/);
563
- assert.match(apiEnv, /FILES_IMAGE_MAX_PIXELS=16777216/);
564
- assert.match(apiEnv, /FILES_IMAGE_MAX_FRAMES=1/);
565
- assert.match(apiEnv, /FILES_IMAGE_PROCESS_TIMEOUT_MS=5000/);
566
- assert.match(apiEnv, /FILES_IMAGE_ALLOWED_MIME_TYPES=image\/jpeg,image\/png,image\/webp/);
567
-
568
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
569
- assert.match(compose, /FILES_IMAGE_ENABLED: \$\{FILES_IMAGE_ENABLED\}/);
570
- assert.match(compose, /FILES_IMAGE_STRIP_METADATA: \$\{FILES_IMAGE_STRIP_METADATA\}/);
571
- assert.match(compose, /FILES_IMAGE_MAX_WIDTH: \$\{FILES_IMAGE_MAX_WIDTH\}/);
572
- assert.match(compose, /FILES_IMAGE_MAX_HEIGHT: \$\{FILES_IMAGE_MAX_HEIGHT\}/);
573
- assert.match(compose, /FILES_IMAGE_MAX_PIXELS: \$\{FILES_IMAGE_MAX_PIXELS\}/);
574
- assert.match(compose, /FILES_IMAGE_MAX_FRAMES: \$\{FILES_IMAGE_MAX_FRAMES\}/);
575
- assert.match(compose, /FILES_IMAGE_PROCESS_TIMEOUT_MS: \$\{FILES_IMAGE_PROCESS_TIMEOUT_MS\}/);
576
-
577
- const filesImagePackage = fs.readFileSync(
578
- path.join(projectRoot, 'packages', 'files-image', 'package.json'),
579
- 'utf8',
580
- );
581
- assert.match(filesImagePackage, /"sharp":/);
582
- assert.match(filesImagePackage, /"file-type":/);
583
-
584
- const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
585
- assert.match(rootPackage, /"onlyBuiltDependencies"/);
586
- assert.match(rootPackage, /"sharp"/);
587
-
588
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
589
- assert.match(readme, /## Files Image Module/);
590
- assert.match(readme, /metadata is stripped before storage/i);
591
- }
592
-
593
- function assertJwtAuthWiring(projectRoot, withPrismaStore) {
594
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
595
- assert.match(apiPackage, /@forgeon\/auth-api/);
596
- assert.match(apiPackage, /@forgeon\/auth-contracts/);
597
- assert.match(apiPackage, /pnpm --filter @forgeon\/auth-contracts build/);
598
- assert.match(apiPackage, /pnpm --filter @forgeon\/auth-api build/);
599
-
600
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
601
- assert.match(appModule, /authConfig/);
602
- assert.match(appModule, /authEnvSchema/);
603
- assert.match(appModule, /ForgeonAuthModule\.register\(/);
604
- if (withPrismaStore) {
605
- assert.match(appModule, /AUTH_REFRESH_TOKEN_STORE/);
606
- assert.match(appModule, /PrismaAuthRefreshTokenStore/);
607
- } else {
608
- assert.doesNotMatch(appModule, /PrismaAuthRefreshTokenStore/);
609
- }
610
-
611
- const healthController = fs.readFileSync(
612
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
613
- 'utf8',
614
- );
615
- assert.match(healthController, /@Get\('auth'\)/);
616
- assert.match(healthController, /authService\.getProbeStatus/);
617
- assert.doesNotMatch(healthController, /,\s*,/);
618
-
619
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
620
- assert.match(appTsx, /Check JWT auth probe/);
621
- assert.match(appTsx, /Auth probe response/);
622
-
623
- const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
624
- assert.match(
625
- apiDockerfile,
626
- /COPY packages\/auth-contracts\/package\.json packages\/auth-contracts\/package\.json/,
627
- );
628
- assert.match(apiDockerfile, /COPY packages\/auth-api\/package\.json packages\/auth-api\/package\.json/);
629
- assert.match(apiDockerfile, /COPY packages\/auth-contracts packages\/auth-contracts/);
630
- assert.match(apiDockerfile, /COPY packages\/auth-api packages\/auth-api/);
631
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/auth-contracts build/);
632
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/auth-api build/);
633
-
634
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
635
- assert.match(apiEnv, /JWT_ACCESS_SECRET=/);
636
- assert.match(apiEnv, /JWT_REFRESH_SECRET=/);
637
- assert.match(apiEnv, /AUTH_DEMO_EMAIL=/);
638
- assert.match(apiEnv, /AUTH_DEMO_PASSWORD=/);
639
-
640
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
641
- assert.match(compose, /JWT_ACCESS_SECRET: \$\{JWT_ACCESS_SECRET\}/);
642
- assert.match(compose, /JWT_REFRESH_SECRET: \$\{JWT_REFRESH_SECRET\}/);
643
-
644
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
645
- assert.match(readme, /## JWT Auth Module/);
646
-
647
- const authServiceSource = fs.readFileSync(
648
- path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
649
- 'utf8',
650
- );
651
- assert.match(authServiceSource, /import type \{/);
652
- assert.doesNotMatch(authServiceSource, /import\s*\{\s*AUTH_ERROR_CODES/);
653
- }
654
-
655
- function stripDbPrismaArtifacts(projectRoot) {
656
- const dbPackageDir = path.join(projectRoot, 'packages', 'db-prisma');
657
- if (fs.existsSync(dbPackageDir)) {
658
- fs.rmSync(dbPackageDir, { recursive: true, force: true });
659
- }
660
-
661
- const prismaDir = path.join(projectRoot, 'apps', 'api', 'prisma');
662
- if (fs.existsSync(prismaDir)) {
663
- fs.rmSync(prismaDir, { recursive: true, force: true });
664
- }
665
-
666
- const apiPackagePath = path.join(projectRoot, 'apps', 'api', 'package.json');
667
- const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
668
- if (apiPackage.dependencies) {
669
- delete apiPackage.dependencies['@forgeon/db-prisma'];
670
- delete apiPackage.dependencies['@prisma/client'];
671
- }
672
- if (apiPackage.devDependencies) {
673
- delete apiPackage.devDependencies.prisma;
674
- }
675
- if (apiPackage.scripts) {
676
- for (const key of Object.keys(apiPackage.scripts)) {
677
- if (key.startsWith('prisma:')) {
678
- delete apiPackage.scripts[key];
679
- }
680
- }
681
- if (typeof apiPackage.scripts.predev === 'string') {
682
- apiPackage.scripts.predev = apiPackage.scripts.predev
683
- .replace('pnpm --filter @forgeon/db-prisma build && ', '')
684
- .replace(' && pnpm --filter @forgeon/db-prisma build', '')
685
- .replace('pnpm --filter @forgeon/db-prisma build', '')
686
- .trim();
687
- if (apiPackage.scripts.predev.length === 0) {
688
- delete apiPackage.scripts.predev;
689
- }
690
- }
691
- }
692
- delete apiPackage.prisma;
693
- fs.writeFileSync(apiPackagePath, `${JSON.stringify(apiPackage, null, 2)}\n`, 'utf8');
694
-
695
- const rootPackagePath = path.join(projectRoot, 'package.json');
696
- const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
697
- if (rootPackage.scripts && typeof rootPackage.scripts.postinstall === 'string') {
698
- rootPackage.scripts.postinstall = rootPackage.scripts.postinstall
699
- .replace(/\s*&&\s*pnpm --filter @forgeon\/api prisma:generate/g, '')
700
- .replace(/pnpm --filter @forgeon\/api prisma:generate\s*&&\s*/g, '')
701
- .trim();
702
- if (rootPackage.scripts.postinstall.length === 0) {
703
- delete rootPackage.scripts.postinstall;
704
- }
705
- }
706
- fs.writeFileSync(rootPackagePath, `${JSON.stringify(rootPackage, null, 2)}\n`, 'utf8');
707
-
708
- const appModulePath = path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts');
709
- let appModule = fs.readFileSync(appModulePath, 'utf8');
710
- appModule = appModule
711
- .replace(/^import \{ dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule \} from '@forgeon\/db-prisma';\r?\n/m, '')
712
- .replace(/,\s*dbPrismaConfig/g, '')
713
- .replace(/dbPrismaConfig,\s*/g, '')
714
- .replace(/,\s*dbPrismaEnvSchema/g, '')
715
- .replace(/dbPrismaEnvSchema,\s*/g, '')
716
- .replace(/^\s*DbPrismaModule,\r?\n/gm, '');
717
- fs.writeFileSync(appModulePath, appModule, 'utf8');
718
-
719
- const healthControllerPath = path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
720
- let healthController = fs.readFileSync(healthControllerPath, 'utf8');
721
- healthController = healthController
722
- .replace(/^import \{ PrismaService \} from '@forgeon\/db-prisma';\r?\n/m, '')
723
- .replace(/\s*private readonly prisma: PrismaService,\r?\n/g, '\n')
724
- .replace(
725
- /\s*@Post\('db'\)\s*async getDbProbe\(\)\s*\{[\s\S]*?\n\s*\}\r?\n/g,
726
- '\n',
727
- );
728
- fs.writeFileSync(healthControllerPath, healthController, 'utf8');
729
-
730
- const apiDockerfilePath = path.join(projectRoot, 'apps', 'api', 'Dockerfile');
731
- let apiDockerfile = fs.readFileSync(apiDockerfilePath, 'utf8');
732
- apiDockerfile = apiDockerfile
733
- .replace(/^COPY apps\/api\/prisma apps\/api\/prisma\r?\n/gm, '')
734
- .replace(/^COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json\r?\n/gm, '')
735
- .replace(/^COPY packages\/db-prisma packages\/db-prisma\r?\n/gm, '')
736
- .replace(/^RUN pnpm --filter @forgeon\/db-prisma build\r?\n/gm, '')
737
- .replace(/^RUN pnpm --filter @forgeon\/api prisma:generate\r?\n/gm, '');
738
- fs.writeFileSync(apiDockerfilePath, apiDockerfile, 'utf8');
739
-
740
- const composePath = path.join(projectRoot, 'infra', 'docker', 'compose.yml');
741
- let compose = fs.readFileSync(composePath, 'utf8');
742
- compose = compose.replace(/^\s+DATABASE_URL:.*\r?\n/gm, '');
743
- fs.writeFileSync(composePath, compose, 'utf8');
744
-
745
- const apiEnvExamplePath = path.join(projectRoot, 'apps', 'api', '.env.example');
746
- let apiEnv = fs.readFileSync(apiEnvExamplePath, 'utf8');
747
- apiEnv = apiEnv.replace(/^DATABASE_URL=.*\r?\n/gm, '');
748
- fs.writeFileSync(apiEnvExamplePath, apiEnv, 'utf8');
749
-
750
- const dockerEnvExamplePath = path.join(projectRoot, 'infra', 'docker', '.env.example');
751
- let dockerEnv = fs.readFileSync(dockerEnvExamplePath, 'utf8');
752
- dockerEnv = dockerEnv.replace(/^DATABASE_URL=.*\r?\n/gm, '');
753
- fs.writeFileSync(dockerEnvExamplePath, dockerEnv, 'utf8');
754
- }
755
-
756
- describe('addModule', () => {
15
+ function createMinimalForgeonProject(targetRoot) {
16
+ fs.mkdirSync(path.join(targetRoot, 'apps', 'api'), { recursive: true });
17
+ fs.writeFileSync(path.join(targetRoot, 'package.json'), '{"name":"demo"}\n', 'utf8');
18
+ fs.writeFileSync(path.join(targetRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8');
19
+ }
20
+
21
+ function assertDbPrismaWiring(projectRoot) {
22
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
23
+ assert.match(appModule, /dbPrismaConfig/);
24
+ assert.match(appModule, /dbPrismaEnvSchema/);
25
+ assert.match(appModule, /DbPrismaModule/);
26
+
27
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
28
+ assert.match(apiPackage, /@forgeon\/db-prisma/);
29
+
30
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
31
+ assert.match(apiDockerfile, /COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json/);
32
+ assert.match(apiDockerfile, /COPY packages\/db-prisma packages\/db-prisma/);
33
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/db-prisma build/);
34
+
35
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
36
+ assert.match(compose, /DATABASE_URL: \$\{DATABASE_URL\}/);
37
+
38
+ const healthController = fs.readFileSync(
39
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
40
+ 'utf8',
41
+ );
42
+ assert.match(healthController, /PrismaService/);
43
+ }
44
+
45
+ function assertRateLimitWiring(projectRoot) {
46
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
47
+ assert.match(appModule, /rateLimitConfig/);
48
+ assert.match(appModule, /rateLimitEnvSchema/);
49
+ assert.match(appModule, /ForgeonRateLimitModule/);
50
+
51
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
52
+ assert.match(apiPackage, /@forgeon\/rate-limit/);
53
+ assert.match(apiPackage, /pnpm --filter @forgeon\/rate-limit build/);
54
+
55
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
56
+ assert.match(apiDockerfile, /COPY packages\/rate-limit\/package\.json packages\/rate-limit\/package\.json/);
57
+ assert.match(apiDockerfile, /COPY packages\/rate-limit packages\/rate-limit/);
58
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rate-limit build/);
59
+
60
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
61
+ assert.match(compose, /THROTTLE_ENABLED: \$\{THROTTLE_ENABLED\}/);
62
+ assert.match(compose, /THROTTLE_LIMIT: \$\{THROTTLE_LIMIT\}/);
63
+
64
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
65
+ assert.match(apiEnv, /THROTTLE_ENABLED=true/);
66
+ assert.match(apiEnv, /THROTTLE_TTL=10/);
67
+ assert.match(apiEnv, /THROTTLE_LIMIT=3/);
68
+
69
+ const healthController = fs.readFileSync(
70
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
71
+ 'utf8',
72
+ );
73
+ assert.match(healthController, /import \{ Header \} from '@nestjs\/common';/);
74
+ assert.match(healthController, /@Header\('Cache-Control', 'no-store, no-cache, must-revalidate'\)/);
75
+ assert.match(healthController, /@Get\('rate-limit'\)/);
76
+ assert.match(healthController, /TOO_MANY_REQUESTS/);
77
+
78
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
79
+ assert.match(appTsx, /cache: 'no-store'/);
80
+ assert.match(appTsx, /Check rate limit \(click repeatedly\)/);
81
+ assert.match(appTsx, /Rate limit probe response/);
82
+
83
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
84
+ assert.match(readme, /## Rate Limit Module/);
85
+ assert.match(readme, /installs independently/i);
86
+ assert.match(readme, /no optional integration sync is required/i);
87
+ }
88
+
89
+ function assertQueueWiring(projectRoot) {
90
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
91
+ assert.match(appModule, /queueConfig/);
92
+ assert.match(appModule, /queueEnvSchema/);
93
+ assert.match(appModule, /ForgeonQueueModule/);
94
+
95
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
96
+ assert.match(apiPackage, /@forgeon\/queue/);
97
+ assert.match(apiPackage, /pnpm --filter @forgeon\/queue build/);
98
+
99
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
100
+ assert.match(apiDockerfile, /COPY packages\/queue\/package\.json packages\/queue\/package\.json/);
101
+ assert.match(apiDockerfile, /COPY packages\/queue packages\/queue/);
102
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/queue build/);
103
+
104
+ const healthController = fs.readFileSync(
105
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
106
+ 'utf8',
107
+ );
108
+ assert.match(healthController, /QueueService/);
109
+ assert.match(healthController, /@Get\('queue'\)/);
110
+ assert.match(healthController, /queueService\.getProbeStatus/);
111
+
112
+ const webApp = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
113
+ assert.match(webApp, /Check queue health/);
114
+ assert.match(webApp, /Queue probe response/);
115
+
116
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
117
+ assert.match(apiEnv, /QUEUE_ENABLED=true/);
118
+ assert.match(apiEnv, /QUEUE_REDIS_URL=redis:\/\/localhost:6379/);
119
+ assert.match(apiEnv, /QUEUE_DEFAULT_ATTEMPTS=3/);
120
+ assert.match(apiEnv, /QUEUE_DEFAULT_BACKOFF_MS=1000/);
121
+
122
+ const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
123
+ assert.match(dockerEnv, /QUEUE_REDIS_URL=redis:\/\/redis:6379/);
124
+
125
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
126
+ assert.match(compose, /^\s{2}redis:\s*$/m);
127
+ assert.match(compose, /QUEUE_ENABLED: \$\{QUEUE_ENABLED\}/);
128
+ assert.match(compose, /api:\n\s+build:\n\s+context: \.\.\/\.\.\n\s+dockerfile: apps\/api\/Dockerfile/);
129
+ assert.match(compose, /restart: unless-stopped\n\s+depends_on:\n\s+redis:\n\s+condition: service_healthy/);
130
+
131
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
132
+ assert.match(readme, /## Queue Module/);
133
+ assert.match(readme, /runtime baseline backed by Redis/i);
134
+ }
135
+
136
+ function assertSchedulerWiring(projectRoot) {
137
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
138
+ assert.match(appModule, /schedulerConfig/);
139
+ assert.match(appModule, /schedulerEnvSchema/);
140
+ assert.match(appModule, /ForgeonSchedulerModule/);
141
+
142
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
143
+ assert.match(apiPackage, /@forgeon\/scheduler/);
144
+ assert.match(apiPackage, /pnpm --filter @forgeon\/scheduler build/);
145
+
146
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
147
+ assert.match(apiDockerfile, /COPY packages\/scheduler\/package\.json packages\/scheduler\/package\.json/);
148
+ assert.match(apiDockerfile, /COPY packages\/scheduler packages\/scheduler/);
149
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/scheduler build/);
150
+
151
+ const healthController = fs.readFileSync(
152
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
153
+ 'utf8',
154
+ );
155
+ assert.match(healthController, /ForgeonSchedulerService/);
156
+ assert.match(healthController, /@Get\('scheduler'\)/);
157
+ assert.match(healthController, /schedulerService\.getProbeStatus/);
158
+
159
+ const webApp = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
160
+ assert.match(webApp, /Check scheduler health/);
161
+ assert.match(webApp, /Scheduler probe response/);
162
+
163
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
164
+ assert.match(apiEnv, /SCHEDULER_ENABLED=true/);
165
+ assert.match(apiEnv, /SCHEDULER_TIMEZONE=UTC/);
166
+ assert.match(apiEnv, /SCHEDULER_HEARTBEAT_CRON=\*\/5 \* \* \* \*/);
167
+
168
+ const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
169
+ assert.match(dockerEnv, /SCHEDULER_TIMEZONE=UTC/);
170
+
171
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
172
+ assert.match(compose, /SCHEDULER_ENABLED: \$\{SCHEDULER_ENABLED\}/);
173
+ assert.match(compose, /SCHEDULER_HEARTBEAT_CRON: \$\{SCHEDULER_HEARTBEAT_CRON\}/);
174
+ assert.doesNotMatch(compose, /^\s{2}scheduler:\s*$/m);
175
+
176
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
177
+ assert.match(readme, /## Scheduler Module/);
178
+ assert.match(readme, /cron-based orchestration/i);
179
+ }
180
+
181
+ function assertRbacWiring(projectRoot) {
182
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
183
+ assert.match(appModule, /ForgeonRbacModule/);
184
+
185
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
186
+ assert.match(apiPackage, /@forgeon\/rbac/);
187
+ assert.match(apiPackage, /pnpm --filter @forgeon\/rbac build/);
188
+
189
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
190
+ assert.match(apiDockerfile, /COPY packages\/rbac\/package\.json packages\/rbac\/package\.json/);
191
+ assert.match(apiDockerfile, /COPY packages\/rbac packages\/rbac/);
192
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rbac build/);
193
+
194
+ const healthController = fs.readFileSync(
195
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
196
+ 'utf8',
197
+ );
198
+ assert.match(healthController, /UseGuards/);
199
+ assert.match(healthController, /ForgeonRbacGuard/);
200
+ assert.match(healthController, /@Get\('rbac'\)/);
201
+ assert.match(healthController, /@Permissions\('health\.rbac'\)/);
202
+
203
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
204
+ assert.match(appTsx, /Check RBAC access/);
205
+ assert.match(appTsx, /RBAC probe response/);
206
+ assert.match(appTsx, /x-forgeon-permissions/);
207
+
208
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
209
+ assert.match(readme, /## RBAC \/ Permissions Module/);
210
+ assert.match(readme, /installs independently/i);
211
+ assert.match(readme, /jwt-auth.*optional/i);
212
+ }
213
+
214
+ function assertFilesWiring(projectRoot, expectedStorageDriver = 'local') {
215
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
216
+ assert.match(appModule, /filesConfig/);
217
+ assert.match(appModule, /filesEnvSchema/);
218
+ assert.match(appModule, /ForgeonFilesModule/);
219
+
220
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
221
+ assert.match(apiPackage, /@forgeon\/files/);
222
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files build/);
223
+
224
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
225
+ assert.match(apiDockerfile, /COPY packages\/files\/package\.json packages\/files\/package\.json/);
226
+ assert.match(apiDockerfile, /COPY packages\/files packages\/files/);
227
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files build/);
228
+
229
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
230
+ assert.match(apiEnv, /FILES_ENABLED=true/);
231
+ assert.match(apiEnv, new RegExp(`FILES_STORAGE_DRIVER=${expectedStorageDriver}`));
232
+ assert.match(apiEnv, /FILES_PUBLIC_BASE_PATH=\/files/);
233
+ assert.match(apiEnv, /FILES_MAX_FILE_SIZE_BYTES=10485760/);
234
+ assert.match(apiEnv, /FILES_ALLOWED_MIME_PREFIXES=image\/,application\/pdf,text\//);
235
+
236
+ const healthController = fs.readFileSync(
237
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
238
+ 'utf8',
239
+ );
240
+ assert.match(healthController, /@Post\('files'\)/);
241
+ assert.match(healthController, /@Get\('files-variants'\)/);
242
+ assert.match(healthController, /filesService\.createProbeRecord/);
243
+ assert.match(healthController, /filesService\.getVariantsProbeStatus/);
244
+ assert.match(healthController, /filesService\.deleteByPublicId/);
245
+
246
+ const filesController = fs.readFileSync(
247
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
248
+ 'utf8',
249
+ );
250
+ assert.match(filesController, /@Query\('variant'\) variantQuery\?: string/);
251
+ assert.match(filesController, /parseVariant\(variantQuery\)/);
252
+ assert.match(filesController, /@Delete\(':publicId'\)/);
253
+
254
+ const filesService = fs.readFileSync(
255
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
256
+ 'utf8',
257
+ );
258
+ assert.match(filesService, /getOrCreateBlob/);
259
+ assert.match(filesService, /cleanupReferencedBlobs/);
260
+ assert.match(filesService, /isUniqueConstraintError/);
261
+ assert.match(filesService, /fileBlob\.deleteMany/);
262
+ assert.match(filesService, /variants:\s*\{[\s\S]*?none:\s*\{[\s\S]*?\}/);
263
+ assert.match(filesService, /prisma\.fileBlob/);
264
+
265
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
266
+ assert.match(appTsx, /Check files probe \(create metadata\)/);
267
+ assert.match(appTsx, /Check files variants capability/);
268
+ assert.match(appTsx, /Files probe response/);
269
+ assert.match(appTsx, /Files variants probe response/);
270
+
271
+ const schema = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'prisma', 'schema.prisma'), 'utf8');
272
+ assert.match(schema, /model FileRecord \{/);
273
+ assert.match(schema, /variants\s+FileVariant\[\]/);
274
+ assert.match(schema, /model FileVariant \{/);
275
+ assert.match(schema, /model FileBlob \{/);
276
+ assert.match(schema, /blobId\s+String/);
277
+ assert.match(schema, /@@unique\(\[hash,\s*size,\s*mimeType,\s*storageDriver\]\)/);
278
+ assert.match(schema, /@@unique\(\[fileId,\s*variantKey\]\)/);
279
+ assert.match(schema, /publicId\s+String\s+@unique/);
280
+ assert.match(schema, /@@index\(\[ownerType,\s*ownerId,\s*createdAt\]\)/);
281
+
282
+ const migration = path.join(
283
+ projectRoot,
284
+ 'apps',
285
+ 'api',
286
+ 'prisma',
287
+ 'migrations',
288
+ '20260306_files_file_record',
289
+ 'migration.sql',
290
+ );
291
+ assert.equal(fs.existsSync(migration), true);
292
+
293
+ const variantMigration = path.join(
294
+ projectRoot,
295
+ 'apps',
296
+ 'api',
297
+ 'prisma',
298
+ 'migrations',
299
+ '20260306_files_file_variant',
300
+ 'migration.sql',
301
+ );
302
+ assert.equal(fs.existsSync(variantMigration), true);
303
+ }
304
+
305
+ function assertFilesLocalWiring(projectRoot) {
306
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
307
+ assert.match(appModule, /filesLocalConfig/);
308
+ assert.match(appModule, /filesLocalEnvSchemaZod/);
309
+ assert.match(appModule, /FilesLocalConfigModule/);
310
+
311
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
312
+ assert.match(apiPackage, /@forgeon\/files-local/);
313
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files-local build/);
314
+
315
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
316
+ assert.match(apiDockerfile, /COPY packages\/files-local\/package\.json packages\/files-local\/package\.json/);
317
+ assert.match(apiDockerfile, /COPY packages\/files-local packages\/files-local/);
318
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-local build/);
319
+
320
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
321
+ assert.match(apiEnv, /FILES_LOCAL_ROOT=storage\/uploads/);
322
+
323
+ const gitignore = fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8');
324
+ assert.match(gitignore, /storage\//);
325
+
326
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
327
+ assert.match(compose, /files_data:\/app\/storage/);
328
+ assert.match(compose, /^\s{2}files_data:\s*$/m);
329
+ }
330
+
331
+ function assertFilesS3Wiring(projectRoot) {
332
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
333
+ assert.match(appModule, /filesS3Config/);
334
+ assert.match(appModule, /filesS3EnvSchemaZod/);
335
+ assert.match(appModule, /FilesS3ConfigModule/);
336
+
337
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
338
+ assert.match(apiPackage, /@forgeon\/files-s3/);
339
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files-s3 build/);
340
+
341
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
342
+ assert.match(apiDockerfile, /COPY packages\/files-s3\/package\.json packages\/files-s3\/package\.json/);
343
+ assert.match(apiDockerfile, /COPY packages\/files-s3 packages\/files-s3/);
344
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-s3 build/);
345
+
346
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
347
+ assert.match(apiEnv, /FILES_STORAGE_DRIVER=s3/);
348
+ assert.match(apiEnv, /FILES_S3_PROVIDER_PRESET=minio/);
349
+ assert.match(apiEnv, /FILES_S3_BUCKET=forgeon-files/);
350
+ assert.match(apiEnv, /FILES_S3_REGION=/);
351
+ assert.match(apiEnv, /FILES_S3_ENDPOINT=/);
352
+ assert.match(apiEnv, /FILES_S3_FORCE_PATH_STYLE=/);
353
+ assert.match(apiEnv, /FILES_S3_MAX_ATTEMPTS=3/);
354
+
355
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
356
+ assert.match(compose, /FILES_S3_PROVIDER_PRESET: \$\{FILES_S3_PROVIDER_PRESET\}/);
357
+ assert.match(compose, /FILES_S3_MAX_ATTEMPTS: \$\{FILES_S3_MAX_ATTEMPTS\}/);
358
+
359
+ const filesS3Package = fs.readFileSync(
360
+ path.join(projectRoot, 'packages', 'files-s3', 'package.json'),
361
+ 'utf8',
362
+ );
363
+ assert.match(filesS3Package, /@aws-sdk\/client-s3/);
364
+ }
365
+
366
+ function assertFilesAccessWiring(projectRoot) {
367
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
368
+ assert.match(appModule, /ForgeonFilesAccessModule/);
369
+
370
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
371
+ assert.match(apiPackage, /@forgeon\/files-access/);
372
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files-access build/);
373
+ assert.equal(
374
+ apiPackage.indexOf('pnpm --filter @forgeon/files-access build') <
375
+ apiPackage.indexOf('pnpm --filter @forgeon/files build'),
376
+ true,
377
+ );
378
+
379
+ const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
380
+ assert.match(filesPackage, /@forgeon\/files-access/);
381
+
382
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
383
+ assert.match(
384
+ apiDockerfile,
385
+ /COPY packages\/files-access\/package\.json packages\/files-access\/package\.json/,
386
+ );
387
+ assert.match(apiDockerfile, /COPY packages\/files-access packages\/files-access/);
388
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-access build/);
389
+ assert.equal(
390
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-access build') <
391
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
392
+ true,
393
+ );
394
+
395
+ const filesController = fs.readFileSync(
396
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
397
+ 'utf8',
398
+ );
399
+ assert.match(filesController, /extractFilesAccessSubject/);
400
+ assert.match(filesController, /filesAccessService\.assertCanRead/);
401
+ assert.match(filesController, /filesAccessService\.assertCanDelete/);
402
+ assert.match(filesController, /@Req\(\) req: any/);
403
+ assert.match(filesController, /openDownload\(publicId,\s*variant\)/);
404
+
405
+ const healthController = fs.readFileSync(
406
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
407
+ 'utf8',
408
+ );
409
+ assert.match(healthController, /@Get\('files-access'\)/);
410
+ assert.match(healthController, /extractFilesAccessSubject/);
411
+ assert.match(healthController, /filesAccessService\.canRead/);
412
+
413
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
414
+ assert.match(appTsx, /Check files access/);
415
+ assert.match(appTsx, /Files access probe response/);
416
+ assert.match(appTsx, /x-forgeon-user-id/);
417
+
418
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
419
+ assert.match(readme, /## Files Access Module/);
420
+ assert.match(readme, /resource-level authorization/i);
421
+ }
422
+
423
+ function assertFilesQuotasWiring(projectRoot) {
424
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
425
+ assert.match(appModule, /filesQuotasConfig/);
426
+ assert.match(appModule, /filesQuotasEnvSchema/);
427
+ assert.match(appModule, /ForgeonFilesQuotasModule/);
428
+
429
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
430
+ assert.match(apiPackage, /@forgeon\/files-quotas/);
431
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files-quotas build/);
432
+ assert.equal(
433
+ apiPackage.indexOf('pnpm --filter @forgeon/files-quotas build') <
434
+ apiPackage.indexOf('pnpm --filter @forgeon/files build'),
435
+ true,
436
+ );
437
+
438
+ const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
439
+ assert.match(filesPackage, /@forgeon\/files-quotas/);
440
+
441
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
442
+ assert.match(
443
+ apiDockerfile,
444
+ /COPY packages\/files-quotas\/package\.json packages\/files-quotas\/package\.json/,
445
+ );
446
+ assert.match(apiDockerfile, /COPY packages\/files-quotas packages\/files-quotas/);
447
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-quotas build/);
448
+ assert.equal(
449
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-quotas build') <
450
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
451
+ true,
452
+ );
453
+
454
+ const filesController = fs.readFileSync(
455
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
456
+ 'utf8',
457
+ );
458
+ assert.match(filesController, /FilesQuotasService/);
459
+ assert.match(filesController, /filesQuotasService\.assertUploadAllowed/);
460
+
461
+ const healthController = fs.readFileSync(
462
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
463
+ 'utf8',
464
+ );
465
+ assert.match(healthController, /@Get\('files-quotas'\)/);
466
+ assert.match(healthController, /filesQuotasService\.getProbeStatus/);
467
+
468
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
469
+ assert.match(appTsx, /Check files quotas/);
470
+ assert.match(appTsx, /Files quotas probe response/);
471
+
472
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
473
+ assert.match(apiEnv, /FILES_QUOTAS_ENABLED=true/);
474
+ assert.match(apiEnv, /FILES_QUOTA_MAX_FILES_PER_OWNER=100/);
475
+ assert.match(apiEnv, /FILES_QUOTA_MAX_BYTES_PER_OWNER=104857600/);
476
+
477
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
478
+ assert.match(compose, /FILES_QUOTAS_ENABLED: \$\{FILES_QUOTAS_ENABLED\}/);
479
+
480
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
481
+ assert.match(readme, /## Files Quotas Module/);
482
+ assert.match(readme, /owner-based limits/i);
483
+ }
484
+
485
+ function assertFilesImageWiring(projectRoot) {
486
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
487
+ assert.match(appModule, /filesImageConfig/);
488
+ assert.match(appModule, /filesImageEnvSchema/);
489
+ assert.match(appModule, /ForgeonFilesImageModule/);
490
+
491
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
492
+ assert.match(apiPackage, /@forgeon\/files-image/);
493
+ assert.match(apiPackage, /pnpm --filter @forgeon\/files-image build/);
494
+ assert.equal(
495
+ apiPackage.indexOf('pnpm --filter @forgeon/files-image build') <
496
+ apiPackage.indexOf('pnpm --filter @forgeon/files build'),
497
+ true,
498
+ );
499
+
500
+ const filesPackage = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'package.json'), 'utf8');
501
+ assert.match(filesPackage, /@forgeon\/files-image/);
502
+
503
+ const filesModule = fs.readFileSync(
504
+ path.join(projectRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts'),
505
+ 'utf8',
506
+ );
507
+ assert.match(filesModule, /ForgeonFilesImageModule/);
508
+
509
+ const filesService = fs.readFileSync(
510
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
511
+ 'utf8',
512
+ );
513
+ assert.match(filesService, /FilesImageService/);
514
+ assert.match(filesService, /filesImageService\.sanitizeForStorage/);
515
+ assert.match(filesService, /sanitizeForStorage\({/);
516
+ assert.match(filesService, /auditContext: input\.auditContext/);
517
+
518
+ const filesController = fs.readFileSync(
519
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.controller.ts'),
520
+ 'utf8',
521
+ );
522
+ assert.match(filesController, /@Req\(\) req: any/);
523
+ assert.match(filesController, /requestId:/);
524
+
525
+ const filesTypes = fs.readFileSync(path.join(projectRoot, 'packages', 'files', 'src', 'files.types.ts'), 'utf8');
526
+ assert.match(filesTypes, /auditContext\?: \{/);
527
+
528
+ const filesImageService = fs.readFileSync(
529
+ path.join(projectRoot, 'packages', 'files-image', 'src', 'files-image.service.ts'),
530
+ 'utf8',
531
+ );
532
+ assert.match(filesImageService, /loadFileTypeModule/);
533
+ assert.match(filesImageService, /new Function\('specifier', 'return import\(specifier\)'\)/);
534
+
535
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
536
+ assert.match(
537
+ apiDockerfile,
538
+ /COPY packages\/files-image\/package\.json packages\/files-image\/package\.json/,
539
+ );
540
+ assert.match(apiDockerfile, /COPY packages\/files-image packages\/files-image/);
541
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/files-image build/);
542
+ assert.equal(
543
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files-image build') <
544
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/files build'),
545
+ true,
546
+ );
547
+
548
+ const healthController = fs.readFileSync(
549
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
550
+ 'utf8',
551
+ );
552
+ assert.match(healthController, /@Get\('files-image'\)/);
553
+ assert.match(healthController, /filesImageService\.getProbeStatus/);
554
+
555
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
556
+ assert.match(appTsx, /Check files image sanitize/);
557
+ assert.match(appTsx, /Files image probe response/);
558
+
559
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
560
+ assert.match(apiEnv, /FILES_IMAGE_ENABLED=true/);
561
+ assert.match(apiEnv, /FILES_IMAGE_STRIP_METADATA=true/);
562
+ assert.match(apiEnv, /FILES_IMAGE_MAX_WIDTH=4096/);
563
+ assert.match(apiEnv, /FILES_IMAGE_MAX_HEIGHT=4096/);
564
+ assert.match(apiEnv, /FILES_IMAGE_MAX_PIXELS=16777216/);
565
+ assert.match(apiEnv, /FILES_IMAGE_MAX_FRAMES=1/);
566
+ assert.match(apiEnv, /FILES_IMAGE_PROCESS_TIMEOUT_MS=5000/);
567
+ assert.match(apiEnv, /FILES_IMAGE_ALLOWED_MIME_TYPES=image\/jpeg,image\/png,image\/webp/);
568
+
569
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
570
+ assert.match(compose, /FILES_IMAGE_ENABLED: \$\{FILES_IMAGE_ENABLED\}/);
571
+ assert.match(compose, /FILES_IMAGE_STRIP_METADATA: \$\{FILES_IMAGE_STRIP_METADATA\}/);
572
+ assert.match(compose, /FILES_IMAGE_MAX_WIDTH: \$\{FILES_IMAGE_MAX_WIDTH\}/);
573
+ assert.match(compose, /FILES_IMAGE_MAX_HEIGHT: \$\{FILES_IMAGE_MAX_HEIGHT\}/);
574
+ assert.match(compose, /FILES_IMAGE_MAX_PIXELS: \$\{FILES_IMAGE_MAX_PIXELS\}/);
575
+ assert.match(compose, /FILES_IMAGE_MAX_FRAMES: \$\{FILES_IMAGE_MAX_FRAMES\}/);
576
+ assert.match(compose, /FILES_IMAGE_PROCESS_TIMEOUT_MS: \$\{FILES_IMAGE_PROCESS_TIMEOUT_MS\}/);
577
+
578
+ const filesImagePackage = fs.readFileSync(
579
+ path.join(projectRoot, 'packages', 'files-image', 'package.json'),
580
+ 'utf8',
581
+ );
582
+ assert.match(filesImagePackage, /"sharp":/);
583
+ assert.match(filesImagePackage, /"file-type":/);
584
+
585
+ const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
586
+ assert.match(rootPackage, /"onlyBuiltDependencies"/);
587
+ assert.match(rootPackage, /"sharp"/);
588
+
589
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
590
+ assert.match(readme, /## Files Image Module/);
591
+ assert.match(readme, /metadata is stripped before storage/i);
592
+ }
593
+
594
+ function assertJwtAuthWiring(projectRoot, withPrismaStore) {
595
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
596
+ assert.match(apiPackage, /@forgeon\/auth-api/);
597
+ assert.match(apiPackage, /@forgeon\/auth-contracts/);
598
+ assert.match(apiPackage, /pnpm --filter @forgeon\/auth-contracts build/);
599
+ assert.match(apiPackage, /pnpm --filter @forgeon\/auth-api build/);
600
+
601
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
602
+ assert.match(appModule, /authConfig/);
603
+ assert.match(appModule, /authEnvSchema/);
604
+ assert.match(appModule, /ForgeonAuthModule\.register\(/);
605
+ if (withPrismaStore) {
606
+ assert.match(appModule, /AUTH_REFRESH_TOKEN_STORE/);
607
+ assert.match(appModule, /PrismaAuthRefreshTokenStore/);
608
+ } else {
609
+ assert.doesNotMatch(appModule, /PrismaAuthRefreshTokenStore/);
610
+ }
611
+
612
+ const healthController = fs.readFileSync(
613
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
614
+ 'utf8',
615
+ );
616
+ assert.match(healthController, /@Get\('auth'\)/);
617
+ assert.match(healthController, /authService\.getProbeStatus/);
618
+ assert.doesNotMatch(healthController, /,\s*,/);
619
+
620
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
621
+ assert.match(appTsx, /Check JWT auth probe/);
622
+ assert.match(appTsx, /Auth probe response/);
623
+
624
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
625
+ assert.match(
626
+ apiDockerfile,
627
+ /COPY packages\/auth-contracts\/package\.json packages\/auth-contracts\/package\.json/,
628
+ );
629
+ assert.match(apiDockerfile, /COPY packages\/auth-api\/package\.json packages\/auth-api\/package\.json/);
630
+ assert.match(apiDockerfile, /COPY packages\/auth-contracts packages\/auth-contracts/);
631
+ assert.match(apiDockerfile, /COPY packages\/auth-api packages\/auth-api/);
632
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/auth-contracts build/);
633
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/auth-api build/);
634
+
635
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
636
+ assert.match(apiEnv, /JWT_ACCESS_SECRET=/);
637
+ assert.match(apiEnv, /JWT_REFRESH_SECRET=/);
638
+ assert.match(apiEnv, /AUTH_DEMO_EMAIL=/);
639
+ assert.match(apiEnv, /AUTH_DEMO_PASSWORD=/);
640
+
641
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
642
+ assert.match(compose, /JWT_ACCESS_SECRET: \$\{JWT_ACCESS_SECRET\}/);
643
+ assert.match(compose, /JWT_REFRESH_SECRET: \$\{JWT_REFRESH_SECRET\}/);
644
+
645
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
646
+ assert.match(readme, /## JWT Auth Module/);
647
+
648
+ const authServiceSource = fs.readFileSync(
649
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
650
+ 'utf8',
651
+ );
652
+ assert.match(authServiceSource, /import type \{/);
653
+ assert.doesNotMatch(authServiceSource, /import\s*\{\s*AUTH_ERROR_CODES/);
654
+ }
655
+
656
+ function stripDbPrismaArtifacts(projectRoot) {
657
+ const dbPackageDir = path.join(projectRoot, 'packages', 'db-prisma');
658
+ if (fs.existsSync(dbPackageDir)) {
659
+ fs.rmSync(dbPackageDir, { recursive: true, force: true });
660
+ }
661
+
662
+ const prismaDir = path.join(projectRoot, 'apps', 'api', 'prisma');
663
+ if (fs.existsSync(prismaDir)) {
664
+ fs.rmSync(prismaDir, { recursive: true, force: true });
665
+ }
666
+
667
+ const apiPackagePath = path.join(projectRoot, 'apps', 'api', 'package.json');
668
+ const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
669
+ if (apiPackage.dependencies) {
670
+ delete apiPackage.dependencies['@forgeon/db-prisma'];
671
+ delete apiPackage.dependencies['@prisma/client'];
672
+ }
673
+ if (apiPackage.devDependencies) {
674
+ delete apiPackage.devDependencies.prisma;
675
+ }
676
+ if (apiPackage.scripts) {
677
+ for (const key of Object.keys(apiPackage.scripts)) {
678
+ if (key.startsWith('prisma:')) {
679
+ delete apiPackage.scripts[key];
680
+ }
681
+ }
682
+ if (typeof apiPackage.scripts.predev === 'string') {
683
+ apiPackage.scripts.predev = apiPackage.scripts.predev
684
+ .replace('pnpm --filter @forgeon/db-prisma build && ', '')
685
+ .replace(' && pnpm --filter @forgeon/db-prisma build', '')
686
+ .replace('pnpm --filter @forgeon/db-prisma build', '')
687
+ .trim();
688
+ if (apiPackage.scripts.predev.length === 0) {
689
+ delete apiPackage.scripts.predev;
690
+ }
691
+ }
692
+ }
693
+ delete apiPackage.prisma;
694
+ fs.writeFileSync(apiPackagePath, `${JSON.stringify(apiPackage, null, 2)}\n`, 'utf8');
695
+
696
+ const rootPackagePath = path.join(projectRoot, 'package.json');
697
+ const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
698
+ if (rootPackage.scripts && typeof rootPackage.scripts.postinstall === 'string') {
699
+ rootPackage.scripts.postinstall = rootPackage.scripts.postinstall
700
+ .replace(/\s*&&\s*pnpm --filter @forgeon\/api prisma:generate/g, '')
701
+ .replace(/pnpm --filter @forgeon\/api prisma:generate\s*&&\s*/g, '')
702
+ .trim();
703
+ if (rootPackage.scripts.postinstall.length === 0) {
704
+ delete rootPackage.scripts.postinstall;
705
+ }
706
+ }
707
+ fs.writeFileSync(rootPackagePath, `${JSON.stringify(rootPackage, null, 2)}\n`, 'utf8');
708
+
709
+ const appModulePath = path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts');
710
+ let appModule = fs.readFileSync(appModulePath, 'utf8');
711
+ appModule = appModule
712
+ .replace(/^import \{ dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule \} from '@forgeon\/db-prisma';\r?\n/m, '')
713
+ .replace(/,\s*dbPrismaConfig/g, '')
714
+ .replace(/dbPrismaConfig,\s*/g, '')
715
+ .replace(/,\s*dbPrismaEnvSchema/g, '')
716
+ .replace(/dbPrismaEnvSchema,\s*/g, '')
717
+ .replace(/^\s*DbPrismaModule,\r?\n/gm, '');
718
+ fs.writeFileSync(appModulePath, appModule, 'utf8');
719
+
720
+ const healthControllerPath = path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
721
+ let healthController = fs.readFileSync(healthControllerPath, 'utf8');
722
+ healthController = healthController
723
+ .replace(/^import \{ PrismaService \} from '@forgeon\/db-prisma';\r?\n/m, '')
724
+ .replace(/\s*private readonly prisma: PrismaService,\r?\n/g, '\n')
725
+ .replace(
726
+ /\s*@Post\('db'\)\s*async getDbProbe\(\)\s*\{[\s\S]*?\n\s*\}\r?\n/g,
727
+ '\n',
728
+ );
729
+ fs.writeFileSync(healthControllerPath, healthController, 'utf8');
730
+
731
+ const apiDockerfilePath = path.join(projectRoot, 'apps', 'api', 'Dockerfile');
732
+ let apiDockerfile = fs.readFileSync(apiDockerfilePath, 'utf8');
733
+ apiDockerfile = apiDockerfile
734
+ .replace(/^COPY apps\/api\/prisma apps\/api\/prisma\r?\n/gm, '')
735
+ .replace(/^COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json\r?\n/gm, '')
736
+ .replace(/^COPY packages\/db-prisma packages\/db-prisma\r?\n/gm, '')
737
+ .replace(/^RUN pnpm --filter @forgeon\/db-prisma build\r?\n/gm, '')
738
+ .replace(/^RUN pnpm --filter @forgeon\/api prisma:generate\r?\n/gm, '');
739
+ fs.writeFileSync(apiDockerfilePath, apiDockerfile, 'utf8');
740
+
741
+ const composePath = path.join(projectRoot, 'infra', 'docker', 'compose.yml');
742
+ let compose = fs.readFileSync(composePath, 'utf8');
743
+ compose = compose.replace(/^\s+DATABASE_URL:.*\r?\n/gm, '');
744
+ fs.writeFileSync(composePath, compose, 'utf8');
745
+
746
+ const apiEnvExamplePath = path.join(projectRoot, 'apps', 'api', '.env.example');
747
+ let apiEnv = fs.readFileSync(apiEnvExamplePath, 'utf8');
748
+ apiEnv = apiEnv.replace(/^DATABASE_URL=.*\r?\n/gm, '');
749
+ fs.writeFileSync(apiEnvExamplePath, apiEnv, 'utf8');
750
+
751
+ const dockerEnvExamplePath = path.join(projectRoot, 'infra', 'docker', '.env.example');
752
+ let dockerEnv = fs.readFileSync(dockerEnvExamplePath, 'utf8');
753
+ dockerEnv = dockerEnv.replace(/^DATABASE_URL=.*\r?\n/gm, '');
754
+ fs.writeFileSync(dockerEnvExamplePath, dockerEnv, 'utf8');
755
+ }
756
+
757
+ describe('addModule', () => {
757
758
  const modulesDir = path.dirname(fileURLToPath(import.meta.url));
758
759
  const packageRoot = path.resolve(modulesDir, '..', '..');
759
760
 
760
- it('applies queue module on top of scaffold without db and i18n', () => {
761
- const targetRoot = mkTmp('forgeon-module-queue-');
762
- const projectRoot = path.join(targetRoot, 'demo-queue');
763
- const templateRoot = path.join(packageRoot, 'templates', 'base');
764
-
765
- try {
766
- scaffoldProject({
767
- templateRoot,
768
- packageRoot,
769
- targetRoot: projectRoot,
770
- projectName: 'demo-queue',
771
- frontend: 'react',
772
- db: 'prisma',
773
- dbPrismaEnabled: false,
774
- i18nEnabled: false,
775
- proxy: 'caddy',
776
- });
777
-
778
- const result = addModule({
779
- moduleId: 'queue',
780
- targetRoot: projectRoot,
781
- packageRoot,
782
- });
783
-
784
- assert.equal(result.applied, true);
785
- assert.match(result.message, /applied/);
786
- assert.equal(fs.existsSync(result.docsPath), true);
787
- assert.match(result.docsPath, /modules[\\/].+[\\/]README\.md$/);
788
- assert.equal(fs.existsSync(path.join(projectRoot, 'modules', 'README.md')), true);
789
-
790
- assertQueueWiring(projectRoot);
791
-
792
- const note = fs.readFileSync(result.docsPath, 'utf8');
793
- assert.match(note, /Queue Worker/);
794
- assert.match(note, /Status: implemented/);
795
- } finally {
796
- fs.rmSync(targetRoot, { recursive: true, force: true });
797
- }
761
+ it('applies queue module on top of scaffold without db and i18n', () => {
762
+ const targetRoot = mkTmp('forgeon-module-queue-');
763
+ const projectRoot = path.join(targetRoot, 'demo-queue');
764
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
765
+
766
+ try {
767
+ scaffoldProject({
768
+ templateRoot,
769
+ packageRoot,
770
+ targetRoot: projectRoot,
771
+ projectName: 'demo-queue',
772
+ frontend: 'react',
773
+ db: 'prisma',
774
+ dbPrismaEnabled: false,
775
+ i18nEnabled: false,
776
+ proxy: 'caddy',
777
+ });
778
+
779
+ const result = addModule({
780
+ moduleId: 'queue',
781
+ targetRoot: projectRoot,
782
+ packageRoot,
783
+ });
784
+
785
+ assert.equal(result.applied, true);
786
+ assert.match(result.message, /applied/);
787
+ assert.equal(fs.existsSync(result.docsPath), true);
788
+ assert.match(result.docsPath, /modules[\\/].+[\\/]README\.md$/);
789
+ assert.equal(fs.existsSync(path.join(projectRoot, 'modules', 'README.md')), true);
790
+
791
+ assertQueueWiring(projectRoot);
792
+
793
+ const note = fs.readFileSync(result.docsPath, 'utf8');
794
+ assert.match(note, /Queue Worker/);
795
+ assert.match(note, /Status: implemented/);
796
+ } finally {
797
+ fs.rmSync(targetRoot, { recursive: true, force: true });
798
+ }
799
+ });
800
+
801
+ it('repairs malformed queue depends_on block from an older generator patch', () => {
802
+ const targetRoot = mkTmp('forgeon-module-queue-repair-');
803
+ const projectRoot = path.join(targetRoot, 'demo-queue-repair');
804
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
805
+
806
+ try {
807
+ scaffoldProject({
808
+ templateRoot,
809
+ packageRoot,
810
+ targetRoot: projectRoot,
811
+ projectName: 'demo-queue-repair',
812
+ frontend: 'react',
813
+ db: 'prisma',
814
+ dbPrismaEnabled: false,
815
+ i18nEnabled: false,
816
+ proxy: 'caddy',
817
+ });
818
+
819
+ addModule({
820
+ moduleId: 'queue',
821
+ targetRoot: projectRoot,
822
+ packageRoot,
823
+ });
824
+
825
+ const composePath = path.join(projectRoot, 'infra', 'docker', 'compose.yml');
826
+ let compose = fs.readFileSync(composePath, 'utf8');
827
+ compose = compose.replace(
828
+ / api:\n build:\n context: \.\.\/\.\.\n dockerfile: apps\/api\/Dockerfile\n restart: unless-stopped\n depends_on:\n redis:\n condition: service_healthy\n environment:/,
829
+ ' api:\n build:\n depends_on:\n redis:\n condition: service_healthy\n\n context: ../..\n dockerfile: apps/api/Dockerfile\n restart: unless-stopped\n environment:',
830
+ );
831
+ fs.writeFileSync(composePath, compose, 'utf8');
832
+
833
+ addModule({
834
+ moduleId: 'queue',
835
+ targetRoot: projectRoot,
836
+ packageRoot,
837
+ });
838
+
839
+ assertQueueWiring(projectRoot);
840
+ } finally {
841
+ fs.rmSync(targetRoot, { recursive: true, force: true });
842
+ }
843
+ });
844
+ it('applies scheduler module on top of project with queue installed', () => {
845
+ const targetRoot = mkTmp('forgeon-module-scheduler-');
846
+ const projectRoot = path.join(targetRoot, 'demo-scheduler');
847
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
848
+
849
+ try {
850
+ scaffoldProject({
851
+ templateRoot,
852
+ packageRoot,
853
+ targetRoot: projectRoot,
854
+ projectName: 'demo-scheduler',
855
+ frontend: 'react',
856
+ db: 'prisma',
857
+ dbPrismaEnabled: false,
858
+ i18nEnabled: false,
859
+ proxy: 'caddy',
860
+ });
861
+
862
+ addModule({
863
+ moduleId: 'queue',
864
+ targetRoot: projectRoot,
865
+ packageRoot,
866
+ });
867
+
868
+ const result = addModule({
869
+ moduleId: 'scheduler',
870
+ targetRoot: projectRoot,
871
+ packageRoot,
872
+ });
873
+
874
+ assert.equal(result.applied, true);
875
+ assert.match(result.message, /applied/);
876
+ assert.equal(fs.existsSync(result.docsPath), true);
877
+
878
+ assertQueueWiring(projectRoot);
879
+ assertSchedulerWiring(projectRoot);
880
+
881
+ addModule({
882
+ moduleId: 'queue',
883
+ targetRoot: projectRoot,
884
+ packageRoot,
885
+ });
886
+
887
+ assertQueueWiring(projectRoot);
888
+ assertSchedulerWiring(projectRoot);
889
+
890
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
891
+ assert(
892
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/queue build') <
893
+ apiDockerfile.indexOf('RUN pnpm --filter @forgeon/scheduler build'),
894
+ );
895
+
896
+ const note = fs.readFileSync(result.docsPath, 'utf8');
897
+ assert.match(note, /Scheduler/);
898
+ assert.match(note, /Status: implemented/);
899
+ } finally {
900
+ fs.rmSync(targetRoot, { recursive: true, force: true });
901
+ }
798
902
  });
799
903
 
800
- it('applies scheduler module on top of project with queue installed', () => {
801
- const targetRoot = mkTmp('forgeon-module-scheduler-');
802
- const projectRoot = path.join(targetRoot, 'demo-scheduler');
803
- const templateRoot = path.join(packageRoot, 'templates', 'base');
804
-
805
- try {
806
- scaffoldProject({
807
- templateRoot,
808
- packageRoot,
809
- targetRoot: projectRoot,
810
- projectName: 'demo-scheduler',
811
- frontend: 'react',
812
- db: 'prisma',
813
- dbPrismaEnabled: false,
814
- i18nEnabled: false,
815
- proxy: 'caddy',
816
- });
817
-
818
- addModule({
819
- moduleId: 'queue',
820
- targetRoot: projectRoot,
821
- packageRoot,
822
- });
823
-
824
- const result = addModule({
825
- moduleId: 'scheduler',
826
- targetRoot: projectRoot,
827
- packageRoot,
828
- });
829
-
830
- assert.equal(result.applied, true);
831
- assert.match(result.message, /applied/);
832
- assert.equal(fs.existsSync(result.docsPath), true);
833
-
834
- assertQueueWiring(projectRoot);
835
- assertSchedulerWiring(projectRoot);
836
-
837
- const note = fs.readFileSync(result.docsPath, 'utf8');
838
- assert.match(note, /Scheduler/);
839
- assert.match(note, /Status: implemented/);
840
- } finally {
841
- fs.rmSync(targetRoot, { recursive: true, force: true });
842
- }
843
- });
844
-
845
- it('throws for unknown module id', () => {
904
+ it('throws for unknown module id', () => {
846
905
  const targetRoot = mkTmp('forgeon-module-unknown-');
847
906
  try {
848
907
  createMinimalForgeonProject(targetRoot);
@@ -855,1591 +914,1592 @@ describe('addModule', () => {
855
914
  }),
856
915
  /Unknown module/,
857
916
  );
858
- } finally {
859
- fs.rmSync(targetRoot, { recursive: true, force: true });
860
- }
861
- });
862
-
863
- it('applies i18n module on top of scaffold without i18n', () => {
864
- const targetRoot = mkTmp('forgeon-module-i18n-');
865
- const projectRoot = path.join(targetRoot, 'demo-i18n');
866
- const templateRoot = path.join(packageRoot, 'templates', 'base');
867
-
868
- try {
869
- scaffoldProject({
870
- templateRoot,
871
- packageRoot,
872
- targetRoot: projectRoot,
873
- projectName: 'demo-i18n',
874
- frontend: 'react',
875
- db: 'prisma',
876
- dbPrismaEnabled: true,
877
- i18nEnabled: false,
878
- proxy: 'caddy',
879
- });
880
-
881
- assert.equal(fs.existsSync(path.join(projectRoot, 'docs')), false);
882
-
883
- const result = addModule({
884
- moduleId: 'i18n',
885
- targetRoot: projectRoot,
886
- packageRoot,
887
- });
888
-
889
- assert.equal(result.applied, true);
890
- assert.match(result.message, /applied/);
891
- assert.equal(
892
- fs.existsSync(path.join(projectRoot, 'packages', 'i18n-contracts', 'package.json')),
893
- true,
894
- );
895
- assert.equal(
896
- fs.existsSync(path.join(projectRoot, 'packages', 'i18n-web', 'package.json')),
897
- true,
898
- );
899
- assert.equal(fs.existsSync(path.join(projectRoot, 'tsconfig.base.node.json')), true);
900
- assert.equal(fs.existsSync(path.join(projectRoot, 'tsconfig.base.esm.json')), true);
901
-
902
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
903
- assert.match(apiPackage, /@forgeon\/db-prisma/);
904
- assert.match(apiPackage, /@forgeon\/i18n/);
905
- assert.match(apiPackage, /@forgeon\/i18n-contracts/);
906
-
907
- const apiTsconfig = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'tsconfig.json'), 'utf8');
908
- assert.match(apiTsconfig, /tsconfig\.base\.node\.json/);
909
-
910
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
911
- assert.match(compose, /I18N_DEFAULT_LANG/);
912
- assert.doesNotMatch(compose, /I18N_ENABLED/);
913
-
914
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
915
- assert.match(appModule, /coreConfig/);
916
- assert.match(appModule, /dbPrismaConfig/);
917
- assert.match(appModule, /dbPrismaEnvSchema/);
918
- assert.match(appModule, /createEnvValidator/);
919
- assert.match(appModule, /coreEnvSchema/);
920
- assert.match(appModule, /i18nConfig/);
921
- assert.match(appModule, /i18nEnvSchema/);
922
- assert.match(appModule, /CoreConfigModule/);
923
- assert.match(appModule, /CoreErrorsModule/);
924
- assert.match(appModule, /DbPrismaModule/);
925
-
926
- const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
927
- assert.match(mainTs, /CoreExceptionFilter/);
928
- assert.match(mainTs, /createValidationPipe/);
929
- assert.doesNotMatch(mainTs, /new ValidationPipe\(/);
930
-
931
- const forgeonI18nModule = fs.readFileSync(
932
- path.join(projectRoot, 'packages', 'i18n', 'src', 'forgeon-i18n.module.ts'),
933
- 'utf8',
934
- );
935
- assert.match(forgeonI18nModule, /const resolvers = \[/);
936
- assert.match(forgeonI18nModule, /I18nModule\.forRootAsync\([\s\S]*resolvers,/);
937
- assert.doesNotMatch(
938
- forgeonI18nModule,
939
- /exports:\s*\[I18nModule,\s*I18nConfigModule,\s*I18nConfigService\]/,
940
- );
941
-
942
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
943
- assert.match(appTsx, /@forgeon\/i18n-web/);
944
- assert.match(appTsx, /react-i18next/);
945
- assert.match(appTsx, /ui:labels\.language/);
946
-
947
- const i18nWebPackage = fs.readFileSync(
948
- path.join(projectRoot, 'packages', 'i18n-web', 'package.json'),
949
- 'utf8',
950
- );
951
- assert.match(i18nWebPackage, /"type": "module"/);
952
-
953
- const i18nContractsPackage = fs.readFileSync(
954
- path.join(projectRoot, 'packages', 'i18n-contracts', 'package.json'),
955
- 'utf8',
956
- );
957
- assert.match(i18nContractsPackage, /"type": "module"/);
958
-
959
- const i18nWebTsconfig = fs.readFileSync(
960
- path.join(projectRoot, 'packages', 'i18n-web', 'tsconfig.json'),
961
- 'utf8',
962
- );
963
- assert.match(i18nWebTsconfig, /tsconfig\.base\.esm\.json/);
964
-
965
- const i18nContractsTsconfig = fs.readFileSync(
966
- path.join(projectRoot, 'packages', 'i18n-contracts', 'tsconfig.json'),
967
- 'utf8',
968
- );
969
- assert.match(i18nContractsTsconfig, /tsconfig\.base\.esm\.json/);
970
-
971
- const i18nWebSource = fs.readFileSync(
972
- path.join(projectRoot, 'packages', 'i18n-web', 'src', 'index.ts'),
973
- 'utf8',
974
- );
975
- assert.match(i18nWebSource, /@forgeon\/i18n-contracts/);
976
- assert.doesNotMatch(i18nWebSource, /I18N_DEFAULT_LANG/);
977
-
978
- const i18nContractsIndex = fs.readFileSync(
979
- path.join(projectRoot, 'packages', 'i18n-contracts', 'src', 'index.ts'),
980
- 'utf8',
981
- );
982
- assert.match(i18nContractsIndex, /from '\.\/generated'/);
983
- assert.doesNotMatch(i18nContractsIndex, /I18N_DEFAULT_LANG/);
984
- assert.doesNotMatch(i18nContractsIndex, /I18N_FALLBACK_LANG/);
985
-
986
- const enCommon = JSON.parse(
987
- fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'common.json'), 'utf8'),
988
- );
989
- assert.equal(enCommon.actions.ok, 'OK');
990
- assert.equal(enCommon.nav.next, 'Next');
991
-
992
- const enErrors = JSON.parse(
993
- fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'errors.json'), 'utf8'),
994
- );
995
- assert.equal(enErrors.http.NOT_FOUND, 'Resource not found');
996
- assert.equal(enErrors.validation.VALIDATION_ERROR, 'Validation error');
997
-
998
- const webPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'package.json'), 'utf8');
999
- assert.match(webPackage, /"i18next":/);
1000
- assert.match(webPackage, /"react-i18next":/);
1001
-
1002
- const mainTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'main.tsx'), 'utf8');
1003
- assert.match(mainTsx, /import '\.\/i18n';/);
1004
-
1005
- const i18nTs = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'i18n.ts'), 'utf8');
1006
- assert.match(i18nTs, /initReactI18next/);
1007
- assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/common\.json/);
1008
- assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/ui\.json/);
1009
- assert.doesNotMatch(i18nTs, /I18N_DEFAULT_LANG/);
1010
-
1011
- const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
1012
- assert.match(rootPackage, /"forgeon:sync-integrations"/);
1013
- assert.match(rootPackage, /"i18n:sync"/);
1014
- assert.match(rootPackage, /"i18n:check"/);
1015
- assert.match(rootPackage, /"i18n:types"/);
1016
- assert.match(rootPackage, /"i18n:add"/);
1017
-
1018
- const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1019
- assert.match(rootReadme, /## I18n Module/);
1020
- assert.match(rootReadme, /installs independently/i);
1021
- assert.match(rootReadme, /multi-package split/i);
1022
- assert.match(rootReadme, /pnpm i18n:sync/);
1023
- assert.match(rootReadme, /pnpm i18n:add <locale>/);
1024
-
1025
- const i18nAddScriptPath = path.join(projectRoot, 'scripts', 'i18n-add.mjs');
1026
- assert.equal(fs.existsSync(i18nAddScriptPath), true);
1027
- const syncScriptPath = path.join(projectRoot, 'scripts', 'forgeon-sync-integrations.mjs');
1028
- assert.equal(fs.existsSync(syncScriptPath), true);
1029
-
1030
- const caddyDockerfile = fs.readFileSync(
1031
- path.join(projectRoot, 'infra', 'docker', 'caddy.Dockerfile'),
1032
- 'utf8',
1033
- );
1034
- assert.match(caddyDockerfile, /COPY tsconfig\.base\.json \.\//);
1035
- assert.match(caddyDockerfile, /COPY tsconfig\.base\.node\.json \.\//);
1036
- assert.match(caddyDockerfile, /COPY tsconfig\.base\.esm\.json \.\//);
1037
- assert.match(
1038
- caddyDockerfile,
1039
- /COPY packages\/i18n-contracts\/package\.json packages\/i18n-contracts\/package\.json/,
1040
- );
1041
- assert.match(
1042
- caddyDockerfile,
1043
- /COPY packages\/i18n-web\/package\.json packages\/i18n-web\/package\.json/,
1044
- );
1045
- assert.match(caddyDockerfile, /COPY resources resources/);
1046
-
1047
- const apiDockerfile = fs.readFileSync(
1048
- path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
1049
- 'utf8',
1050
- );
1051
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/core build/);
1052
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/db-prisma build/);
1053
- assert.match(apiDockerfile, /COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json/);
1054
-
1055
- const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1056
- assert.match(moduleDoc, /I18n/);
1057
- assert.match(moduleDoc, /installs independently/i);
1058
- assert.match(moduleDoc, /helper commands are part of the module surface/i);
1059
- } finally {
1060
- fs.rmSync(targetRoot, { recursive: true, force: true });
1061
- }
1062
- });
1063
-
1064
- it('applies logger module on top of scaffold without i18n', () => {
1065
- const targetRoot = mkTmp('forgeon-module-logger-');
1066
- const projectRoot = path.join(targetRoot, 'demo-logger');
1067
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1068
-
1069
- try {
1070
- scaffoldProject({
1071
- templateRoot,
1072
- packageRoot,
1073
- targetRoot: projectRoot,
1074
- projectName: 'demo-logger',
1075
- frontend: 'react',
1076
- db: 'prisma',
1077
- dbPrismaEnabled: true,
1078
- i18nEnabled: false,
1079
- proxy: 'caddy',
1080
- });
1081
-
1082
- const result = addModule({
1083
- moduleId: 'logger',
1084
- targetRoot: projectRoot,
1085
- packageRoot,
1086
- });
1087
-
1088
- assert.equal(result.applied, true);
1089
- assert.match(result.message, /applied/);
1090
- assert.equal(
1091
- fs.existsSync(path.join(projectRoot, 'packages', 'logger', 'package.json')),
1092
- true,
1093
- );
1094
-
1095
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1096
- assert.match(apiPackage, /@forgeon\/logger/);
1097
- assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
1098
-
1099
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1100
- assert.match(appModule, /@forgeon\/logger/);
1101
- assert.match(appModule, /loggerConfig/);
1102
- assert.match(appModule, /loggerEnvSchema/);
1103
- assert.match(appModule, /ForgeonLoggerModule/);
1104
-
1105
- const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1106
- assert.match(mainTs, /ForgeonLoggerService/);
1107
- assert.match(mainTs, /bufferLogs: true/);
1108
- assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
1109
- assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
1110
-
1111
- const apiDockerfile = fs.readFileSync(
1112
- path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
1113
- 'utf8',
1114
- );
1115
- assert.match(apiDockerfile, /COPY packages\/logger\/package\.json packages\/logger\/package\.json/);
1116
- assert.match(apiDockerfile, /COPY packages\/logger packages\/logger/);
1117
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/logger build/);
1118
-
1119
- const loggerTsconfig = fs.readFileSync(
1120
- path.join(projectRoot, 'packages', 'logger', 'tsconfig.json'),
1121
- 'utf8',
1122
- );
1123
- assert.match(loggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
1124
-
1125
- const loggerModule = fs.readFileSync(
1126
- path.join(projectRoot, 'packages', 'logger', 'src', 'forgeon-logger.module.ts'),
1127
- 'utf8',
1128
- );
1129
- assert.match(loggerModule, /ForgeonHttpLoggingMiddleware/);
1130
- assert.match(loggerModule, /consumer\.apply\(RequestIdMiddleware, ForgeonHttpLoggingMiddleware\)\.forRoutes\('\*'\);/);
1131
-
1132
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
1133
- assert.match(apiEnv, /LOGGER_LEVEL=log/);
1134
- assert.match(apiEnv, /LOGGER_HTTP_ENABLED=true/);
1135
- assert.match(apiEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
1136
-
1137
- const healthController = fs.readFileSync(
1138
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1139
- 'utf8',
1140
- );
1141
- assert.doesNotMatch(healthController, /logger/i);
1142
-
1143
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
1144
- assert.doesNotMatch(appTsx, /Check logger/i);
1145
-
1146
- const dockerEnv = fs.readFileSync(
1147
- path.join(projectRoot, 'infra', 'docker', '.env.example'),
1148
- 'utf8',
1149
- );
1150
- assert.match(dockerEnv, /LOGGER_LEVEL=log/);
1151
- assert.match(dockerEnv, /LOGGER_HTTP_ENABLED=true/);
1152
- assert.match(dockerEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
1153
-
1154
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
1155
- assert.match(compose, /LOGGER_LEVEL: \$\{LOGGER_LEVEL\}/);
1156
- assert.match(compose, /LOGGER_HTTP_ENABLED: \$\{LOGGER_HTTP_ENABLED\}/);
1157
- assert.match(compose, /LOGGER_REQUEST_ID_HEADER: \$\{LOGGER_REQUEST_ID_HEADER\}/);
1158
-
1159
- const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1160
- assert.match(rootReadme, /## Logger Module/);
1161
- assert.match(rootReadme, /installs independently/i);
1162
- assert.match(rootReadme, /does not add a dedicated API\/web probe/i);
1163
- assert.match(rootReadme, /LOGGER_LEVEL=log/);
1164
- assert.match(rootReadme, /stdout\/stderr/i);
1165
- assert.match(rootReadme, /docker compose logs api/);
1166
-
1167
- const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1168
- assert.match(moduleDoc, /Logger/);
1169
- assert.match(moduleDoc, /Status: implemented/);
1170
- assert.match(moduleDoc, /no dedicated probe is added by design/i);
1171
- } finally {
1172
- fs.rmSync(targetRoot, { recursive: true, force: true });
1173
- }
1174
- });
1175
-
1176
- it('applies rate-limit module on top of scaffold without i18n', () => {
1177
- const targetRoot = mkTmp('forgeon-module-rate-limit-');
1178
- const projectRoot = path.join(targetRoot, 'demo-rate-limit');
1179
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1180
-
1181
- try {
1182
- scaffoldProject({
1183
- templateRoot,
1184
- packageRoot,
1185
- targetRoot: projectRoot,
1186
- projectName: 'demo-rate-limit',
1187
- frontend: 'react',
1188
- db: 'prisma',
1189
- dbPrismaEnabled: false,
1190
- i18nEnabled: false,
1191
- proxy: 'caddy',
1192
- });
1193
-
1194
- const result = addModule({
1195
- moduleId: 'rate-limit',
1196
- targetRoot: projectRoot,
1197
- packageRoot,
1198
- });
1199
-
1200
- assert.equal(result.applied, true);
1201
- assertRateLimitWiring(projectRoot);
1202
-
1203
- const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1204
- assert.match(moduleDoc, /## Idea \/ Why/);
1205
- assert.match(moduleDoc, /## Configuration/);
1206
- assert.match(moduleDoc, /installs independently/i);
1207
- assert.match(moduleDoc, /No follow-up integration sync is required/i);
1208
- } finally {
1209
- fs.rmSync(targetRoot, { recursive: true, force: true });
1210
- }
1211
- });
1212
-
1213
- it('applies rbac module on top of scaffold without i18n', () => {
1214
- const targetRoot = mkTmp('forgeon-module-rbac-');
1215
- const projectRoot = path.join(targetRoot, 'demo-rbac');
1216
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1217
-
1218
- try {
1219
- scaffoldProject({
1220
- templateRoot,
1221
- packageRoot,
1222
- targetRoot: projectRoot,
1223
- projectName: 'demo-rbac',
1224
- frontend: 'react',
1225
- db: 'prisma',
1226
- dbPrismaEnabled: false,
1227
- i18nEnabled: false,
1228
- proxy: 'caddy',
1229
- });
1230
-
1231
- const result = addModule({
1232
- moduleId: 'rbac',
1233
- targetRoot: projectRoot,
1234
- packageRoot,
1235
- });
1236
-
1237
- assert.equal(result.applied, true);
1238
- assertRbacWiring(projectRoot);
1239
-
1240
- const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1241
- assert.match(moduleDoc, /## Idea \/ Why/);
1242
- assert.match(moduleDoc, /## How It Works/);
1243
- } finally {
1244
- fs.rmSync(targetRoot, { recursive: true, force: true });
1245
- }
1246
- });
1247
-
1248
- it('applies files-local then files foundation modules without breaking api wiring', () => {
1249
- const targetRoot = mkTmp('forgeon-module-files-local-');
1250
- const projectRoot = path.join(targetRoot, 'demo-files-local');
1251
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1252
-
1253
- try {
1254
- scaffoldProject({
1255
- templateRoot,
1256
- packageRoot,
1257
- targetRoot: projectRoot,
1258
- projectName: 'demo-files-local',
1259
- frontend: 'react',
1260
- db: 'prisma',
1261
- dbPrismaEnabled: true,
1262
- i18nEnabled: false,
1263
- proxy: 'caddy',
1264
- });
1265
-
1266
- const localResult = addModule({
1267
- moduleId: 'files-local',
1268
- targetRoot: projectRoot,
1269
- packageRoot,
1270
- });
1271
- assert.equal(localResult.applied, true);
1272
- assertFilesLocalWiring(projectRoot);
1273
-
1274
- const filesResult = addModule({
1275
- moduleId: 'files',
1276
- targetRoot: projectRoot,
1277
- packageRoot,
1278
- });
1279
- assert.equal(filesResult.applied, true);
1280
- assertFilesWiring(projectRoot);
1281
-
1282
- const moduleDoc = fs.readFileSync(filesResult.docsPath, 'utf8');
1283
- assert.match(moduleDoc, /requires `db-adapter`/i);
1284
- assert.match(moduleDoc, /requires `files-storage-adapter`/i);
1285
- } finally {
1286
- fs.rmSync(targetRoot, { recursive: true, force: true });
1287
- }
1288
- });
1289
-
1290
- it('applies files-s3 foundation module with env and docker wiring', () => {
1291
- const targetRoot = mkTmp('forgeon-module-files-s3-');
1292
- const projectRoot = path.join(targetRoot, 'demo-files-s3');
1293
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1294
-
1295
- try {
1296
- scaffoldProject({
1297
- templateRoot,
1298
- packageRoot,
1299
- targetRoot: projectRoot,
1300
- projectName: 'demo-files-s3',
1301
- frontend: 'react',
1302
- db: 'prisma',
1303
- dbPrismaEnabled: true,
1304
- i18nEnabled: false,
1305
- proxy: 'caddy',
1306
- });
1307
-
1308
- const result = addModule({
1309
- moduleId: 'files-s3',
1310
- targetRoot: projectRoot,
1311
- packageRoot,
1312
- });
1313
- assert.equal(result.applied, true);
1314
- assertFilesS3Wiring(projectRoot);
1315
- } finally {
1316
- fs.rmSync(targetRoot, { recursive: true, force: true });
1317
- }
1318
- });
1319
-
1320
- it('applies files-s3 then files and keeps s3 driver default without requiring files-local', () => {
1321
- const targetRoot = mkTmp('forgeon-module-files-s3-runtime-');
1322
- const projectRoot = path.join(targetRoot, 'demo-files-s3-runtime');
1323
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1324
-
1325
- try {
1326
- scaffoldProject({
1327
- templateRoot,
1328
- packageRoot,
1329
- targetRoot: projectRoot,
1330
- projectName: 'demo-files-s3-runtime',
1331
- frontend: 'react',
1332
- db: 'prisma',
1333
- dbPrismaEnabled: true,
1334
- i18nEnabled: false,
1335
- proxy: 'caddy',
1336
- });
1337
-
1338
- addModule({
1339
- moduleId: 'files-s3',
1340
- targetRoot: projectRoot,
1341
- packageRoot,
1342
- });
1343
- addModule({
1344
- moduleId: 'files',
1345
- targetRoot: projectRoot,
1346
- packageRoot,
1347
- });
1348
-
1349
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
1350
- assert.match(apiEnv, /FILES_STORAGE_DRIVER=s3/);
1351
-
1352
- const filesService = fs.readFileSync(
1353
- path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
1354
- 'utf8',
1355
- );
1356
- assert.match(filesService, /storeS3/);
1357
- assert.match(filesService, /openS3/);
1358
- assert.match(filesService, /deleteS3/);
1359
- assert.match(filesService, /@aws-sdk\/client-s3/);
1360
- } finally {
1361
- fs.rmSync(targetRoot, { recursive: true, force: true });
1362
- }
1363
- });
1364
-
1365
- it('applies files-access after files and wires file route checks and probe UI', () => {
1366
- const targetRoot = mkTmp('forgeon-module-files-access-');
1367
- const projectRoot = path.join(targetRoot, 'demo-files-access');
1368
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1369
-
1370
- try {
1371
- scaffoldProject({
1372
- templateRoot,
1373
- packageRoot,
1374
- targetRoot: projectRoot,
1375
- projectName: 'demo-files-access',
1376
- frontend: 'react',
1377
- db: 'prisma',
1378
- dbPrismaEnabled: true,
1379
- i18nEnabled: false,
1380
- proxy: 'caddy',
1381
- });
1382
-
1383
- addModule({
1384
- moduleId: 'files-local',
1385
- targetRoot: projectRoot,
1386
- packageRoot,
1387
- });
1388
- addModule({
1389
- moduleId: 'files',
1390
- targetRoot: projectRoot,
1391
- packageRoot,
1392
- });
1393
- const result = addModule({
1394
- moduleId: 'files-access',
1395
- targetRoot: projectRoot,
1396
- packageRoot,
1397
- });
1398
-
1399
- assert.equal(result.applied, true);
1400
- assertFilesAccessWiring(projectRoot);
1401
- } finally {
1402
- fs.rmSync(targetRoot, { recursive: true, force: true });
1403
- }
1404
- });
1405
-
1406
- it('applies files-quotas after files and wires upload quota checks and probe UI', () => {
1407
- const targetRoot = mkTmp('forgeon-module-files-quotas-');
1408
- const projectRoot = path.join(targetRoot, 'demo-files-quotas');
1409
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1410
-
1411
- try {
1412
- scaffoldProject({
1413
- templateRoot,
1414
- packageRoot,
1415
- targetRoot: projectRoot,
1416
- projectName: 'demo-files-quotas',
1417
- frontend: 'react',
1418
- db: 'prisma',
1419
- dbPrismaEnabled: true,
1420
- i18nEnabled: false,
1421
- proxy: 'caddy',
1422
- });
1423
-
1424
- addModule({
1425
- moduleId: 'files-local',
1426
- targetRoot: projectRoot,
1427
- packageRoot,
1428
- });
1429
- addModule({
1430
- moduleId: 'files',
1431
- targetRoot: projectRoot,
1432
- packageRoot,
1433
- });
1434
- const result = addModule({
1435
- moduleId: 'files-quotas',
1436
- targetRoot: projectRoot,
1437
- packageRoot,
1438
- });
1439
-
1440
- assert.equal(result.applied, true);
1441
- assertFilesQuotasWiring(projectRoot);
1442
- } finally {
1443
- fs.rmSync(targetRoot, { recursive: true, force: true });
1444
- }
1445
- });
1446
-
1447
- it('applies files-image after files and wires sanitize pipeline with default metadata stripping', () => {
1448
- const targetRoot = mkTmp('forgeon-module-files-image-');
1449
- const projectRoot = path.join(targetRoot, 'demo-files-image');
1450
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1451
-
1452
- try {
1453
- scaffoldProject({
1454
- templateRoot,
1455
- packageRoot,
1456
- targetRoot: projectRoot,
1457
- projectName: 'demo-files-image',
1458
- frontend: 'react',
1459
- db: 'prisma',
1460
- dbPrismaEnabled: true,
1461
- i18nEnabled: false,
1462
- proxy: 'caddy',
1463
- });
1464
-
1465
- addModule({
1466
- moduleId: 'files-local',
1467
- targetRoot: projectRoot,
1468
- packageRoot,
1469
- });
1470
- addModule({
1471
- moduleId: 'files',
1472
- targetRoot: projectRoot,
1473
- packageRoot,
1474
- });
1475
- const result = addModule({
1476
- moduleId: 'files-image',
1477
- targetRoot: projectRoot,
1478
- packageRoot,
1479
- });
1480
-
1481
- assert.equal(result.applied, true);
1482
- assertFilesImageWiring(projectRoot);
1483
- } finally {
1484
- fs.rmSync(targetRoot, { recursive: true, force: true });
1485
- }
1486
- });
1487
-
1488
- it('applies full files stack in mixed order and keeps runtime probes consistent', () => {
1489
- const targetRoot = mkTmp('forgeon-module-files-stack-smoke-');
1490
- const projectRoot = path.join(targetRoot, 'demo-files-stack-smoke');
1491
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1492
-
1493
- try {
1494
- scaffoldProject({
1495
- templateRoot,
1496
- packageRoot,
1497
- targetRoot: projectRoot,
1498
- projectName: 'demo-files-stack-smoke',
1499
- frontend: 'react',
1500
- db: 'prisma',
1501
- dbPrismaEnabled: true,
1502
- i18nEnabled: false,
1503
- proxy: 'caddy',
1504
- });
1505
-
1506
- addModule({
1507
- moduleId: 'files-s3',
1508
- targetRoot: projectRoot,
1509
- packageRoot,
1510
- });
1511
- addModule({
1512
- moduleId: 'files',
1513
- targetRoot: projectRoot,
1514
- packageRoot,
1515
- });
1516
- addModule({
1517
- moduleId: 'files-image',
1518
- targetRoot: projectRoot,
1519
- packageRoot,
1520
- });
1521
- addModule({
1522
- moduleId: 'files-access',
1523
- targetRoot: projectRoot,
1524
- packageRoot,
1525
- });
1526
- addModule({
1527
- moduleId: 'files-quotas',
1528
- targetRoot: projectRoot,
1529
- packageRoot,
1530
- });
1531
-
1532
- assertFilesS3Wiring(projectRoot);
1533
- assertFilesWiring(projectRoot, 's3');
1534
- assertFilesImageWiring(projectRoot);
1535
- assertFilesAccessWiring(projectRoot);
1536
- assertFilesQuotasWiring(projectRoot);
1537
-
1538
- const healthController = fs.readFileSync(
1539
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1540
- 'utf8',
1541
- );
1542
- assert.match(healthController, /@Post\('files'\)/);
1543
- assert.match(healthController, /@Get\('files-variants'\)/);
1544
- assert.match(healthController, /@Get\('files-image'\)/);
1545
- assert.match(healthController, /@Get\('files-access'\)/);
1546
- assert.match(healthController, /@Get\('files-quotas'\)/);
1547
-
1548
- const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
1549
- const filesChecks = appTsx.match(/Check files /g) ?? [];
1550
- assert.equal(filesChecks.length, 5);
1551
- } finally {
1552
- fs.rmSync(targetRoot, { recursive: true, force: true });
1553
- }
1554
- });
1555
-
1556
- it('applies swagger module on top of scaffold without i18n', () => {
1557
- const targetRoot = mkTmp('forgeon-module-swagger-');
1558
- const projectRoot = path.join(targetRoot, 'demo-swagger');
1559
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1560
-
1561
- try {
1562
- scaffoldProject({
1563
- templateRoot,
1564
- packageRoot,
1565
- targetRoot: projectRoot,
1566
- projectName: 'demo-swagger',
1567
- frontend: 'react',
1568
- db: 'prisma',
1569
- dbPrismaEnabled: true,
1570
- i18nEnabled: false,
1571
- proxy: 'caddy',
1572
- });
1573
-
1574
- const result = addModule({
1575
- moduleId: 'swagger',
1576
- targetRoot: projectRoot,
1577
- packageRoot,
1578
- });
1579
-
1580
- assert.equal(result.applied, true);
1581
- assert.match(result.message, /applied/);
1582
- assert.equal(
1583
- fs.existsSync(path.join(projectRoot, 'packages', 'swagger', 'package.json')),
1584
- true,
1585
- );
1586
-
1587
- const swaggerTsconfig = fs.readFileSync(
1588
- path.join(projectRoot, 'packages', 'swagger', 'tsconfig.json'),
1589
- 'utf8',
1590
- );
1591
- assert.match(swaggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
1592
-
1593
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1594
- assert.match(apiPackage, /@forgeon\/swagger/);
1595
- assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
1596
-
1597
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1598
- assert.match(appModule, /@forgeon\/swagger/);
1599
- assert.match(appModule, /swaggerConfig/);
1600
- assert.match(appModule, /swaggerEnvSchema/);
1601
- assert.match(appModule, /ForgeonSwaggerModule/);
1602
-
1603
- const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1604
- assert.match(mainTs, /setupSwagger/);
1605
- assert.match(mainTs, /SwaggerConfigService/);
1606
- assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
1607
-
1608
- const apiDockerfile = fs.readFileSync(
1609
- path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
1610
- 'utf8',
1611
- );
1612
- assert.match(apiDockerfile, /COPY packages\/swagger\/package\.json packages\/swagger\/package\.json/);
1613
- assert.match(apiDockerfile, /COPY packages\/swagger packages\/swagger/);
1614
- assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/swagger build/);
1615
-
1616
- const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
1617
- assert.match(apiEnv, /SWAGGER_ENABLED=false/);
1618
- assert.match(apiEnv, /SWAGGER_PATH=docs/);
1619
- assert.match(apiEnv, /SWAGGER_TITLE="Forgeon API"/);
1620
- assert.match(apiEnv, /SWAGGER_VERSION=1\.0\.0/);
1621
-
1622
- const dockerEnv = fs.readFileSync(
1623
- path.join(projectRoot, 'infra', 'docker', '.env.example'),
1624
- 'utf8',
1625
- );
1626
- assert.match(dockerEnv, /SWAGGER_ENABLED=false/);
1627
- assert.match(dockerEnv, /SWAGGER_PATH=docs/);
1628
- assert.match(dockerEnv, /SWAGGER_TITLE="Forgeon API"/);
1629
- assert.match(dockerEnv, /SWAGGER_VERSION=1\.0\.0/);
1630
-
1631
- const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
1632
- assert.match(compose, /SWAGGER_ENABLED: \$\{SWAGGER_ENABLED\}/);
1633
- assert.match(compose, /SWAGGER_PATH: \$\{SWAGGER_PATH\}/);
1634
- assert.match(compose, /SWAGGER_TITLE: \$\{SWAGGER_TITLE\}/);
1635
- assert.match(compose, /SWAGGER_VERSION: \$\{SWAGGER_VERSION\}/);
1636
-
1637
- const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1638
- assert.match(rootReadme, /## Swagger \/ OpenAPI Module/);
1639
- assert.match(rootReadme, /installs independently/i);
1640
- assert.match(rootReadme, /decorators.*manual/i);
1641
- assert.match(rootReadme, /SWAGGER_ENABLED=false/);
1642
- assert.match(rootReadme, /localhost:3000\/api\/docs/);
1643
-
1644
- const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1645
- assert.match(moduleDoc, /Swagger \/ OpenAPI/);
1646
- assert.match(moduleDoc, /Status: implemented/);
1647
- assert.match(moduleDoc, /feature-specific Swagger decorators remain manual/i);
1648
- } finally {
1649
- fs.rmSync(targetRoot, { recursive: true, force: true });
1650
- }
1651
- });
1652
-
1653
- it('applies swagger module on top of scaffold with i18n', () => {
1654
- const targetRoot = mkTmp('forgeon-module-swagger-i18n-');
1655
- const projectRoot = path.join(targetRoot, 'demo-swagger-i18n');
1656
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1657
-
1658
- try {
1659
- scaffoldProject({
1660
- templateRoot,
1661
- packageRoot,
1662
- targetRoot: projectRoot,
1663
- projectName: 'demo-swagger-i18n',
1664
- frontend: 'react',
1665
- db: 'prisma',
1666
- dbPrismaEnabled: true,
1667
- i18nEnabled: true,
1668
- proxy: 'caddy',
1669
- });
1670
-
1671
- const result = addModule({
1672
- moduleId: 'swagger',
1673
- targetRoot: projectRoot,
1674
- packageRoot,
1675
- });
1676
-
1677
- assert.equal(result.applied, true);
1678
-
1679
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1680
- const loadMatch = appModule.match(/load: \[([^\]]+)\]/);
1681
- assert.ok(loadMatch);
1682
- assert.match(loadMatch[1], /coreConfig/);
1683
- assert.match(loadMatch[1], /dbPrismaConfig/);
1684
- assert.match(loadMatch[1], /i18nConfig/);
1685
- assert.match(loadMatch[1], /swaggerConfig/);
1686
-
1687
- const validateMatch = appModule.match(/validate: createEnvValidator\(\[([^\]]+)\]\)/);
1688
- assert.ok(validateMatch);
1689
- assert.match(validateMatch[1], /coreEnvSchema/);
1690
- assert.match(validateMatch[1], /dbPrismaEnvSchema/);
1691
- assert.match(validateMatch[1], /i18nEnvSchema/);
1692
- assert.match(validateMatch[1], /swaggerEnvSchema/);
1693
- assert.match(appModule, /ForgeonSwaggerModule/);
1694
- assert.match(appModule, /ForgeonI18nModule/);
1695
- } finally {
1696
- fs.rmSync(targetRoot, { recursive: true, force: true });
1697
- }
1698
- });
1699
-
1700
- it('applies logger after swagger without losing logger config keys', () => {
1701
- const targetRoot = mkTmp('forgeon-module-swagger-logger-');
1702
- const projectRoot = path.join(targetRoot, 'demo-swagger-logger');
1703
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1704
-
1705
- try {
1706
- scaffoldProject({
1707
- templateRoot,
1708
- packageRoot,
1709
- targetRoot: projectRoot,
1710
- projectName: 'demo-swagger-logger',
1711
- frontend: 'react',
1712
- db: 'prisma',
1713
- dbPrismaEnabled: true,
1714
- i18nEnabled: true,
1715
- proxy: 'caddy',
1716
- });
1717
-
1718
- const swaggerResult = addModule({
1719
- moduleId: 'swagger',
1720
- targetRoot: projectRoot,
1721
- packageRoot,
1722
- });
1723
- assert.equal(swaggerResult.applied, true);
1724
-
1725
- const loggerResult = addModule({
1726
- moduleId: 'logger',
1727
- targetRoot: projectRoot,
1728
- packageRoot,
1729
- });
1730
- assert.equal(loggerResult.applied, true);
1731
-
1732
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1733
- const loadMatch = appModule.match(/load: \[([^\]]+)\]/);
1734
- assert.ok(loadMatch);
1735
- assert.match(loadMatch[1], /coreConfig/);
1736
- assert.match(loadMatch[1], /dbPrismaConfig/);
1737
- assert.match(loadMatch[1], /i18nConfig/);
1738
- assert.match(loadMatch[1], /swaggerConfig/);
1739
- assert.match(loadMatch[1], /loggerConfig/);
1740
-
1741
- const validateMatch = appModule.match(/validate: createEnvValidator\(\[([^\]]+)\]\)/);
1742
- assert.ok(validateMatch);
1743
- assert.match(validateMatch[1], /coreEnvSchema/);
1744
- assert.match(validateMatch[1], /dbPrismaEnvSchema/);
1745
- assert.match(validateMatch[1], /i18nEnvSchema/);
1746
- assert.match(validateMatch[1], /swaggerEnvSchema/);
1747
- assert.match(validateMatch[1], /loggerEnvSchema/);
1748
- assert.match(appModule, /ForgeonSwaggerModule/);
1749
- assert.match(appModule, /ForgeonLoggerModule/);
1750
- } finally {
1751
- fs.rmSync(targetRoot, { recursive: true, force: true });
1752
- }
1753
- });
1754
-
1755
- it('applies i18n after logger without losing logger config keys', () => {
1756
- const targetRoot = mkTmp('forgeon-module-logger-i18n-');
1757
- const projectRoot = path.join(targetRoot, 'demo-logger-i18n');
1758
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1759
-
1760
- try {
1761
- scaffoldProject({
1762
- templateRoot,
1763
- packageRoot,
1764
- targetRoot: projectRoot,
1765
- projectName: 'demo-logger-i18n',
1766
- frontend: 'react',
1767
- db: 'prisma',
1768
- dbPrismaEnabled: true,
1769
- i18nEnabled: false,
1770
- proxy: 'caddy',
1771
- });
1772
-
1773
- addModule({
1774
- moduleId: 'logger',
1775
- targetRoot: projectRoot,
1776
- packageRoot,
1777
- });
1778
-
1779
- const i18nResult = addModule({
1780
- moduleId: 'i18n',
1781
- targetRoot: projectRoot,
1782
- packageRoot,
1783
- });
1784
- assert.equal(i18nResult.applied, true);
1785
-
1786
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1787
- assert.match(
1788
- appModule,
1789
- /load: \[coreConfig,\s*dbPrismaConfig,\s*loggerConfig,\s*i18nConfig\]/,
1790
- );
1791
- assert.match(
1792
- appModule,
1793
- /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*loggerEnvSchema,\s*i18nEnvSchema\]\)/,
1794
- );
1795
- assert.match(appModule, /ForgeonLoggerModule/);
1796
- assert.match(appModule, /ForgeonI18nModule/);
1797
-
1798
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1799
- assert.match(apiPackage, /@forgeon\/logger/);
1800
- assert.match(apiPackage, /@forgeon\/i18n/);
1801
- assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
1802
- assert.match(apiPackage, /pnpm --filter @forgeon\/i18n build/);
1803
-
1804
- const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1805
- assert.match(mainTs, /ForgeonLoggerService/);
1806
- assert.doesNotMatch(mainTs, /ForgeonHttpLoggingInterceptor/);
1807
- } finally {
1808
- fs.rmSync(targetRoot, { recursive: true, force: true });
1809
- }
1810
- });
1811
-
1812
- it('applies i18n after swagger without losing swagger config keys', () => {
1813
- const targetRoot = mkTmp('forgeon-module-swagger-i18n-order-');
1814
- const projectRoot = path.join(targetRoot, 'demo-swagger-i18n-order');
1815
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1816
-
1817
- try {
1818
- scaffoldProject({
1819
- templateRoot,
1820
- packageRoot,
1821
- targetRoot: projectRoot,
1822
- projectName: 'demo-swagger-i18n-order',
1823
- frontend: 'react',
1824
- db: 'prisma',
1825
- dbPrismaEnabled: true,
1826
- i18nEnabled: false,
1827
- proxy: 'caddy',
1828
- });
1829
-
1830
- addModule({
1831
- moduleId: 'swagger',
1832
- targetRoot: projectRoot,
1833
- packageRoot,
1834
- });
1835
-
1836
- const i18nResult = addModule({
1837
- moduleId: 'i18n',
1838
- targetRoot: projectRoot,
1839
- packageRoot,
1840
- });
1841
- assert.equal(i18nResult.applied, true);
1842
-
1843
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1844
- assert.match(
1845
- appModule,
1846
- /load: \[coreConfig,\s*dbPrismaConfig,\s*swaggerConfig,\s*i18nConfig\]/,
1847
- );
1848
- assert.match(
1849
- appModule,
1850
- /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*swaggerEnvSchema,\s*i18nEnvSchema\]\)/,
1851
- );
1852
- assert.match(appModule, /ForgeonSwaggerModule/);
1853
- assert.match(appModule, /ForgeonI18nModule/);
1854
-
1855
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1856
- assert.match(apiPackage, /@forgeon\/swagger/);
1857
- assert.match(apiPackage, /@forgeon\/i18n/);
1858
- assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
1859
- assert.match(apiPackage, /pnpm --filter @forgeon\/i18n build/);
1860
-
1861
- const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1862
- assert.match(mainTs, /setupSwagger/);
1863
- assert.match(mainTs, /SwaggerConfigService/);
1864
- } finally {
1865
- fs.rmSync(targetRoot, { recursive: true, force: true });
1866
- }
1867
- });
1868
-
1869
- it('applies swagger -> logger -> i18n and keeps all module wiring', () => {
1870
- const targetRoot = mkTmp('forgeon-module-mixed-order-');
1871
- const projectRoot = path.join(targetRoot, 'demo-mixed-order');
1872
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1873
-
1874
- try {
1875
- scaffoldProject({
1876
- templateRoot,
1877
- packageRoot,
1878
- targetRoot: projectRoot,
1879
- projectName: 'demo-mixed-order',
1880
- frontend: 'react',
1881
- db: 'prisma',
1882
- dbPrismaEnabled: true,
1883
- i18nEnabled: false,
1884
- proxy: 'caddy',
1885
- });
1886
-
1887
- addModule({ moduleId: 'swagger', targetRoot: projectRoot, packageRoot });
1888
- addModule({ moduleId: 'logger', targetRoot: projectRoot, packageRoot });
1889
- addModule({ moduleId: 'i18n', targetRoot: projectRoot, packageRoot });
1890
-
1891
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1892
- assert.match(
1893
- appModule,
1894
- /load: \[coreConfig,\s*dbPrismaConfig,\s*swaggerConfig,\s*loggerConfig,\s*i18nConfig\]/,
1895
- );
1896
- assert.match(
1897
- appModule,
1898
- /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*swaggerEnvSchema,\s*loggerEnvSchema,\s*i18nEnvSchema\]\)/,
1899
- );
1900
- assert.match(appModule, /ForgeonSwaggerModule/);
1901
- assert.match(appModule, /ForgeonLoggerModule/);
1902
- assert.match(appModule, /ForgeonI18nModule/);
1903
-
1904
- const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1905
- assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
1906
- assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
1907
- assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
1908
-
1909
- const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1910
- assert.match(apiPackage, /@forgeon\/swagger/);
1911
- assert.match(apiPackage, /@forgeon\/logger/);
1912
- assert.match(apiPackage, /@forgeon\/i18n/);
1913
- assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
1914
- assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
1915
- assert.match(apiPackage, /pnpm --filter @forgeon\/i18n build/);
1916
-
1917
- assertDbPrismaWiring(projectRoot);
1918
- } finally {
1919
- fs.rmSync(targetRoot, { recursive: true, force: true });
1920
- }
1921
- });
1922
-
1923
- it('applies jwt-auth with db-prisma as stateless first, then wires persistence via explicit sync', () => {
1924
- const targetRoot = mkTmp('forgeon-module-jwt-db-');
1925
- const projectRoot = path.join(targetRoot, 'demo-jwt-db');
1926
- const templateRoot = path.join(packageRoot, 'templates', 'base');
1927
-
1928
- try {
1929
- scaffoldProject({
1930
- templateRoot,
1931
- packageRoot,
1932
- targetRoot: projectRoot,
1933
- projectName: 'demo-jwt-db',
1934
- frontend: 'react',
1935
- db: 'prisma',
1936
- dbPrismaEnabled: true,
1937
- i18nEnabled: true,
1938
- proxy: 'caddy',
1939
- });
1940
-
1941
- const result = addModule({
1942
- moduleId: 'jwt-auth',
1943
- targetRoot: projectRoot,
1944
- packageRoot,
1945
- });
1946
-
1947
- assert.equal(result.applied, true);
1948
- assertJwtAuthWiring(projectRoot, false);
1949
-
1950
- const syncResult = syncIntegrations({ targetRoot: projectRoot, packageRoot });
1951
- const dbPair = syncResult.summary.find((item) => item.id === 'auth-persistence');
1952
- assert.ok(dbPair);
1953
- assert.equal(dbPair.result.applied, true);
1954
- assert.equal(syncResult.changedFiles.length > 0, true);
1955
-
1956
- assertJwtAuthWiring(projectRoot, true);
1957
-
1958
- const storeFile = path.join(
1959
- projectRoot,
1960
- 'apps',
1961
- 'api',
1962
- 'src',
1963
- 'auth',
1964
- 'prisma-auth-refresh-token.store.ts',
1965
- );
1966
- assert.equal(fs.existsSync(storeFile), true);
1967
-
1968
- const schema = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'prisma', 'schema.prisma'), 'utf8');
1969
- assert.match(schema, /refreshTokenHash/);
1970
-
1971
- const migrationPath = path.join(
1972
- projectRoot,
1973
- 'apps',
1974
- 'api',
1975
- 'prisma',
1976
- 'migrations',
1977
- '0002_auth_refresh_token_hash',
1978
- 'migration.sql',
1979
- );
1980
- assert.equal(fs.existsSync(migrationPath), true);
1981
-
1982
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1983
- assert.match(readme, /refresh token persistence: enabled/);
1984
- assert.match(readme, /db-adapter/);
1985
- assert.match(readme, /current provider: `db-prisma`/);
1986
- assert.match(readme, /0002_auth_refresh_token_hash/);
1987
-
1988
- const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1989
- assert.match(moduleDoc, /Status: implemented/);
1990
- assert.match(moduleDoc, /db-adapter/);
1991
- } finally {
1992
- fs.rmSync(targetRoot, { recursive: true, force: true });
1993
- }
1994
- });
1995
-
1996
- it('applies jwt-auth without db and keeps stateless fallback until pair sync is available', () => {
1997
- const targetRoot = mkTmp('forgeon-module-jwt-nodb-');
1998
- const projectRoot = path.join(targetRoot, 'demo-jwt-nodb');
1999
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2000
-
2001
- try {
2002
- scaffoldProject({
2003
- templateRoot,
2004
- packageRoot,
2005
- targetRoot: projectRoot,
2006
- projectName: 'demo-jwt-nodb',
2007
- frontend: 'react',
2008
- db: 'prisma',
2009
- dbPrismaEnabled: true,
2010
- i18nEnabled: false,
2011
- proxy: 'caddy',
2012
- });
2013
-
2014
- stripDbPrismaArtifacts(projectRoot);
2015
-
2016
- const result = addModule({
2017
- moduleId: 'jwt-auth',
2018
- targetRoot: projectRoot,
2019
- packageRoot,
2020
- });
2021
-
2022
- assert.equal(result.applied, true);
2023
- assertJwtAuthWiring(projectRoot, false);
2024
- assert.equal(
2025
- fs.existsSync(path.join(projectRoot, 'apps', 'api', 'src', 'auth', 'prisma-auth-refresh-token.store.ts')),
2026
- false,
2027
- );
2028
-
2029
- const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
2030
- assert.match(readme, /refresh token persistence: disabled/);
2031
- assert.match(readme, /db-adapter/);
2032
- assert.match(readme, /create-forgeon add db-prisma/);
2033
-
2034
- } finally {
2035
- fs.rmSync(targetRoot, { recursive: true, force: true });
2036
- }
2037
- });
2038
-
2039
- it('detects and applies jwt-auth + rbac claims integration explicitly', () => {
2040
- const targetRoot = mkTmp('forgeon-module-jwt-rbac-');
2041
- const projectRoot = path.join(targetRoot, 'demo-jwt-rbac');
2042
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2043
-
2044
- try {
2045
- scaffoldProject({
2046
- templateRoot,
2047
- packageRoot,
2048
- targetRoot: projectRoot,
2049
- projectName: 'demo-jwt-rbac',
2050
- frontend: 'react',
2051
- db: 'prisma',
2052
- dbPrismaEnabled: false,
2053
- i18nEnabled: false,
2054
- proxy: 'caddy',
2055
- });
2056
-
2057
- addModule({
2058
- moduleId: 'rbac',
2059
- targetRoot: projectRoot,
2060
- packageRoot,
2061
- });
2062
- addModule({
2063
- moduleId: 'jwt-auth',
2064
- targetRoot: projectRoot,
2065
- packageRoot,
2066
- });
2067
-
2068
- const scan = scanIntegrations({
2069
- targetRoot: projectRoot,
2070
- relatedModuleId: 'jwt-auth',
2071
- });
2072
- assert.equal(scan.groups.some((group) => group.id === 'auth-rbac-claims'), true);
2073
-
2074
- const syncResult = syncIntegrations({
2075
- targetRoot: projectRoot,
2076
- packageRoot,
2077
- groupIds: ['auth-rbac-claims'],
2078
- });
2079
- const claimsPair = syncResult.summary.find((item) => item.id === 'auth-rbac-claims');
2080
- assert.ok(claimsPair);
2081
- assert.equal(claimsPair.result.applied, true);
2082
-
2083
- const authContracts = fs.readFileSync(
2084
- path.join(projectRoot, 'packages', 'auth-contracts', 'src', 'index.ts'),
2085
- 'utf8',
2086
- );
2087
- assert.match(authContracts, /permissions\?: string\[\];/);
2088
-
2089
- const authService = fs.readFileSync(
2090
- path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
2091
- 'utf8',
2092
- );
2093
- assert.match(authService, /permissions: \['health\.rbac'\]/);
2094
- assert.match(authService, /permissions: user\.permissions,/);
2095
- assert.match(
2096
- authService,
2097
- /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
2098
- );
2099
-
2100
- const authController = fs.readFileSync(
2101
- path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.controller.ts'),
2102
- 'utf8',
2103
- );
2104
- assert.match(
2105
- authController,
2106
- /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
2107
- );
2108
- } finally {
2109
- fs.rmSync(targetRoot, { recursive: true, force: true });
2110
- }
2111
- });
2112
-
2113
- it('scans auth persistence as db-adapter participant while remaining triggerable from db-prisma install order', () => {
2114
- const targetRoot = mkTmp('forgeon-module-jwt-db-scan-');
2115
- const projectRoot = path.join(targetRoot, 'demo-jwt-db-scan');
2116
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2117
-
2118
- try {
2119
- scaffoldProject({
2120
- templateRoot,
2121
- packageRoot,
2122
- targetRoot: projectRoot,
2123
- projectName: 'demo-jwt-db-scan',
2124
- frontend: 'react',
2125
- db: 'prisma',
2126
- dbPrismaEnabled: false,
2127
- i18nEnabled: false,
2128
- proxy: 'caddy',
2129
- });
2130
-
2131
- addModule({
2132
- moduleId: 'jwt-auth',
2133
- targetRoot: projectRoot,
2134
- packageRoot,
2135
- });
2136
- addModule({
2137
- moduleId: 'db-prisma',
2138
- targetRoot: projectRoot,
2139
- packageRoot,
2140
- });
2141
-
2142
- const scan = scanIntegrations({
2143
- targetRoot: projectRoot,
2144
- relatedModuleId: 'db-prisma',
2145
- });
2146
- const persistenceGroup = scan.groups.find((group) => group.id === 'auth-persistence');
2147
-
2148
- assert.ok(persistenceGroup);
2149
- assert.deepEqual(persistenceGroup.modules, ['jwt-auth', 'db-adapter']);
2150
- } finally {
2151
- fs.rmSync(targetRoot, { recursive: true, force: true });
2152
- }
2153
- });
2154
-
2155
- it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
2156
- const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
2157
- const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
2158
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2159
-
2160
- try {
2161
- scaffoldProject({
2162
- templateRoot,
2163
- packageRoot,
2164
- targetRoot: projectRoot,
2165
- projectName: 'demo-jwt-nodb-noi18n',
2166
- frontend: 'react',
2167
- db: 'prisma',
2168
- dbPrismaEnabled: false,
2169
- i18nEnabled: false,
2170
- proxy: 'caddy',
2171
- });
2172
-
2173
- addModule({
2174
- moduleId: 'logger',
2175
- targetRoot: projectRoot,
2176
- packageRoot,
2177
- });
2178
- addModule({
2179
- moduleId: 'jwt-auth',
2180
- targetRoot: projectRoot,
2181
- packageRoot,
2182
- });
2183
-
2184
- const healthController = fs.readFileSync(
2185
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2186
- 'utf8',
2187
- );
2188
- assert.match(healthController, /constructor\(private readonly authService: AuthService\)/);
2189
- assert.match(healthController, /@Get\('auth'\)/);
2190
- assert.match(healthController, /return this\.authService\.getProbeStatus\(\);/);
2191
-
2192
- const classStart = healthController.indexOf('export class HealthController {');
2193
- const classEnd = healthController.lastIndexOf('\n}');
2194
- const authProbe = healthController.indexOf("@Get('auth')");
2195
- assert.equal(classStart > -1, true);
2196
- assert.equal(classEnd > classStart, true);
2197
- assert.equal(authProbe > classStart && authProbe < classEnd, true);
2198
- } finally {
2199
- fs.rmSync(targetRoot, { recursive: true, force: true });
2200
- }
2201
- });
2202
-
2203
- it('keeps health controller valid for add sequence jwt-auth -> logger -> swagger -> i18n -> db-prisma on db/i18n-disabled scaffold', () => {
2204
- const targetRoot = mkTmp('forgeon-module-seq-health-valid-');
2205
- const projectRoot = path.join(targetRoot, 'demo-seq-health-valid');
2206
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2207
-
2208
- try {
2209
- scaffoldProject({
2210
- templateRoot,
2211
- packageRoot,
2212
- targetRoot: projectRoot,
2213
- projectName: 'demo-seq-health-valid',
2214
- frontend: 'react',
2215
- db: 'prisma',
2216
- dbPrismaEnabled: false,
2217
- i18nEnabled: false,
2218
- proxy: 'caddy',
2219
- });
2220
-
2221
- for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'i18n', 'db-prisma']) {
2222
- addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2223
- }
2224
-
2225
- const healthController = fs.readFileSync(
2226
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2227
- 'utf8',
2228
- );
2229
-
2230
- const classStart = healthController.indexOf('export class HealthController {');
2231
- const classEnd = healthController.lastIndexOf('\n}');
2232
- assert.equal(classStart > -1, true);
2233
- assert.equal(classEnd > classStart, true);
2234
-
2235
- const imports = [...healthController.matchAll(/^import\s.+;$/gm)];
2236
- assert.equal(imports.length > 0, true);
2237
- for (const importLine of imports) {
2238
- assert.equal(importLine.index < classStart, true);
2239
- }
2240
-
2241
- const authProbe = healthController.indexOf("@Get('auth')");
2242
- const dbProbe = healthController.indexOf("@Post('db')");
2243
- const translateMethod = healthController.indexOf('private translate(');
2244
- assert.equal(authProbe > classStart && authProbe < classEnd, true);
2245
- assert.equal(dbProbe > classStart && dbProbe < classEnd, true);
2246
- assert.equal(translateMethod > classStart && translateMethod < classEnd, true);
2247
-
2248
- assert.match(healthController, /private readonly authService: AuthService/);
2249
- assert.match(healthController, /private readonly i18n: I18nService/);
2250
- assert.match(healthController, /private readonly prisma: PrismaService/);
2251
- } finally {
2252
- fs.rmSync(targetRoot, { recursive: true, force: true });
2253
- }
2254
- });
2255
-
2256
- it('applies swagger then jwt-auth without forcing swagger dependency in auth-api', () => {
2257
- const targetRoot = mkTmp('forgeon-module-jwt-swagger-');
2258
- const projectRoot = path.join(targetRoot, 'demo-jwt-swagger');
2259
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2260
-
2261
- try {
2262
- scaffoldProject({
2263
- templateRoot,
2264
- packageRoot,
2265
- targetRoot: projectRoot,
2266
- projectName: 'demo-jwt-swagger',
2267
- frontend: 'react',
2268
- db: 'prisma',
2269
- dbPrismaEnabled: false,
2270
- i18nEnabled: false,
2271
- proxy: 'caddy',
2272
- });
2273
-
2274
- addModule({
2275
- moduleId: 'swagger',
2276
- targetRoot: projectRoot,
2277
- packageRoot,
2278
- });
2279
- addModule({
2280
- moduleId: 'jwt-auth',
2281
- targetRoot: projectRoot,
2282
- packageRoot,
2283
- });
2284
-
2285
- const authApiPackage = JSON.parse(
2286
- fs.readFileSync(path.join(projectRoot, 'packages', 'auth-api', 'package.json'), 'utf8'),
2287
- );
2288
- assert.equal(Object.hasOwn(authApiPackage.dependencies ?? {}, '@nestjs/swagger'), false);
2289
- } finally {
2290
- fs.rmSync(targetRoot, { recursive: true, force: true });
2291
- }
2292
- });
2293
-
2294
- it('keeps rate-limit wiring valid after mixed module installation order', () => {
2295
- const targetRoot = mkTmp('forgeon-module-rate-limit-order-');
2296
- const projectRoot = path.join(targetRoot, 'demo-rate-limit-order');
2297
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2298
-
2299
- try {
2300
- scaffoldProject({
2301
- templateRoot,
2302
- packageRoot,
2303
- targetRoot: projectRoot,
2304
- projectName: 'demo-rate-limit-order',
2305
- frontend: 'react',
2306
- db: 'prisma',
2307
- dbPrismaEnabled: false,
2308
- i18nEnabled: false,
2309
- proxy: 'caddy',
2310
- });
2311
-
2312
- for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'rate-limit', 'i18n', 'db-prisma']) {
2313
- addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2314
- }
2315
-
2316
- assertRateLimitWiring(projectRoot);
2317
-
2318
- const healthController = fs.readFileSync(
2319
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2320
- 'utf8',
2321
- );
2322
- const classStart = healthController.indexOf('export class HealthController {');
2323
- const classEnd = healthController.lastIndexOf('\n}');
2324
- const rateLimitProbe = healthController.indexOf("@Get('rate-limit')");
2325
- assert.equal(rateLimitProbe > classStart && rateLimitProbe < classEnd, true);
2326
- } finally {
2327
- fs.rmSync(targetRoot, { recursive: true, force: true });
2328
- }
2329
- });
2330
-
2331
- it('keeps rbac wiring valid after mixed module installation order', () => {
2332
- const targetRoot = mkTmp('forgeon-module-rbac-order-');
2333
- const projectRoot = path.join(targetRoot, 'demo-rbac-order');
2334
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2335
-
2336
- try {
2337
- scaffoldProject({
2338
- templateRoot,
2339
- packageRoot,
2340
- targetRoot: projectRoot,
2341
- projectName: 'demo-rbac-order',
2342
- frontend: 'react',
2343
- db: 'prisma',
2344
- dbPrismaEnabled: false,
2345
- i18nEnabled: false,
2346
- proxy: 'caddy',
2347
- });
2348
-
2349
- for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
2350
- addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2351
- }
2352
-
2353
- assertRbacWiring(projectRoot);
2354
-
2355
- const healthController = fs.readFileSync(
2356
- path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2357
- 'utf8',
2358
- );
2359
- const classStart = healthController.indexOf('export class HealthController {');
2360
- const classEnd = healthController.lastIndexOf('\n}');
2361
- const rbacProbe = healthController.indexOf("@Get('rbac')");
2362
- assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
2363
- } finally {
2364
- fs.rmSync(targetRoot, { recursive: true, force: true });
2365
- }
2366
- });
2367
-
2368
- it('keeps db-prisma wiring across module installation orders', () => {
2369
- const sequences = [
2370
- ['logger', 'swagger', 'i18n'],
2371
- ['swagger', 'i18n', 'logger'],
2372
- ['i18n', 'logger', 'swagger'],
2373
- ];
2374
-
2375
- for (const sequence of sequences) {
2376
- const targetRoot = mkTmp(`forgeon-module-db-order-${sequence.join('-')}-`);
2377
- const projectRoot = path.join(targetRoot, `demo-db-${sequence.join('-')}`);
2378
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2379
-
2380
- try {
2381
- scaffoldProject({
2382
- templateRoot,
2383
- packageRoot,
2384
- targetRoot: projectRoot,
2385
- projectName: `demo-db-${sequence.join('-')}`,
2386
- frontend: 'react',
2387
- db: 'prisma',
2388
- dbPrismaEnabled: true,
2389
- i18nEnabled: false,
2390
- proxy: 'caddy',
2391
- });
2392
-
2393
- for (const moduleId of sequence) {
2394
- addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2395
- }
2396
-
2397
- assertDbPrismaWiring(projectRoot);
2398
- } finally {
2399
- fs.rmSync(targetRoot, { recursive: true, force: true });
2400
- }
2401
- }
2402
- });
2403
-
2404
- it('applies db-prisma as final module after other modules', () => {
2405
- const targetRoot = mkTmp('forgeon-module-db-last-');
2406
- const projectRoot = path.join(targetRoot, 'demo-db-last');
2407
- const templateRoot = path.join(packageRoot, 'templates', 'base');
2408
-
2409
- try {
2410
- scaffoldProject({
2411
- templateRoot,
2412
- packageRoot,
2413
- targetRoot: projectRoot,
2414
- projectName: 'demo-db-last',
2415
- frontend: 'react',
2416
- db: 'prisma',
2417
- dbPrismaEnabled: true,
2418
- i18nEnabled: false,
2419
- proxy: 'caddy',
2420
- });
2421
-
2422
- stripDbPrismaArtifacts(projectRoot);
2423
-
2424
- addModule({ moduleId: 'logger', targetRoot: projectRoot, packageRoot });
2425
- addModule({ moduleId: 'swagger', targetRoot: projectRoot, packageRoot });
2426
- addModule({ moduleId: 'i18n', targetRoot: projectRoot, packageRoot });
2427
- const dbResult = addModule({ moduleId: 'db-prisma', targetRoot: projectRoot, packageRoot });
2428
- assert.equal(dbResult.applied, true);
2429
-
2430
- assertDbPrismaWiring(projectRoot);
2431
-
2432
- const moduleDoc = fs.readFileSync(dbResult.docsPath, 'utf8');
2433
- assert.match(moduleDoc, /db-adapter/);
2434
- assert.match(moduleDoc, /current canonical implementation for `db-adapter`/);
2435
-
2436
- const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
2437
- assert.match(appModule, /ForgeonLoggerModule/);
2438
- assert.match(appModule, /ForgeonSwaggerModule/);
2439
- assert.match(appModule, /ForgeonI18nModule/);
2440
- } finally {
2441
- fs.rmSync(targetRoot, { recursive: true, force: true });
2442
- }
2443
- });
2444
- });
917
+ } finally {
918
+ fs.rmSync(targetRoot, { recursive: true, force: true });
919
+ }
920
+ });
921
+
922
+ it('applies i18n module on top of scaffold without i18n', () => {
923
+ const targetRoot = mkTmp('forgeon-module-i18n-');
924
+ const projectRoot = path.join(targetRoot, 'demo-i18n');
925
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
926
+
927
+ try {
928
+ scaffoldProject({
929
+ templateRoot,
930
+ packageRoot,
931
+ targetRoot: projectRoot,
932
+ projectName: 'demo-i18n',
933
+ frontend: 'react',
934
+ db: 'prisma',
935
+ dbPrismaEnabled: true,
936
+ i18nEnabled: false,
937
+ proxy: 'caddy',
938
+ });
939
+
940
+ assert.equal(fs.existsSync(path.join(projectRoot, 'docs')), false);
941
+
942
+ const result = addModule({
943
+ moduleId: 'i18n',
944
+ targetRoot: projectRoot,
945
+ packageRoot,
946
+ });
947
+
948
+ assert.equal(result.applied, true);
949
+ assert.match(result.message, /applied/);
950
+ assert.equal(
951
+ fs.existsSync(path.join(projectRoot, 'packages', 'i18n-contracts', 'package.json')),
952
+ true,
953
+ );
954
+ assert.equal(
955
+ fs.existsSync(path.join(projectRoot, 'packages', 'i18n-web', 'package.json')),
956
+ true,
957
+ );
958
+ assert.equal(fs.existsSync(path.join(projectRoot, 'tsconfig.base.node.json')), true);
959
+ assert.equal(fs.existsSync(path.join(projectRoot, 'tsconfig.base.esm.json')), true);
960
+
961
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
962
+ assert.match(apiPackage, /@forgeon\/db-prisma/);
963
+ assert.match(apiPackage, /@forgeon\/i18n/);
964
+ assert.match(apiPackage, /@forgeon\/i18n-contracts/);
965
+
966
+ const apiTsconfig = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'tsconfig.json'), 'utf8');
967
+ assert.match(apiTsconfig, /tsconfig\.base\.node\.json/);
968
+
969
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
970
+ assert.match(compose, /I18N_DEFAULT_LANG/);
971
+ assert.doesNotMatch(compose, /I18N_ENABLED/);
972
+
973
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
974
+ assert.match(appModule, /coreConfig/);
975
+ assert.match(appModule, /dbPrismaConfig/);
976
+ assert.match(appModule, /dbPrismaEnvSchema/);
977
+ assert.match(appModule, /createEnvValidator/);
978
+ assert.match(appModule, /coreEnvSchema/);
979
+ assert.match(appModule, /i18nConfig/);
980
+ assert.match(appModule, /i18nEnvSchema/);
981
+ assert.match(appModule, /CoreConfigModule/);
982
+ assert.match(appModule, /CoreErrorsModule/);
983
+ assert.match(appModule, /DbPrismaModule/);
984
+
985
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
986
+ assert.match(mainTs, /CoreExceptionFilter/);
987
+ assert.match(mainTs, /createValidationPipe/);
988
+ assert.doesNotMatch(mainTs, /new ValidationPipe\(/);
989
+
990
+ const forgeonI18nModule = fs.readFileSync(
991
+ path.join(projectRoot, 'packages', 'i18n', 'src', 'forgeon-i18n.module.ts'),
992
+ 'utf8',
993
+ );
994
+ assert.match(forgeonI18nModule, /const resolvers = \[/);
995
+ assert.match(forgeonI18nModule, /I18nModule\.forRootAsync\([\s\S]*resolvers,/);
996
+ assert.doesNotMatch(
997
+ forgeonI18nModule,
998
+ /exports:\s*\[I18nModule,\s*I18nConfigModule,\s*I18nConfigService\]/,
999
+ );
1000
+
1001
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
1002
+ assert.match(appTsx, /@forgeon\/i18n-web/);
1003
+ assert.match(appTsx, /react-i18next/);
1004
+ assert.match(appTsx, /ui:labels\.language/);
1005
+
1006
+ const i18nWebPackage = fs.readFileSync(
1007
+ path.join(projectRoot, 'packages', 'i18n-web', 'package.json'),
1008
+ 'utf8',
1009
+ );
1010
+ assert.match(i18nWebPackage, /"type": "module"/);
1011
+
1012
+ const i18nContractsPackage = fs.readFileSync(
1013
+ path.join(projectRoot, 'packages', 'i18n-contracts', 'package.json'),
1014
+ 'utf8',
1015
+ );
1016
+ assert.match(i18nContractsPackage, /"type": "module"/);
1017
+
1018
+ const i18nWebTsconfig = fs.readFileSync(
1019
+ path.join(projectRoot, 'packages', 'i18n-web', 'tsconfig.json'),
1020
+ 'utf8',
1021
+ );
1022
+ assert.match(i18nWebTsconfig, /tsconfig\.base\.esm\.json/);
1023
+
1024
+ const i18nContractsTsconfig = fs.readFileSync(
1025
+ path.join(projectRoot, 'packages', 'i18n-contracts', 'tsconfig.json'),
1026
+ 'utf8',
1027
+ );
1028
+ assert.match(i18nContractsTsconfig, /tsconfig\.base\.esm\.json/);
1029
+
1030
+ const i18nWebSource = fs.readFileSync(
1031
+ path.join(projectRoot, 'packages', 'i18n-web', 'src', 'index.ts'),
1032
+ 'utf8',
1033
+ );
1034
+ assert.match(i18nWebSource, /@forgeon\/i18n-contracts/);
1035
+ assert.doesNotMatch(i18nWebSource, /I18N_DEFAULT_LANG/);
1036
+
1037
+ const i18nContractsIndex = fs.readFileSync(
1038
+ path.join(projectRoot, 'packages', 'i18n-contracts', 'src', 'index.ts'),
1039
+ 'utf8',
1040
+ );
1041
+ assert.match(i18nContractsIndex, /from '\.\/generated'/);
1042
+ assert.doesNotMatch(i18nContractsIndex, /I18N_DEFAULT_LANG/);
1043
+ assert.doesNotMatch(i18nContractsIndex, /I18N_FALLBACK_LANG/);
1044
+
1045
+ const enCommon = JSON.parse(
1046
+ fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'common.json'), 'utf8'),
1047
+ );
1048
+ assert.equal(enCommon.actions.ok, 'OK');
1049
+ assert.equal(enCommon.nav.next, 'Next');
1050
+
1051
+ const enErrors = JSON.parse(
1052
+ fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'errors.json'), 'utf8'),
1053
+ );
1054
+ assert.equal(enErrors.http.NOT_FOUND, 'Resource not found');
1055
+ assert.equal(enErrors.validation.VALIDATION_ERROR, 'Validation error');
1056
+
1057
+ const webPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'package.json'), 'utf8');
1058
+ assert.match(webPackage, /"i18next":/);
1059
+ assert.match(webPackage, /"react-i18next":/);
1060
+
1061
+ const mainTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'main.tsx'), 'utf8');
1062
+ assert.match(mainTsx, /import '\.\/i18n';/);
1063
+
1064
+ const i18nTs = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'i18n.ts'), 'utf8');
1065
+ assert.match(i18nTs, /initReactI18next/);
1066
+ assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/common\.json/);
1067
+ assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/ui\.json/);
1068
+ assert.doesNotMatch(i18nTs, /I18N_DEFAULT_LANG/);
1069
+
1070
+ const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
1071
+ assert.match(rootPackage, /"forgeon:sync-integrations"/);
1072
+ assert.match(rootPackage, /"i18n:sync"/);
1073
+ assert.match(rootPackage, /"i18n:check"/);
1074
+ assert.match(rootPackage, /"i18n:types"/);
1075
+ assert.match(rootPackage, /"i18n:add"/);
1076
+
1077
+ const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1078
+ assert.match(rootReadme, /## I18n Module/);
1079
+ assert.match(rootReadme, /installs independently/i);
1080
+ assert.match(rootReadme, /multi-package split/i);
1081
+ assert.match(rootReadme, /pnpm i18n:sync/);
1082
+ assert.match(rootReadme, /pnpm i18n:add <locale>/);
1083
+
1084
+ const i18nAddScriptPath = path.join(projectRoot, 'scripts', 'i18n-add.mjs');
1085
+ assert.equal(fs.existsSync(i18nAddScriptPath), true);
1086
+ const syncScriptPath = path.join(projectRoot, 'scripts', 'forgeon-sync-integrations.mjs');
1087
+ assert.equal(fs.existsSync(syncScriptPath), true);
1088
+
1089
+ const caddyDockerfile = fs.readFileSync(
1090
+ path.join(projectRoot, 'infra', 'docker', 'caddy.Dockerfile'),
1091
+ 'utf8',
1092
+ );
1093
+ assert.match(caddyDockerfile, /COPY tsconfig\.base\.json \.\//);
1094
+ assert.match(caddyDockerfile, /COPY tsconfig\.base\.node\.json \.\//);
1095
+ assert.match(caddyDockerfile, /COPY tsconfig\.base\.esm\.json \.\//);
1096
+ assert.match(
1097
+ caddyDockerfile,
1098
+ /COPY packages\/i18n-contracts\/package\.json packages\/i18n-contracts\/package\.json/,
1099
+ );
1100
+ assert.match(
1101
+ caddyDockerfile,
1102
+ /COPY packages\/i18n-web\/package\.json packages\/i18n-web\/package\.json/,
1103
+ );
1104
+ assert.match(caddyDockerfile, /COPY resources resources/);
1105
+
1106
+ const apiDockerfile = fs.readFileSync(
1107
+ path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
1108
+ 'utf8',
1109
+ );
1110
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/core build/);
1111
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/db-prisma build/);
1112
+ assert.match(apiDockerfile, /COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json/);
1113
+
1114
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1115
+ assert.match(moduleDoc, /I18n/);
1116
+ assert.match(moduleDoc, /installs independently/i);
1117
+ assert.match(moduleDoc, /helper commands are part of the module surface/i);
1118
+ } finally {
1119
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1120
+ }
1121
+ });
1122
+
1123
+ it('applies logger module on top of scaffold without i18n', () => {
1124
+ const targetRoot = mkTmp('forgeon-module-logger-');
1125
+ const projectRoot = path.join(targetRoot, 'demo-logger');
1126
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1127
+
1128
+ try {
1129
+ scaffoldProject({
1130
+ templateRoot,
1131
+ packageRoot,
1132
+ targetRoot: projectRoot,
1133
+ projectName: 'demo-logger',
1134
+ frontend: 'react',
1135
+ db: 'prisma',
1136
+ dbPrismaEnabled: true,
1137
+ i18nEnabled: false,
1138
+ proxy: 'caddy',
1139
+ });
1140
+
1141
+ const result = addModule({
1142
+ moduleId: 'logger',
1143
+ targetRoot: projectRoot,
1144
+ packageRoot,
1145
+ });
1146
+
1147
+ assert.equal(result.applied, true);
1148
+ assert.match(result.message, /applied/);
1149
+ assert.equal(
1150
+ fs.existsSync(path.join(projectRoot, 'packages', 'logger', 'package.json')),
1151
+ true,
1152
+ );
1153
+
1154
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1155
+ assert.match(apiPackage, /@forgeon\/logger/);
1156
+ assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
1157
+
1158
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1159
+ assert.match(appModule, /@forgeon\/logger/);
1160
+ assert.match(appModule, /loggerConfig/);
1161
+ assert.match(appModule, /loggerEnvSchema/);
1162
+ assert.match(appModule, /ForgeonLoggerModule/);
1163
+
1164
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1165
+ assert.match(mainTs, /ForgeonLoggerService/);
1166
+ assert.match(mainTs, /bufferLogs: true/);
1167
+ assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
1168
+ assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
1169
+
1170
+ const apiDockerfile = fs.readFileSync(
1171
+ path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
1172
+ 'utf8',
1173
+ );
1174
+ assert.match(apiDockerfile, /COPY packages\/logger\/package\.json packages\/logger\/package\.json/);
1175
+ assert.match(apiDockerfile, /COPY packages\/logger packages\/logger/);
1176
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/logger build/);
1177
+
1178
+ const loggerTsconfig = fs.readFileSync(
1179
+ path.join(projectRoot, 'packages', 'logger', 'tsconfig.json'),
1180
+ 'utf8',
1181
+ );
1182
+ assert.match(loggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
1183
+
1184
+ const loggerModule = fs.readFileSync(
1185
+ path.join(projectRoot, 'packages', 'logger', 'src', 'forgeon-logger.module.ts'),
1186
+ 'utf8',
1187
+ );
1188
+ assert.match(loggerModule, /ForgeonHttpLoggingMiddleware/);
1189
+ assert.match(loggerModule, /consumer\.apply\(RequestIdMiddleware, ForgeonHttpLoggingMiddleware\)\.forRoutes\('\*'\);/);
1190
+
1191
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
1192
+ assert.match(apiEnv, /LOGGER_LEVEL=log/);
1193
+ assert.match(apiEnv, /LOGGER_HTTP_ENABLED=true/);
1194
+ assert.match(apiEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
1195
+
1196
+ const healthController = fs.readFileSync(
1197
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1198
+ 'utf8',
1199
+ );
1200
+ assert.doesNotMatch(healthController, /logger/i);
1201
+
1202
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
1203
+ assert.doesNotMatch(appTsx, /Check logger/i);
1204
+
1205
+ const dockerEnv = fs.readFileSync(
1206
+ path.join(projectRoot, 'infra', 'docker', '.env.example'),
1207
+ 'utf8',
1208
+ );
1209
+ assert.match(dockerEnv, /LOGGER_LEVEL=log/);
1210
+ assert.match(dockerEnv, /LOGGER_HTTP_ENABLED=true/);
1211
+ assert.match(dockerEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
1212
+
1213
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
1214
+ assert.match(compose, /LOGGER_LEVEL: \$\{LOGGER_LEVEL\}/);
1215
+ assert.match(compose, /LOGGER_HTTP_ENABLED: \$\{LOGGER_HTTP_ENABLED\}/);
1216
+ assert.match(compose, /LOGGER_REQUEST_ID_HEADER: \$\{LOGGER_REQUEST_ID_HEADER\}/);
1217
+
1218
+ const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1219
+ assert.match(rootReadme, /## Logger Module/);
1220
+ assert.match(rootReadme, /installs independently/i);
1221
+ assert.match(rootReadme, /does not add a dedicated API\/web probe/i);
1222
+ assert.match(rootReadme, /LOGGER_LEVEL=log/);
1223
+ assert.match(rootReadme, /stdout\/stderr/i);
1224
+ assert.match(rootReadme, /docker compose logs api/);
1225
+
1226
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1227
+ assert.match(moduleDoc, /Logger/);
1228
+ assert.match(moduleDoc, /Status: implemented/);
1229
+ assert.match(moduleDoc, /no dedicated probe is added by design/i);
1230
+ } finally {
1231
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1232
+ }
1233
+ });
1234
+
1235
+ it('applies rate-limit module on top of scaffold without i18n', () => {
1236
+ const targetRoot = mkTmp('forgeon-module-rate-limit-');
1237
+ const projectRoot = path.join(targetRoot, 'demo-rate-limit');
1238
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1239
+
1240
+ try {
1241
+ scaffoldProject({
1242
+ templateRoot,
1243
+ packageRoot,
1244
+ targetRoot: projectRoot,
1245
+ projectName: 'demo-rate-limit',
1246
+ frontend: 'react',
1247
+ db: 'prisma',
1248
+ dbPrismaEnabled: false,
1249
+ i18nEnabled: false,
1250
+ proxy: 'caddy',
1251
+ });
1252
+
1253
+ const result = addModule({
1254
+ moduleId: 'rate-limit',
1255
+ targetRoot: projectRoot,
1256
+ packageRoot,
1257
+ });
1258
+
1259
+ assert.equal(result.applied, true);
1260
+ assertRateLimitWiring(projectRoot);
1261
+
1262
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1263
+ assert.match(moduleDoc, /## Idea \/ Why/);
1264
+ assert.match(moduleDoc, /## Configuration/);
1265
+ assert.match(moduleDoc, /installs independently/i);
1266
+ assert.match(moduleDoc, /No follow-up integration sync is required/i);
1267
+ } finally {
1268
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1269
+ }
1270
+ });
1271
+
1272
+ it('applies rbac module on top of scaffold without i18n', () => {
1273
+ const targetRoot = mkTmp('forgeon-module-rbac-');
1274
+ const projectRoot = path.join(targetRoot, 'demo-rbac');
1275
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1276
+
1277
+ try {
1278
+ scaffoldProject({
1279
+ templateRoot,
1280
+ packageRoot,
1281
+ targetRoot: projectRoot,
1282
+ projectName: 'demo-rbac',
1283
+ frontend: 'react',
1284
+ db: 'prisma',
1285
+ dbPrismaEnabled: false,
1286
+ i18nEnabled: false,
1287
+ proxy: 'caddy',
1288
+ });
1289
+
1290
+ const result = addModule({
1291
+ moduleId: 'rbac',
1292
+ targetRoot: projectRoot,
1293
+ packageRoot,
1294
+ });
1295
+
1296
+ assert.equal(result.applied, true);
1297
+ assertRbacWiring(projectRoot);
1298
+
1299
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1300
+ assert.match(moduleDoc, /## Idea \/ Why/);
1301
+ assert.match(moduleDoc, /## How It Works/);
1302
+ } finally {
1303
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1304
+ }
1305
+ });
1306
+
1307
+ it('applies files-local then files foundation modules without breaking api wiring', () => {
1308
+ const targetRoot = mkTmp('forgeon-module-files-local-');
1309
+ const projectRoot = path.join(targetRoot, 'demo-files-local');
1310
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1311
+
1312
+ try {
1313
+ scaffoldProject({
1314
+ templateRoot,
1315
+ packageRoot,
1316
+ targetRoot: projectRoot,
1317
+ projectName: 'demo-files-local',
1318
+ frontend: 'react',
1319
+ db: 'prisma',
1320
+ dbPrismaEnabled: true,
1321
+ i18nEnabled: false,
1322
+ proxy: 'caddy',
1323
+ });
1324
+
1325
+ const localResult = addModule({
1326
+ moduleId: 'files-local',
1327
+ targetRoot: projectRoot,
1328
+ packageRoot,
1329
+ });
1330
+ assert.equal(localResult.applied, true);
1331
+ assertFilesLocalWiring(projectRoot);
1332
+
1333
+ const filesResult = addModule({
1334
+ moduleId: 'files',
1335
+ targetRoot: projectRoot,
1336
+ packageRoot,
1337
+ });
1338
+ assert.equal(filesResult.applied, true);
1339
+ assertFilesWiring(projectRoot);
1340
+
1341
+ const moduleDoc = fs.readFileSync(filesResult.docsPath, 'utf8');
1342
+ assert.match(moduleDoc, /requires `db-adapter`/i);
1343
+ assert.match(moduleDoc, /requires `files-storage-adapter`/i);
1344
+ } finally {
1345
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1346
+ }
1347
+ });
1348
+
1349
+ it('applies files-s3 foundation module with env and docker wiring', () => {
1350
+ const targetRoot = mkTmp('forgeon-module-files-s3-');
1351
+ const projectRoot = path.join(targetRoot, 'demo-files-s3');
1352
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1353
+
1354
+ try {
1355
+ scaffoldProject({
1356
+ templateRoot,
1357
+ packageRoot,
1358
+ targetRoot: projectRoot,
1359
+ projectName: 'demo-files-s3',
1360
+ frontend: 'react',
1361
+ db: 'prisma',
1362
+ dbPrismaEnabled: true,
1363
+ i18nEnabled: false,
1364
+ proxy: 'caddy',
1365
+ });
1366
+
1367
+ const result = addModule({
1368
+ moduleId: 'files-s3',
1369
+ targetRoot: projectRoot,
1370
+ packageRoot,
1371
+ });
1372
+ assert.equal(result.applied, true);
1373
+ assertFilesS3Wiring(projectRoot);
1374
+ } finally {
1375
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1376
+ }
1377
+ });
1378
+
1379
+ it('applies files-s3 then files and keeps s3 driver default without requiring files-local', () => {
1380
+ const targetRoot = mkTmp('forgeon-module-files-s3-runtime-');
1381
+ const projectRoot = path.join(targetRoot, 'demo-files-s3-runtime');
1382
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1383
+
1384
+ try {
1385
+ scaffoldProject({
1386
+ templateRoot,
1387
+ packageRoot,
1388
+ targetRoot: projectRoot,
1389
+ projectName: 'demo-files-s3-runtime',
1390
+ frontend: 'react',
1391
+ db: 'prisma',
1392
+ dbPrismaEnabled: true,
1393
+ i18nEnabled: false,
1394
+ proxy: 'caddy',
1395
+ });
1396
+
1397
+ addModule({
1398
+ moduleId: 'files-s3',
1399
+ targetRoot: projectRoot,
1400
+ packageRoot,
1401
+ });
1402
+ addModule({
1403
+ moduleId: 'files',
1404
+ targetRoot: projectRoot,
1405
+ packageRoot,
1406
+ });
1407
+
1408
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
1409
+ assert.match(apiEnv, /FILES_STORAGE_DRIVER=s3/);
1410
+
1411
+ const filesService = fs.readFileSync(
1412
+ path.join(projectRoot, 'packages', 'files', 'src', 'files.service.ts'),
1413
+ 'utf8',
1414
+ );
1415
+ assert.match(filesService, /storeS3/);
1416
+ assert.match(filesService, /openS3/);
1417
+ assert.match(filesService, /deleteS3/);
1418
+ assert.match(filesService, /@aws-sdk\/client-s3/);
1419
+ } finally {
1420
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1421
+ }
1422
+ });
1423
+
1424
+ it('applies files-access after files and wires file route checks and probe UI', () => {
1425
+ const targetRoot = mkTmp('forgeon-module-files-access-');
1426
+ const projectRoot = path.join(targetRoot, 'demo-files-access');
1427
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1428
+
1429
+ try {
1430
+ scaffoldProject({
1431
+ templateRoot,
1432
+ packageRoot,
1433
+ targetRoot: projectRoot,
1434
+ projectName: 'demo-files-access',
1435
+ frontend: 'react',
1436
+ db: 'prisma',
1437
+ dbPrismaEnabled: true,
1438
+ i18nEnabled: false,
1439
+ proxy: 'caddy',
1440
+ });
1441
+
1442
+ addModule({
1443
+ moduleId: 'files-local',
1444
+ targetRoot: projectRoot,
1445
+ packageRoot,
1446
+ });
1447
+ addModule({
1448
+ moduleId: 'files',
1449
+ targetRoot: projectRoot,
1450
+ packageRoot,
1451
+ });
1452
+ const result = addModule({
1453
+ moduleId: 'files-access',
1454
+ targetRoot: projectRoot,
1455
+ packageRoot,
1456
+ });
1457
+
1458
+ assert.equal(result.applied, true);
1459
+ assertFilesAccessWiring(projectRoot);
1460
+ } finally {
1461
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1462
+ }
1463
+ });
1464
+
1465
+ it('applies files-quotas after files and wires upload quota checks and probe UI', () => {
1466
+ const targetRoot = mkTmp('forgeon-module-files-quotas-');
1467
+ const projectRoot = path.join(targetRoot, 'demo-files-quotas');
1468
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1469
+
1470
+ try {
1471
+ scaffoldProject({
1472
+ templateRoot,
1473
+ packageRoot,
1474
+ targetRoot: projectRoot,
1475
+ projectName: 'demo-files-quotas',
1476
+ frontend: 'react',
1477
+ db: 'prisma',
1478
+ dbPrismaEnabled: true,
1479
+ i18nEnabled: false,
1480
+ proxy: 'caddy',
1481
+ });
1482
+
1483
+ addModule({
1484
+ moduleId: 'files-local',
1485
+ targetRoot: projectRoot,
1486
+ packageRoot,
1487
+ });
1488
+ addModule({
1489
+ moduleId: 'files',
1490
+ targetRoot: projectRoot,
1491
+ packageRoot,
1492
+ });
1493
+ const result = addModule({
1494
+ moduleId: 'files-quotas',
1495
+ targetRoot: projectRoot,
1496
+ packageRoot,
1497
+ });
1498
+
1499
+ assert.equal(result.applied, true);
1500
+ assertFilesQuotasWiring(projectRoot);
1501
+ } finally {
1502
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1503
+ }
1504
+ });
1505
+
1506
+ it('applies files-image after files and wires sanitize pipeline with default metadata stripping', () => {
1507
+ const targetRoot = mkTmp('forgeon-module-files-image-');
1508
+ const projectRoot = path.join(targetRoot, 'demo-files-image');
1509
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1510
+
1511
+ try {
1512
+ scaffoldProject({
1513
+ templateRoot,
1514
+ packageRoot,
1515
+ targetRoot: projectRoot,
1516
+ projectName: 'demo-files-image',
1517
+ frontend: 'react',
1518
+ db: 'prisma',
1519
+ dbPrismaEnabled: true,
1520
+ i18nEnabled: false,
1521
+ proxy: 'caddy',
1522
+ });
1523
+
1524
+ addModule({
1525
+ moduleId: 'files-local',
1526
+ targetRoot: projectRoot,
1527
+ packageRoot,
1528
+ });
1529
+ addModule({
1530
+ moduleId: 'files',
1531
+ targetRoot: projectRoot,
1532
+ packageRoot,
1533
+ });
1534
+ const result = addModule({
1535
+ moduleId: 'files-image',
1536
+ targetRoot: projectRoot,
1537
+ packageRoot,
1538
+ });
1539
+
1540
+ assert.equal(result.applied, true);
1541
+ assertFilesImageWiring(projectRoot);
1542
+ } finally {
1543
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1544
+ }
1545
+ });
1546
+
1547
+ it('applies full files stack in mixed order and keeps runtime probes consistent', () => {
1548
+ const targetRoot = mkTmp('forgeon-module-files-stack-smoke-');
1549
+ const projectRoot = path.join(targetRoot, 'demo-files-stack-smoke');
1550
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1551
+
1552
+ try {
1553
+ scaffoldProject({
1554
+ templateRoot,
1555
+ packageRoot,
1556
+ targetRoot: projectRoot,
1557
+ projectName: 'demo-files-stack-smoke',
1558
+ frontend: 'react',
1559
+ db: 'prisma',
1560
+ dbPrismaEnabled: true,
1561
+ i18nEnabled: false,
1562
+ proxy: 'caddy',
1563
+ });
1564
+
1565
+ addModule({
1566
+ moduleId: 'files-s3',
1567
+ targetRoot: projectRoot,
1568
+ packageRoot,
1569
+ });
1570
+ addModule({
1571
+ moduleId: 'files',
1572
+ targetRoot: projectRoot,
1573
+ packageRoot,
1574
+ });
1575
+ addModule({
1576
+ moduleId: 'files-image',
1577
+ targetRoot: projectRoot,
1578
+ packageRoot,
1579
+ });
1580
+ addModule({
1581
+ moduleId: 'files-access',
1582
+ targetRoot: projectRoot,
1583
+ packageRoot,
1584
+ });
1585
+ addModule({
1586
+ moduleId: 'files-quotas',
1587
+ targetRoot: projectRoot,
1588
+ packageRoot,
1589
+ });
1590
+
1591
+ assertFilesS3Wiring(projectRoot);
1592
+ assertFilesWiring(projectRoot, 's3');
1593
+ assertFilesImageWiring(projectRoot);
1594
+ assertFilesAccessWiring(projectRoot);
1595
+ assertFilesQuotasWiring(projectRoot);
1596
+
1597
+ const healthController = fs.readFileSync(
1598
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1599
+ 'utf8',
1600
+ );
1601
+ assert.match(healthController, /@Post\('files'\)/);
1602
+ assert.match(healthController, /@Get\('files-variants'\)/);
1603
+ assert.match(healthController, /@Get\('files-image'\)/);
1604
+ assert.match(healthController, /@Get\('files-access'\)/);
1605
+ assert.match(healthController, /@Get\('files-quotas'\)/);
1606
+
1607
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
1608
+ const filesChecks = appTsx.match(/Check files /g) ?? [];
1609
+ assert.equal(filesChecks.length, 5);
1610
+ } finally {
1611
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1612
+ }
1613
+ });
1614
+
1615
+ it('applies swagger module on top of scaffold without i18n', () => {
1616
+ const targetRoot = mkTmp('forgeon-module-swagger-');
1617
+ const projectRoot = path.join(targetRoot, 'demo-swagger');
1618
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1619
+
1620
+ try {
1621
+ scaffoldProject({
1622
+ templateRoot,
1623
+ packageRoot,
1624
+ targetRoot: projectRoot,
1625
+ projectName: 'demo-swagger',
1626
+ frontend: 'react',
1627
+ db: 'prisma',
1628
+ dbPrismaEnabled: true,
1629
+ i18nEnabled: false,
1630
+ proxy: 'caddy',
1631
+ });
1632
+
1633
+ const result = addModule({
1634
+ moduleId: 'swagger',
1635
+ targetRoot: projectRoot,
1636
+ packageRoot,
1637
+ });
1638
+
1639
+ assert.equal(result.applied, true);
1640
+ assert.match(result.message, /applied/);
1641
+ assert.equal(
1642
+ fs.existsSync(path.join(projectRoot, 'packages', 'swagger', 'package.json')),
1643
+ true,
1644
+ );
1645
+
1646
+ const swaggerTsconfig = fs.readFileSync(
1647
+ path.join(projectRoot, 'packages', 'swagger', 'tsconfig.json'),
1648
+ 'utf8',
1649
+ );
1650
+ assert.match(swaggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
1651
+
1652
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1653
+ assert.match(apiPackage, /@forgeon\/swagger/);
1654
+ assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
1655
+
1656
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1657
+ assert.match(appModule, /@forgeon\/swagger/);
1658
+ assert.match(appModule, /swaggerConfig/);
1659
+ assert.match(appModule, /swaggerEnvSchema/);
1660
+ assert.match(appModule, /ForgeonSwaggerModule/);
1661
+
1662
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1663
+ assert.match(mainTs, /setupSwagger/);
1664
+ assert.match(mainTs, /SwaggerConfigService/);
1665
+ assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
1666
+
1667
+ const apiDockerfile = fs.readFileSync(
1668
+ path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
1669
+ 'utf8',
1670
+ );
1671
+ assert.match(apiDockerfile, /COPY packages\/swagger\/package\.json packages\/swagger\/package\.json/);
1672
+ assert.match(apiDockerfile, /COPY packages\/swagger packages\/swagger/);
1673
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/swagger build/);
1674
+
1675
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
1676
+ assert.match(apiEnv, /SWAGGER_ENABLED=false/);
1677
+ assert.match(apiEnv, /SWAGGER_PATH=docs/);
1678
+ assert.match(apiEnv, /SWAGGER_TITLE="Forgeon API"/);
1679
+ assert.match(apiEnv, /SWAGGER_VERSION=1\.0\.0/);
1680
+
1681
+ const dockerEnv = fs.readFileSync(
1682
+ path.join(projectRoot, 'infra', 'docker', '.env.example'),
1683
+ 'utf8',
1684
+ );
1685
+ assert.match(dockerEnv, /SWAGGER_ENABLED=false/);
1686
+ assert.match(dockerEnv, /SWAGGER_PATH=docs/);
1687
+ assert.match(dockerEnv, /SWAGGER_TITLE="Forgeon API"/);
1688
+ assert.match(dockerEnv, /SWAGGER_VERSION=1\.0\.0/);
1689
+
1690
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
1691
+ assert.match(compose, /SWAGGER_ENABLED: \$\{SWAGGER_ENABLED\}/);
1692
+ assert.match(compose, /SWAGGER_PATH: \$\{SWAGGER_PATH\}/);
1693
+ assert.match(compose, /SWAGGER_TITLE: \$\{SWAGGER_TITLE\}/);
1694
+ assert.match(compose, /SWAGGER_VERSION: \$\{SWAGGER_VERSION\}/);
1695
+
1696
+ const rootReadme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
1697
+ assert.match(rootReadme, /## Swagger \/ OpenAPI Module/);
1698
+ assert.match(rootReadme, /installs independently/i);
1699
+ assert.match(rootReadme, /decorators.*manual/i);
1700
+ assert.match(rootReadme, /SWAGGER_ENABLED=false/);
1701
+ assert.match(rootReadme, /localhost:3000\/api\/docs/);
1702
+
1703
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
1704
+ assert.match(moduleDoc, /Swagger \/ OpenAPI/);
1705
+ assert.match(moduleDoc, /Status: implemented/);
1706
+ assert.match(moduleDoc, /feature-specific Swagger decorators remain manual/i);
1707
+ } finally {
1708
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1709
+ }
1710
+ });
1711
+
1712
+ it('applies swagger module on top of scaffold with i18n', () => {
1713
+ const targetRoot = mkTmp('forgeon-module-swagger-i18n-');
1714
+ const projectRoot = path.join(targetRoot, 'demo-swagger-i18n');
1715
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1716
+
1717
+ try {
1718
+ scaffoldProject({
1719
+ templateRoot,
1720
+ packageRoot,
1721
+ targetRoot: projectRoot,
1722
+ projectName: 'demo-swagger-i18n',
1723
+ frontend: 'react',
1724
+ db: 'prisma',
1725
+ dbPrismaEnabled: true,
1726
+ i18nEnabled: true,
1727
+ proxy: 'caddy',
1728
+ });
1729
+
1730
+ const result = addModule({
1731
+ moduleId: 'swagger',
1732
+ targetRoot: projectRoot,
1733
+ packageRoot,
1734
+ });
1735
+
1736
+ assert.equal(result.applied, true);
1737
+
1738
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1739
+ const loadMatch = appModule.match(/load: \[([^\]]+)\]/);
1740
+ assert.ok(loadMatch);
1741
+ assert.match(loadMatch[1], /coreConfig/);
1742
+ assert.match(loadMatch[1], /dbPrismaConfig/);
1743
+ assert.match(loadMatch[1], /i18nConfig/);
1744
+ assert.match(loadMatch[1], /swaggerConfig/);
1745
+
1746
+ const validateMatch = appModule.match(/validate: createEnvValidator\(\[([^\]]+)\]\)/);
1747
+ assert.ok(validateMatch);
1748
+ assert.match(validateMatch[1], /coreEnvSchema/);
1749
+ assert.match(validateMatch[1], /dbPrismaEnvSchema/);
1750
+ assert.match(validateMatch[1], /i18nEnvSchema/);
1751
+ assert.match(validateMatch[1], /swaggerEnvSchema/);
1752
+ assert.match(appModule, /ForgeonSwaggerModule/);
1753
+ assert.match(appModule, /ForgeonI18nModule/);
1754
+ } finally {
1755
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1756
+ }
1757
+ });
1758
+
1759
+ it('applies logger after swagger without losing logger config keys', () => {
1760
+ const targetRoot = mkTmp('forgeon-module-swagger-logger-');
1761
+ const projectRoot = path.join(targetRoot, 'demo-swagger-logger');
1762
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1763
+
1764
+ try {
1765
+ scaffoldProject({
1766
+ templateRoot,
1767
+ packageRoot,
1768
+ targetRoot: projectRoot,
1769
+ projectName: 'demo-swagger-logger',
1770
+ frontend: 'react',
1771
+ db: 'prisma',
1772
+ dbPrismaEnabled: true,
1773
+ i18nEnabled: true,
1774
+ proxy: 'caddy',
1775
+ });
1776
+
1777
+ const swaggerResult = addModule({
1778
+ moduleId: 'swagger',
1779
+ targetRoot: projectRoot,
1780
+ packageRoot,
1781
+ });
1782
+ assert.equal(swaggerResult.applied, true);
1783
+
1784
+ const loggerResult = addModule({
1785
+ moduleId: 'logger',
1786
+ targetRoot: projectRoot,
1787
+ packageRoot,
1788
+ });
1789
+ assert.equal(loggerResult.applied, true);
1790
+
1791
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1792
+ const loadMatch = appModule.match(/load: \[([^\]]+)\]/);
1793
+ assert.ok(loadMatch);
1794
+ assert.match(loadMatch[1], /coreConfig/);
1795
+ assert.match(loadMatch[1], /dbPrismaConfig/);
1796
+ assert.match(loadMatch[1], /i18nConfig/);
1797
+ assert.match(loadMatch[1], /swaggerConfig/);
1798
+ assert.match(loadMatch[1], /loggerConfig/);
1799
+
1800
+ const validateMatch = appModule.match(/validate: createEnvValidator\(\[([^\]]+)\]\)/);
1801
+ assert.ok(validateMatch);
1802
+ assert.match(validateMatch[1], /coreEnvSchema/);
1803
+ assert.match(validateMatch[1], /dbPrismaEnvSchema/);
1804
+ assert.match(validateMatch[1], /i18nEnvSchema/);
1805
+ assert.match(validateMatch[1], /swaggerEnvSchema/);
1806
+ assert.match(validateMatch[1], /loggerEnvSchema/);
1807
+ assert.match(appModule, /ForgeonSwaggerModule/);
1808
+ assert.match(appModule, /ForgeonLoggerModule/);
1809
+ } finally {
1810
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1811
+ }
1812
+ });
1813
+
1814
+ it('applies i18n after logger without losing logger config keys', () => {
1815
+ const targetRoot = mkTmp('forgeon-module-logger-i18n-');
1816
+ const projectRoot = path.join(targetRoot, 'demo-logger-i18n');
1817
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1818
+
1819
+ try {
1820
+ scaffoldProject({
1821
+ templateRoot,
1822
+ packageRoot,
1823
+ targetRoot: projectRoot,
1824
+ projectName: 'demo-logger-i18n',
1825
+ frontend: 'react',
1826
+ db: 'prisma',
1827
+ dbPrismaEnabled: true,
1828
+ i18nEnabled: false,
1829
+ proxy: 'caddy',
1830
+ });
1831
+
1832
+ addModule({
1833
+ moduleId: 'logger',
1834
+ targetRoot: projectRoot,
1835
+ packageRoot,
1836
+ });
1837
+
1838
+ const i18nResult = addModule({
1839
+ moduleId: 'i18n',
1840
+ targetRoot: projectRoot,
1841
+ packageRoot,
1842
+ });
1843
+ assert.equal(i18nResult.applied, true);
1844
+
1845
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1846
+ assert.match(
1847
+ appModule,
1848
+ /load: \[coreConfig,\s*dbPrismaConfig,\s*loggerConfig,\s*i18nConfig\]/,
1849
+ );
1850
+ assert.match(
1851
+ appModule,
1852
+ /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*loggerEnvSchema,\s*i18nEnvSchema\]\)/,
1853
+ );
1854
+ assert.match(appModule, /ForgeonLoggerModule/);
1855
+ assert.match(appModule, /ForgeonI18nModule/);
1856
+
1857
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1858
+ assert.match(apiPackage, /@forgeon\/logger/);
1859
+ assert.match(apiPackage, /@forgeon\/i18n/);
1860
+ assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
1861
+ assert.match(apiPackage, /pnpm --filter @forgeon\/i18n build/);
1862
+
1863
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1864
+ assert.match(mainTs, /ForgeonLoggerService/);
1865
+ assert.doesNotMatch(mainTs, /ForgeonHttpLoggingInterceptor/);
1866
+ } finally {
1867
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1868
+ }
1869
+ });
1870
+
1871
+ it('applies i18n after swagger without losing swagger config keys', () => {
1872
+ const targetRoot = mkTmp('forgeon-module-swagger-i18n-order-');
1873
+ const projectRoot = path.join(targetRoot, 'demo-swagger-i18n-order');
1874
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1875
+
1876
+ try {
1877
+ scaffoldProject({
1878
+ templateRoot,
1879
+ packageRoot,
1880
+ targetRoot: projectRoot,
1881
+ projectName: 'demo-swagger-i18n-order',
1882
+ frontend: 'react',
1883
+ db: 'prisma',
1884
+ dbPrismaEnabled: true,
1885
+ i18nEnabled: false,
1886
+ proxy: 'caddy',
1887
+ });
1888
+
1889
+ addModule({
1890
+ moduleId: 'swagger',
1891
+ targetRoot: projectRoot,
1892
+ packageRoot,
1893
+ });
1894
+
1895
+ const i18nResult = addModule({
1896
+ moduleId: 'i18n',
1897
+ targetRoot: projectRoot,
1898
+ packageRoot,
1899
+ });
1900
+ assert.equal(i18nResult.applied, true);
1901
+
1902
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1903
+ assert.match(
1904
+ appModule,
1905
+ /load: \[coreConfig,\s*dbPrismaConfig,\s*swaggerConfig,\s*i18nConfig\]/,
1906
+ );
1907
+ assert.match(
1908
+ appModule,
1909
+ /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*swaggerEnvSchema,\s*i18nEnvSchema\]\)/,
1910
+ );
1911
+ assert.match(appModule, /ForgeonSwaggerModule/);
1912
+ assert.match(appModule, /ForgeonI18nModule/);
1913
+
1914
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1915
+ assert.match(apiPackage, /@forgeon\/swagger/);
1916
+ assert.match(apiPackage, /@forgeon\/i18n/);
1917
+ assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
1918
+ assert.match(apiPackage, /pnpm --filter @forgeon\/i18n build/);
1919
+
1920
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1921
+ assert.match(mainTs, /setupSwagger/);
1922
+ assert.match(mainTs, /SwaggerConfigService/);
1923
+ } finally {
1924
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1925
+ }
1926
+ });
1927
+
1928
+ it('applies swagger -> logger -> i18n and keeps all module wiring', () => {
1929
+ const targetRoot = mkTmp('forgeon-module-mixed-order-');
1930
+ const projectRoot = path.join(targetRoot, 'demo-mixed-order');
1931
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1932
+
1933
+ try {
1934
+ scaffoldProject({
1935
+ templateRoot,
1936
+ packageRoot,
1937
+ targetRoot: projectRoot,
1938
+ projectName: 'demo-mixed-order',
1939
+ frontend: 'react',
1940
+ db: 'prisma',
1941
+ dbPrismaEnabled: true,
1942
+ i18nEnabled: false,
1943
+ proxy: 'caddy',
1944
+ });
1945
+
1946
+ addModule({ moduleId: 'swagger', targetRoot: projectRoot, packageRoot });
1947
+ addModule({ moduleId: 'logger', targetRoot: projectRoot, packageRoot });
1948
+ addModule({ moduleId: 'i18n', targetRoot: projectRoot, packageRoot });
1949
+
1950
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
1951
+ assert.match(
1952
+ appModule,
1953
+ /load: \[coreConfig,\s*dbPrismaConfig,\s*swaggerConfig,\s*loggerConfig,\s*i18nConfig\]/,
1954
+ );
1955
+ assert.match(
1956
+ appModule,
1957
+ /validate: createEnvValidator\(\[coreEnvSchema,\s*dbPrismaEnvSchema,\s*swaggerEnvSchema,\s*loggerEnvSchema,\s*i18nEnvSchema\]\)/,
1958
+ );
1959
+ assert.match(appModule, /ForgeonSwaggerModule/);
1960
+ assert.match(appModule, /ForgeonLoggerModule/);
1961
+ assert.match(appModule, /ForgeonI18nModule/);
1962
+
1963
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1964
+ assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
1965
+ assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
1966
+ assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
1967
+
1968
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1969
+ assert.match(apiPackage, /@forgeon\/swagger/);
1970
+ assert.match(apiPackage, /@forgeon\/logger/);
1971
+ assert.match(apiPackage, /@forgeon\/i18n/);
1972
+ assert.match(apiPackage, /pnpm --filter @forgeon\/swagger build/);
1973
+ assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
1974
+ assert.match(apiPackage, /pnpm --filter @forgeon\/i18n build/);
1975
+
1976
+ assertDbPrismaWiring(projectRoot);
1977
+ } finally {
1978
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1979
+ }
1980
+ });
1981
+
1982
+ it('applies jwt-auth with db-prisma as stateless first, then wires persistence via explicit sync', () => {
1983
+ const targetRoot = mkTmp('forgeon-module-jwt-db-');
1984
+ const projectRoot = path.join(targetRoot, 'demo-jwt-db');
1985
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1986
+
1987
+ try {
1988
+ scaffoldProject({
1989
+ templateRoot,
1990
+ packageRoot,
1991
+ targetRoot: projectRoot,
1992
+ projectName: 'demo-jwt-db',
1993
+ frontend: 'react',
1994
+ db: 'prisma',
1995
+ dbPrismaEnabled: true,
1996
+ i18nEnabled: true,
1997
+ proxy: 'caddy',
1998
+ });
1999
+
2000
+ const result = addModule({
2001
+ moduleId: 'jwt-auth',
2002
+ targetRoot: projectRoot,
2003
+ packageRoot,
2004
+ });
2005
+
2006
+ assert.equal(result.applied, true);
2007
+ assertJwtAuthWiring(projectRoot, false);
2008
+
2009
+ const syncResult = syncIntegrations({ targetRoot: projectRoot, packageRoot });
2010
+ const dbPair = syncResult.summary.find((item) => item.id === 'auth-persistence');
2011
+ assert.ok(dbPair);
2012
+ assert.equal(dbPair.result.applied, true);
2013
+ assert.equal(syncResult.changedFiles.length > 0, true);
2014
+
2015
+ assertJwtAuthWiring(projectRoot, true);
2016
+
2017
+ const storeFile = path.join(
2018
+ projectRoot,
2019
+ 'apps',
2020
+ 'api',
2021
+ 'src',
2022
+ 'auth',
2023
+ 'prisma-auth-refresh-token.store.ts',
2024
+ );
2025
+ assert.equal(fs.existsSync(storeFile), true);
2026
+
2027
+ const schema = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'prisma', 'schema.prisma'), 'utf8');
2028
+ assert.match(schema, /refreshTokenHash/);
2029
+
2030
+ const migrationPath = path.join(
2031
+ projectRoot,
2032
+ 'apps',
2033
+ 'api',
2034
+ 'prisma',
2035
+ 'migrations',
2036
+ '0002_auth_refresh_token_hash',
2037
+ 'migration.sql',
2038
+ );
2039
+ assert.equal(fs.existsSync(migrationPath), true);
2040
+
2041
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
2042
+ assert.match(readme, /refresh token persistence: enabled/);
2043
+ assert.match(readme, /db-adapter/);
2044
+ assert.match(readme, /current provider: `db-prisma`/);
2045
+ assert.match(readme, /0002_auth_refresh_token_hash/);
2046
+
2047
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
2048
+ assert.match(moduleDoc, /Status: implemented/);
2049
+ assert.match(moduleDoc, /db-adapter/);
2050
+ } finally {
2051
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2052
+ }
2053
+ });
2054
+
2055
+ it('applies jwt-auth without db and keeps stateless fallback until pair sync is available', () => {
2056
+ const targetRoot = mkTmp('forgeon-module-jwt-nodb-');
2057
+ const projectRoot = path.join(targetRoot, 'demo-jwt-nodb');
2058
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2059
+
2060
+ try {
2061
+ scaffoldProject({
2062
+ templateRoot,
2063
+ packageRoot,
2064
+ targetRoot: projectRoot,
2065
+ projectName: 'demo-jwt-nodb',
2066
+ frontend: 'react',
2067
+ db: 'prisma',
2068
+ dbPrismaEnabled: true,
2069
+ i18nEnabled: false,
2070
+ proxy: 'caddy',
2071
+ });
2072
+
2073
+ stripDbPrismaArtifacts(projectRoot);
2074
+
2075
+ const result = addModule({
2076
+ moduleId: 'jwt-auth',
2077
+ targetRoot: projectRoot,
2078
+ packageRoot,
2079
+ });
2080
+
2081
+ assert.equal(result.applied, true);
2082
+ assertJwtAuthWiring(projectRoot, false);
2083
+ assert.equal(
2084
+ fs.existsSync(path.join(projectRoot, 'apps', 'api', 'src', 'auth', 'prisma-auth-refresh-token.store.ts')),
2085
+ false,
2086
+ );
2087
+
2088
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
2089
+ assert.match(readme, /refresh token persistence: disabled/);
2090
+ assert.match(readme, /db-adapter/);
2091
+ assert.match(readme, /create-forgeon add db-prisma/);
2092
+
2093
+ } finally {
2094
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2095
+ }
2096
+ });
2097
+
2098
+ it('detects and applies jwt-auth + rbac claims integration explicitly', () => {
2099
+ const targetRoot = mkTmp('forgeon-module-jwt-rbac-');
2100
+ const projectRoot = path.join(targetRoot, 'demo-jwt-rbac');
2101
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2102
+
2103
+ try {
2104
+ scaffoldProject({
2105
+ templateRoot,
2106
+ packageRoot,
2107
+ targetRoot: projectRoot,
2108
+ projectName: 'demo-jwt-rbac',
2109
+ frontend: 'react',
2110
+ db: 'prisma',
2111
+ dbPrismaEnabled: false,
2112
+ i18nEnabled: false,
2113
+ proxy: 'caddy',
2114
+ });
2115
+
2116
+ addModule({
2117
+ moduleId: 'rbac',
2118
+ targetRoot: projectRoot,
2119
+ packageRoot,
2120
+ });
2121
+ addModule({
2122
+ moduleId: 'jwt-auth',
2123
+ targetRoot: projectRoot,
2124
+ packageRoot,
2125
+ });
2126
+
2127
+ const scan = scanIntegrations({
2128
+ targetRoot: projectRoot,
2129
+ relatedModuleId: 'jwt-auth',
2130
+ });
2131
+ assert.equal(scan.groups.some((group) => group.id === 'auth-rbac-claims'), true);
2132
+
2133
+ const syncResult = syncIntegrations({
2134
+ targetRoot: projectRoot,
2135
+ packageRoot,
2136
+ groupIds: ['auth-rbac-claims'],
2137
+ });
2138
+ const claimsPair = syncResult.summary.find((item) => item.id === 'auth-rbac-claims');
2139
+ assert.ok(claimsPair);
2140
+ assert.equal(claimsPair.result.applied, true);
2141
+
2142
+ const authContracts = fs.readFileSync(
2143
+ path.join(projectRoot, 'packages', 'auth-contracts', 'src', 'index.ts'),
2144
+ 'utf8',
2145
+ );
2146
+ assert.match(authContracts, /permissions\?: string\[\];/);
2147
+
2148
+ const authService = fs.readFileSync(
2149
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
2150
+ 'utf8',
2151
+ );
2152
+ assert.match(authService, /permissions: \['health\.rbac'\]/);
2153
+ assert.match(authService, /permissions: user\.permissions,/);
2154
+ assert.match(
2155
+ authService,
2156
+ /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
2157
+ );
2158
+
2159
+ const authController = fs.readFileSync(
2160
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.controller.ts'),
2161
+ 'utf8',
2162
+ );
2163
+ assert.match(
2164
+ authController,
2165
+ /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
2166
+ );
2167
+ } finally {
2168
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2169
+ }
2170
+ });
2171
+
2172
+ it('scans auth persistence as db-adapter participant while remaining triggerable from db-prisma install order', () => {
2173
+ const targetRoot = mkTmp('forgeon-module-jwt-db-scan-');
2174
+ const projectRoot = path.join(targetRoot, 'demo-jwt-db-scan');
2175
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2176
+
2177
+ try {
2178
+ scaffoldProject({
2179
+ templateRoot,
2180
+ packageRoot,
2181
+ targetRoot: projectRoot,
2182
+ projectName: 'demo-jwt-db-scan',
2183
+ frontend: 'react',
2184
+ db: 'prisma',
2185
+ dbPrismaEnabled: false,
2186
+ i18nEnabled: false,
2187
+ proxy: 'caddy',
2188
+ });
2189
+
2190
+ addModule({
2191
+ moduleId: 'jwt-auth',
2192
+ targetRoot: projectRoot,
2193
+ packageRoot,
2194
+ });
2195
+ addModule({
2196
+ moduleId: 'db-prisma',
2197
+ targetRoot: projectRoot,
2198
+ packageRoot,
2199
+ });
2200
+
2201
+ const scan = scanIntegrations({
2202
+ targetRoot: projectRoot,
2203
+ relatedModuleId: 'db-prisma',
2204
+ });
2205
+ const persistenceGroup = scan.groups.find((group) => group.id === 'auth-persistence');
2206
+
2207
+ assert.ok(persistenceGroup);
2208
+ assert.deepEqual(persistenceGroup.modules, ['jwt-auth', 'db-adapter']);
2209
+ } finally {
2210
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2211
+ }
2212
+ });
2213
+
2214
+ it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
2215
+ const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
2216
+ const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
2217
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2218
+
2219
+ try {
2220
+ scaffoldProject({
2221
+ templateRoot,
2222
+ packageRoot,
2223
+ targetRoot: projectRoot,
2224
+ projectName: 'demo-jwt-nodb-noi18n',
2225
+ frontend: 'react',
2226
+ db: 'prisma',
2227
+ dbPrismaEnabled: false,
2228
+ i18nEnabled: false,
2229
+ proxy: 'caddy',
2230
+ });
2231
+
2232
+ addModule({
2233
+ moduleId: 'logger',
2234
+ targetRoot: projectRoot,
2235
+ packageRoot,
2236
+ });
2237
+ addModule({
2238
+ moduleId: 'jwt-auth',
2239
+ targetRoot: projectRoot,
2240
+ packageRoot,
2241
+ });
2242
+
2243
+ const healthController = fs.readFileSync(
2244
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2245
+ 'utf8',
2246
+ );
2247
+ assert.match(healthController, /constructor\(private readonly authService: AuthService\)/);
2248
+ assert.match(healthController, /@Get\('auth'\)/);
2249
+ assert.match(healthController, /return this\.authService\.getProbeStatus\(\);/);
2250
+
2251
+ const classStart = healthController.indexOf('export class HealthController {');
2252
+ const classEnd = healthController.lastIndexOf('\n}');
2253
+ const authProbe = healthController.indexOf("@Get('auth')");
2254
+ assert.equal(classStart > -1, true);
2255
+ assert.equal(classEnd > classStart, true);
2256
+ assert.equal(authProbe > classStart && authProbe < classEnd, true);
2257
+ } finally {
2258
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2259
+ }
2260
+ });
2261
+
2262
+ it('keeps health controller valid for add sequence jwt-auth -> logger -> swagger -> i18n -> db-prisma on db/i18n-disabled scaffold', () => {
2263
+ const targetRoot = mkTmp('forgeon-module-seq-health-valid-');
2264
+ const projectRoot = path.join(targetRoot, 'demo-seq-health-valid');
2265
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2266
+
2267
+ try {
2268
+ scaffoldProject({
2269
+ templateRoot,
2270
+ packageRoot,
2271
+ targetRoot: projectRoot,
2272
+ projectName: 'demo-seq-health-valid',
2273
+ frontend: 'react',
2274
+ db: 'prisma',
2275
+ dbPrismaEnabled: false,
2276
+ i18nEnabled: false,
2277
+ proxy: 'caddy',
2278
+ });
2279
+
2280
+ for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'i18n', 'db-prisma']) {
2281
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2282
+ }
2283
+
2284
+ const healthController = fs.readFileSync(
2285
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2286
+ 'utf8',
2287
+ );
2288
+
2289
+ const classStart = healthController.indexOf('export class HealthController {');
2290
+ const classEnd = healthController.lastIndexOf('\n}');
2291
+ assert.equal(classStart > -1, true);
2292
+ assert.equal(classEnd > classStart, true);
2293
+
2294
+ const imports = [...healthController.matchAll(/^import\s.+;$/gm)];
2295
+ assert.equal(imports.length > 0, true);
2296
+ for (const importLine of imports) {
2297
+ assert.equal(importLine.index < classStart, true);
2298
+ }
2299
+
2300
+ const authProbe = healthController.indexOf("@Get('auth')");
2301
+ const dbProbe = healthController.indexOf("@Post('db')");
2302
+ const translateMethod = healthController.indexOf('private translate(');
2303
+ assert.equal(authProbe > classStart && authProbe < classEnd, true);
2304
+ assert.equal(dbProbe > classStart && dbProbe < classEnd, true);
2305
+ assert.equal(translateMethod > classStart && translateMethod < classEnd, true);
2306
+
2307
+ assert.match(healthController, /private readonly authService: AuthService/);
2308
+ assert.match(healthController, /private readonly i18n: I18nService/);
2309
+ assert.match(healthController, /private readonly prisma: PrismaService/);
2310
+ } finally {
2311
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2312
+ }
2313
+ });
2314
+
2315
+ it('applies swagger then jwt-auth without forcing swagger dependency in auth-api', () => {
2316
+ const targetRoot = mkTmp('forgeon-module-jwt-swagger-');
2317
+ const projectRoot = path.join(targetRoot, 'demo-jwt-swagger');
2318
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2319
+
2320
+ try {
2321
+ scaffoldProject({
2322
+ templateRoot,
2323
+ packageRoot,
2324
+ targetRoot: projectRoot,
2325
+ projectName: 'demo-jwt-swagger',
2326
+ frontend: 'react',
2327
+ db: 'prisma',
2328
+ dbPrismaEnabled: false,
2329
+ i18nEnabled: false,
2330
+ proxy: 'caddy',
2331
+ });
2332
+
2333
+ addModule({
2334
+ moduleId: 'swagger',
2335
+ targetRoot: projectRoot,
2336
+ packageRoot,
2337
+ });
2338
+ addModule({
2339
+ moduleId: 'jwt-auth',
2340
+ targetRoot: projectRoot,
2341
+ packageRoot,
2342
+ });
2343
+
2344
+ const authApiPackage = JSON.parse(
2345
+ fs.readFileSync(path.join(projectRoot, 'packages', 'auth-api', 'package.json'), 'utf8'),
2346
+ );
2347
+ assert.equal(Object.hasOwn(authApiPackage.dependencies ?? {}, '@nestjs/swagger'), false);
2348
+ } finally {
2349
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2350
+ }
2351
+ });
2352
+
2353
+ it('keeps rate-limit wiring valid after mixed module installation order', () => {
2354
+ const targetRoot = mkTmp('forgeon-module-rate-limit-order-');
2355
+ const projectRoot = path.join(targetRoot, 'demo-rate-limit-order');
2356
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2357
+
2358
+ try {
2359
+ scaffoldProject({
2360
+ templateRoot,
2361
+ packageRoot,
2362
+ targetRoot: projectRoot,
2363
+ projectName: 'demo-rate-limit-order',
2364
+ frontend: 'react',
2365
+ db: 'prisma',
2366
+ dbPrismaEnabled: false,
2367
+ i18nEnabled: false,
2368
+ proxy: 'caddy',
2369
+ });
2370
+
2371
+ for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'rate-limit', 'i18n', 'db-prisma']) {
2372
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2373
+ }
2374
+
2375
+ assertRateLimitWiring(projectRoot);
2376
+
2377
+ const healthController = fs.readFileSync(
2378
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2379
+ 'utf8',
2380
+ );
2381
+ const classStart = healthController.indexOf('export class HealthController {');
2382
+ const classEnd = healthController.lastIndexOf('\n}');
2383
+ const rateLimitProbe = healthController.indexOf("@Get('rate-limit')");
2384
+ assert.equal(rateLimitProbe > classStart && rateLimitProbe < classEnd, true);
2385
+ } finally {
2386
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2387
+ }
2388
+ });
2389
+
2390
+ it('keeps rbac wiring valid after mixed module installation order', () => {
2391
+ const targetRoot = mkTmp('forgeon-module-rbac-order-');
2392
+ const projectRoot = path.join(targetRoot, 'demo-rbac-order');
2393
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2394
+
2395
+ try {
2396
+ scaffoldProject({
2397
+ templateRoot,
2398
+ packageRoot,
2399
+ targetRoot: projectRoot,
2400
+ projectName: 'demo-rbac-order',
2401
+ frontend: 'react',
2402
+ db: 'prisma',
2403
+ dbPrismaEnabled: false,
2404
+ i18nEnabled: false,
2405
+ proxy: 'caddy',
2406
+ });
2407
+
2408
+ for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
2409
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2410
+ }
2411
+
2412
+ assertRbacWiring(projectRoot);
2413
+
2414
+ const healthController = fs.readFileSync(
2415
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
2416
+ 'utf8',
2417
+ );
2418
+ const classStart = healthController.indexOf('export class HealthController {');
2419
+ const classEnd = healthController.lastIndexOf('\n}');
2420
+ const rbacProbe = healthController.indexOf("@Get('rbac')");
2421
+ assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
2422
+ } finally {
2423
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2424
+ }
2425
+ });
2426
+
2427
+ it('keeps db-prisma wiring across module installation orders', () => {
2428
+ const sequences = [
2429
+ ['logger', 'swagger', 'i18n'],
2430
+ ['swagger', 'i18n', 'logger'],
2431
+ ['i18n', 'logger', 'swagger'],
2432
+ ];
2433
+
2434
+ for (const sequence of sequences) {
2435
+ const targetRoot = mkTmp(`forgeon-module-db-order-${sequence.join('-')}-`);
2436
+ const projectRoot = path.join(targetRoot, `demo-db-${sequence.join('-')}`);
2437
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2438
+
2439
+ try {
2440
+ scaffoldProject({
2441
+ templateRoot,
2442
+ packageRoot,
2443
+ targetRoot: projectRoot,
2444
+ projectName: `demo-db-${sequence.join('-')}`,
2445
+ frontend: 'react',
2446
+ db: 'prisma',
2447
+ dbPrismaEnabled: true,
2448
+ i18nEnabled: false,
2449
+ proxy: 'caddy',
2450
+ });
2451
+
2452
+ for (const moduleId of sequence) {
2453
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
2454
+ }
2455
+
2456
+ assertDbPrismaWiring(projectRoot);
2457
+ } finally {
2458
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2459
+ }
2460
+ }
2461
+ });
2462
+
2463
+ it('applies db-prisma as final module after other modules', () => {
2464
+ const targetRoot = mkTmp('forgeon-module-db-last-');
2465
+ const projectRoot = path.join(targetRoot, 'demo-db-last');
2466
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
2467
+
2468
+ try {
2469
+ scaffoldProject({
2470
+ templateRoot,
2471
+ packageRoot,
2472
+ targetRoot: projectRoot,
2473
+ projectName: 'demo-db-last',
2474
+ frontend: 'react',
2475
+ db: 'prisma',
2476
+ dbPrismaEnabled: true,
2477
+ i18nEnabled: false,
2478
+ proxy: 'caddy',
2479
+ });
2480
+
2481
+ stripDbPrismaArtifacts(projectRoot);
2482
+
2483
+ addModule({ moduleId: 'logger', targetRoot: projectRoot, packageRoot });
2484
+ addModule({ moduleId: 'swagger', targetRoot: projectRoot, packageRoot });
2485
+ addModule({ moduleId: 'i18n', targetRoot: projectRoot, packageRoot });
2486
+ const dbResult = addModule({ moduleId: 'db-prisma', targetRoot: projectRoot, packageRoot });
2487
+ assert.equal(dbResult.applied, true);
2488
+
2489
+ assertDbPrismaWiring(projectRoot);
2490
+
2491
+ const moduleDoc = fs.readFileSync(dbResult.docsPath, 'utf8');
2492
+ assert.match(moduleDoc, /db-adapter/);
2493
+ assert.match(moduleDoc, /current canonical implementation for `db-adapter`/);
2494
+
2495
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
2496
+ assert.match(appModule, /ForgeonLoggerModule/);
2497
+ assert.match(appModule, /ForgeonSwaggerModule/);
2498
+ assert.match(appModule, /ForgeonI18nModule/);
2499
+ } finally {
2500
+ fs.rmSync(targetRoot, { recursive: true, force: true });
2501
+ }
2502
+ });
2503
+ });
2504
+
2445
2505