@ftisindia/create-app 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -0,0 +1,269 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import request from 'supertest';
4
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
5
+ import { PrismaService } from '../src/database/prisma/prisma.service';
6
+
7
+ describe('Forms public/anonymous path (e2e)', () => {
8
+ let app: INestApplication;
9
+ let prisma: PrismaService;
10
+ let owner: { accessToken: string; userId: string; email: string; orgId: string };
11
+
12
+ beforeAll(async () => {
13
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
14
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
15
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
16
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
17
+ process.env.NODE_ENV = 'test';
18
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
19
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
20
+
21
+ const { AppModule } = await import('../src/app.module');
22
+ const moduleRef = await Test.createTestingModule({
23
+ imports: [AppModule],
24
+ }).compile();
25
+
26
+ app = moduleRef.createNestApplication();
27
+ app.useGlobalFilters(new HttpExceptionFilter());
28
+ app.useGlobalPipes(
29
+ new ValidationPipe({
30
+ forbidNonWhitelisted: true,
31
+ transform: true,
32
+ whitelist: true,
33
+ }),
34
+ );
35
+ await app.init();
36
+ prisma = app.get(PrismaService);
37
+
38
+ owner = await createUserAndOrg('public-owner');
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await app.close();
43
+ });
44
+
45
+ it('answers 404 for private forms on the anonymous path, then serves them once public', async () => {
46
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('pub-flip')));
47
+
48
+ // Private published form: existence is never revealed anonymously.
49
+ await request(app.getHttpServer())
50
+ .get(`/public/organisations/${owner.orgId}/forms/${key}/render`)
51
+ .expect(404);
52
+ await request(app.getHttpServer())
53
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
54
+ .send({ data: { fullName: 'Anon', email: 'anon@example.com', ticketType: 'standard' } })
55
+ .expect(404);
56
+
57
+ // Owner flips it public.
58
+ await request(app.getHttpServer())
59
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
60
+ .set('Authorization', `Bearer ${owner.accessToken}`)
61
+ .send({ access: 'public' })
62
+ .expect(200);
63
+
64
+ const render = await request(app.getHttpServer())
65
+ .get(`/public/organisations/${owner.orgId}/forms/${key}/render`)
66
+ .expect(200);
67
+ expect(render.body.definition.key).toBe(key);
68
+ expect(render.body.options).toBeDefined();
69
+
70
+ const submitted = await request(app.getHttpServer())
71
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
72
+ .set('User-Agent', 'forms-public-e2e')
73
+ .send({ data: { fullName: 'Anon User', email: 'anon@example.com', ticketType: 'student' } })
74
+ .expect(200);
75
+
76
+ const submissionId = submitted.body.submissionId as string;
77
+ expect(submissionId).toBeTruthy();
78
+
79
+ const row = await prisma.formSubmission.findUnique({ where: { id: submissionId } });
80
+ expect(row).not.toBeNull();
81
+ expect(row?.createdBy).toBeNull();
82
+ expect(row?.ipAddress).toBeTruthy();
83
+ expect(row?.userAgent).toBeTruthy();
84
+ });
85
+
86
+ it('enforces the per-IP daily submission cap on public forms', async () => {
87
+ const key = uniqueFormKey('pub-cap');
88
+ await publishDefinition({
89
+ ...registrationDefinition(key),
90
+ settings: { access: 'public', maxSubmissionsPerIpPerDay: 2 },
91
+ });
92
+
93
+ for (let i = 0; i < 2; i += 1) {
94
+ await request(app.getHttpServer())
95
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
96
+ .send({
97
+ data: { fullName: `Cap ${i}`, email: `cap-${i}@example.com`, ticketType: 'standard' },
98
+ })
99
+ .expect(200);
100
+ }
101
+
102
+ const blocked = await request(app.getHttpServer())
103
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
104
+ .send({ data: { fullName: 'Cap 3', email: 'cap-3@example.com', ticketType: 'standard' } })
105
+ .expect(422);
106
+
107
+ const errors = blocked.body.error.details.errors as Array<{ code: string }>;
108
+ expect(errors.some((error) => error.code === 'SUBMISSION_CAP')).toBe(true);
109
+ });
110
+
111
+ it('runs the login milestone: anonymous authenticate with full redaction', async () => {
112
+ await setOrgSetting(owner.accessToken, owner.orgId, 'forms.allowedDangerousActions', [
113
+ 'authenticate',
114
+ ]);
115
+ const key = await publishDefinition(loginDefinition(uniqueFormKey('pub-login')));
116
+
117
+ const success = await request(app.getHttpServer())
118
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
119
+ .send({ data: { email: owner.email, password: 'test-password-123' } })
120
+ .expect(200);
121
+
122
+ const outputs = success.body.outputs as { authenticate?: { accessToken?: string } };
123
+ expect(outputs.authenticate?.accessToken).toBeTruthy();
124
+
125
+ // Dangerous actions log neither input nor output — only the literal.
126
+ const logRow = await prisma.formActionLog.findFirst({
127
+ where: { orgId: owner.orgId, formKey: key, action: 'authenticate', status: 'OK' },
128
+ orderBy: { createdAt: 'desc' },
129
+ });
130
+ expect(logRow).not.toBeNull();
131
+ expect(logRow?.input).toBe('[REDACTED]');
132
+ expect(logRow?.output).toBe('[REDACTED]');
133
+
134
+ // AuthService throws Nest exceptions — they propagate as-is (401).
135
+ await request(app.getHttpServer())
136
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
137
+ .send({ data: { email: owner.email, password: 'wrong-password-99' } })
138
+ .expect(401);
139
+
140
+ // Execute-time backstop: removing the allowlist entry blocks submission.
141
+ await setOrgSetting(owner.accessToken, owner.orgId, 'forms.allowedDangerousActions', []);
142
+ await request(app.getHttpServer())
143
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
144
+ .send({ data: { email: owner.email, password: 'test-password-123' } })
145
+ .expect(403);
146
+ });
147
+
148
+ // ── Helpers ────────────────────────────────────────────────────────────────
149
+
150
+ function uniqueSuffix() {
151
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
152
+ }
153
+
154
+ function uniqueFormKey(label: string) {
155
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
156
+ }
157
+
158
+ function registrationDefinition(key: string): Record<string, unknown> {
159
+ return {
160
+ key,
161
+ version: 1,
162
+ title: 'Event registration',
163
+ fields: [
164
+ {
165
+ type: 'text',
166
+ name: 'fullName',
167
+ label: 'Full name',
168
+ validators: { required: true, maxLength: 120 },
169
+ reportable: true,
170
+ },
171
+ {
172
+ type: 'email',
173
+ name: 'email',
174
+ label: 'Email address',
175
+ validators: { required: true },
176
+ reportable: true,
177
+ },
178
+ {
179
+ type: 'select',
180
+ name: 'ticketType',
181
+ label: 'Ticket type',
182
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
183
+ reportable: true,
184
+ },
185
+ ],
186
+ actions: {
187
+ submit: ['validateAll', 'persist'],
188
+ saveDraft: ['persistDraft'],
189
+ },
190
+ };
191
+ }
192
+
193
+ function loginDefinition(key: string): Record<string, unknown> {
194
+ return {
195
+ key,
196
+ version: 1,
197
+ title: 'Sign in',
198
+ settings: { access: 'public' },
199
+ fields: [
200
+ { type: 'email', name: 'email', label: 'Email address', validators: { required: true } },
201
+ {
202
+ type: 'password',
203
+ name: 'password',
204
+ label: 'Password',
205
+ validators: { required: true, minLength: 8 },
206
+ ui: { widget: 'password' },
207
+ },
208
+ ],
209
+ actions: {
210
+ submit: ['authenticate'],
211
+ },
212
+ };
213
+ }
214
+
215
+ async function setOrgSetting(accessToken: string, orgId: string, key: string, value: unknown) {
216
+ await request(app.getHttpServer())
217
+ .patch(`/organisations/${orgId}/settings`)
218
+ .set('Authorization', `Bearer ${accessToken}`)
219
+ .send({ key, value })
220
+ .expect(200);
221
+ }
222
+
223
+ async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
224
+ const created = await request(app.getHttpServer())
225
+ .post(`/organisations/${owner.orgId}/forms`)
226
+ .set('Authorization', `Bearer ${owner.accessToken}`)
227
+ .send({ definition })
228
+ .expect(201);
229
+
230
+ await request(app.getHttpServer())
231
+ .post(
232
+ `/organisations/${owner.orgId}/forms/${created.body.key as string}/versions/${created.body.version as number}/publish`,
233
+ )
234
+ .set('Authorization', `Bearer ${owner.accessToken}`)
235
+ .expect(200);
236
+
237
+ return created.body.key as string;
238
+ }
239
+
240
+ async function createUserAndOrg(label: string) {
241
+ const suffix = uniqueSuffix();
242
+ const email = `${label}-${suffix}@example.com`;
243
+ const signup = await request(app.getHttpServer())
244
+ .post('/auth/signup')
245
+ .send({
246
+ email,
247
+ password: 'test-password-123',
248
+ displayName: label,
249
+ })
250
+ .expect(201);
251
+
252
+ const accessToken = signup.body.accessToken as string;
253
+ const org = await request(app.getHttpServer())
254
+ .post('/organisations')
255
+ .set('Authorization', `Bearer ${accessToken}`)
256
+ .send({
257
+ name: `${label} ${suffix}`,
258
+ slug: `${label}-${suffix}`,
259
+ })
260
+ .expect(201);
261
+
262
+ return {
263
+ accessToken,
264
+ userId: signup.body.user.id as string,
265
+ email,
266
+ orgId: org.body.organisation.id as string,
267
+ };
268
+ }
269
+ });
@@ -0,0 +1,65 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
4
+ import { PrismaService } from '../src/database/prisma/prisma.service';
5
+ import { FormsSchemaCheckService } from '../src/modules/forms/infrastructure/schema-check/forms-schema-check.service';
6
+
7
+ describe('Forms schema compatibility check (e2e)', () => {
8
+ let app: INestApplication;
9
+ let prisma: PrismaService;
10
+ let schemaCheck: FormsSchemaCheckService;
11
+
12
+ beforeAll(async () => {
13
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
14
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
15
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
16
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
17
+ process.env.NODE_ENV = 'test';
18
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
19
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
20
+
21
+ const { AppModule } = await import('../src/app.module');
22
+ const moduleRef = await Test.createTestingModule({
23
+ imports: [AppModule],
24
+ }).compile();
25
+
26
+ app = moduleRef.createNestApplication();
27
+ app.useGlobalFilters(new HttpExceptionFilter());
28
+ app.useGlobalPipes(
29
+ new ValidationPipe({
30
+ forbidNonWhitelisted: true,
31
+ transform: true,
32
+ whitelist: true,
33
+ }),
34
+ );
35
+ await app.init();
36
+ prisma = app.get(PrismaService);
37
+ schemaCheck = app.get(FormsSchemaCheckService);
38
+ });
39
+
40
+ afterAll(async () => {
41
+ await app.close();
42
+ });
43
+
44
+ it('passes against the migrated database', async () => {
45
+ await expect(schemaCheck.check()).resolves.toBeUndefined();
46
+ });
47
+
48
+ it('fails fast with an actionable message when the schema drifts', async () => {
49
+ // Simulate drift by renaming an engine column. Suites run with
50
+ // --runInBand, so restoring in finally keeps every other suite green.
51
+ await prisma.$executeRawUnsafe(
52
+ 'ALTER TABLE "FormSubmission" RENAME COLUMN "locked" TO "locked_bak"',
53
+ );
54
+ try {
55
+ await expect(schemaCheck.check()).rejects.toThrow(/FormSubmission/);
56
+ } finally {
57
+ await prisma.$executeRawUnsafe(
58
+ 'ALTER TABLE "FormSubmission" RENAME COLUMN "locked_bak" TO "locked"',
59
+ );
60
+ }
61
+
62
+ // The restored schema must pass again.
63
+ await expect(schemaCheck.check()).resolves.toBeUndefined();
64
+ });
65
+ });