@ftisindia/create-app 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +7 -3
  4. package/template/docs/FORMS.md +87 -18
  5. package/template/docs/FORMS_CHECKLIST.md +36 -26
  6. package/template/docs/REPORTS.md +22 -4
  7. package/template/docs/REPORTS_CHECKLIST.md +150 -95
  8. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  9. package/template/prisma/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
  10. package/template/prisma/schema.prisma +13 -6
  11. package/template/scripts/push-report.ts +123 -0
  12. package/template/src/app.module.ts +4 -3
  13. package/template/src/common/swagger/api-error-responses.ts +8 -2
  14. package/template/src/config/env.validation.ts +3 -0
  15. package/template/src/config/forms.config.ts +1 -0
  16. package/template/src/config/reports.config.ts +2 -0
  17. package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
  18. package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
  19. package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
  20. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
  21. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  22. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  23. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  24. package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
  25. package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
  26. package/template/src/modules/forms/forms.module.ts +12 -2
  27. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
  28. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  29. package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
  30. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  31. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  32. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  33. package/template/src/modules/settings/types/setting-definitions.ts +4 -0
  34. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  35. package/template/test/forms-files.e2e-spec.ts +42 -20
  36. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  37. package/template/test/forms-public.e2e-spec.ts +24 -0
  38. package/template/test/forms-submissions.e2e-spec.ts +2 -11
  39. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  40. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  41. package/template/test/jest-e2e.json +1 -0
  42. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  43. package/template/test/reports-query.e2e-spec.ts +52 -0
  44. package/template/test/reports-tiers.e2e-spec.ts +106 -20
  45. package/template/test/route-registry.validator.spec.ts +4 -2
@@ -1,16 +1,19 @@
1
1
  import { INestApplication, ValidationPipe } from '@nestjs/common';
2
2
  import { Test } from '@nestjs/testing';
3
3
  import { ActionRegistry, OutboxHandlerRegistry } from '@ftisindia/form-builder';
4
+ import type { EngineTx } from '@ftisindia/form-builder';
4
5
  import request from 'supertest';
5
6
  import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
7
  import { PrismaService } from '../src/database/prisma/prisma.service';
7
8
  import { AuditService } from '../src/modules/audit/application/services/audit.service';
8
9
  import { OutboxDispatcherService } from '../src/modules/forms/application/services/outbox-dispatcher.service';
10
+ import { PrismaOutboxStore } from '../src/modules/forms/infrastructure/stores/prisma-outbox.store';
9
11
 
10
12
  describe('Forms transactional outbox (e2e)', () => {
11
13
  let app: INestApplication;
12
14
  let prisma: PrismaService;
13
15
  let dispatcher: OutboxDispatcherService;
16
+ let outboxStore: PrismaOutboxStore;
14
17
  let owner: { accessToken: string; userId: string; orgId: string };
15
18
 
16
19
  beforeAll(async () => {
@@ -20,6 +23,7 @@ describe('Forms transactional outbox (e2e)', () => {
20
23
  process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
21
24
  process.env.NODE_ENV = 'test';
22
25
  process.env.FORMS_OUTBOX_ENABLED = 'false';
26
+ process.env.FORMS_OUTBOX_HEARTBEAT_MS = '25';
23
27
  process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
24
28
 
25
29
  const { AppModule } = await import('../src/app.module');
@@ -39,6 +43,7 @@ describe('Forms transactional outbox (e2e)', () => {
39
43
  await app.init();
40
44
  prisma = app.get(PrismaService);
41
45
  dispatcher = app.get(OutboxDispatcherService);
46
+ outboxStore = app.get(PrismaOutboxStore);
42
47
 
43
48
  // Test-only extensions: a handler type that always throws, and a
44
49
  // post-commit action that enqueues a job for it with a tight retry budget.
@@ -48,6 +53,10 @@ describe('Forms transactional outbox (e2e)', () => {
48
53
  return Promise.reject(new Error('boom handler failure'));
49
54
  },
50
55
  });
56
+ app.get(OutboxHandlerRegistry).register({
57
+ type: 'slow-heartbeat',
58
+ handle: () => new Promise((resolve) => setTimeout(resolve, 90)),
59
+ });
51
60
  app.get(ActionRegistry).register({
52
61
  name: 'enqueueBoom',
53
62
  kind: 'post-commit',
@@ -62,7 +71,11 @@ describe('Forms transactional outbox (e2e)', () => {
62
71
  // batch is ordered oldest-first and capped).
63
72
  await prisma.formOutboxJob.updateMany({
64
73
  where: { status: { in: ['PENDING', 'PROCESSING'] } },
65
- data: { status: 'FAILED', lastError: 'parked by forms-outbox e2e setup' },
74
+ data: {
75
+ status: 'FAILED',
76
+ claimedBy: null,
77
+ lastError: 'parked by forms-outbox e2e setup',
78
+ },
66
79
  });
67
80
 
68
81
  owner = await createUserAndOrg('outbox-owner');
@@ -80,7 +93,11 @@ describe('Forms transactional outbox (e2e)', () => {
80
93
  .set('Authorization', `Bearer ${owner.accessToken}`)
81
94
  .send({
82
95
  mode: 'submit',
83
- data: { fullName: 'Outbox Tester', email: 'outbox@example.com', ticketType: 'standard' },
96
+ data: {
97
+ fullName: 'Outbox Tester',
98
+ email: 'outbox@example.com',
99
+ ticketType: 'standard',
100
+ },
84
101
  })
85
102
  .expect(200);
86
103
 
@@ -96,8 +113,11 @@ describe('Forms transactional outbox (e2e)', () => {
96
113
 
97
114
  await dispatcher.runOnce();
98
115
 
99
- const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job!.id } });
116
+ const delivered = await prisma.formOutboxJob.findUnique({
117
+ where: { id: job!.id },
118
+ });
100
119
  expect(delivered?.status).toBe('DONE');
120
+ expect(delivered?.claimedBy).toBeNull();
101
121
 
102
122
  const auditRow = await prisma.auditLog.findFirst({
103
123
  where: { action: 'forms.outbox.delivered', targetId: job!.id },
@@ -121,7 +141,11 @@ describe('Forms transactional outbox (e2e)', () => {
121
141
  .set('Authorization', `Bearer ${owner.accessToken}`)
122
142
  .send({
123
143
  mode: 'submit',
124
- data: { fullName: 'Boom Tester', email: 'boom@example.com', ticketType: 'vip' },
144
+ data: {
145
+ fullName: 'Boom Tester',
146
+ email: 'boom@example.com',
147
+ ticketType: 'vip',
148
+ },
125
149
  })
126
150
  .expect(200);
127
151
 
@@ -134,8 +158,11 @@ describe('Forms transactional outbox (e2e)', () => {
134
158
 
135
159
  // First attempt fails -> retried: back to PENDING with a future runAfter.
136
160
  await dispatcher.runOnce();
137
- const retried = await prisma.formOutboxJob.findUnique({ where: { id: job!.id } });
161
+ const retried = await prisma.formOutboxJob.findUnique({
162
+ where: { id: job!.id },
163
+ });
138
164
  expect(retried?.status).toBe('PENDING');
165
+ expect(retried?.claimedBy).toBeNull();
139
166
  expect(retried?.attempts).toBe(1);
140
167
  expect(retried?.runAfter).not.toBeNull();
141
168
  expect(retried!.runAfter!.getTime()).toBeGreaterThan(Date.now());
@@ -147,8 +174,11 @@ describe('Forms transactional outbox (e2e)', () => {
147
174
  });
148
175
  await dispatcher.runOnce();
149
176
 
150
- const parked = await prisma.formOutboxJob.findUnique({ where: { id: job!.id } });
177
+ const parked = await prisma.formOutboxJob.findUnique({
178
+ where: { id: job!.id },
179
+ });
151
180
  expect(parked?.status).toBe('FAILED');
181
+ expect(parked?.claimedBy).toBeNull();
152
182
  expect(parked?.attempts).toBe(2);
153
183
  });
154
184
 
@@ -165,8 +195,11 @@ describe('Forms transactional outbox (e2e)', () => {
165
195
 
166
196
  await dispatcher.runOnce();
167
197
 
168
- const failed = await prisma.formOutboxJob.findUnique({ where: { id: orphan.id } });
198
+ const failed = await prisma.formOutboxJob.findUnique({
199
+ where: { id: orphan.id },
200
+ });
169
201
  expect(failed?.status).toBe('FAILED');
202
+ expect(failed?.claimedBy).toBeNull();
170
203
  expect(failed?.attempts).toBe(1);
171
204
  expect(failed?.lastError).toMatch(/No handler registered/);
172
205
  });
@@ -188,8 +221,11 @@ describe('Forms transactional outbox (e2e)', () => {
188
221
 
189
222
  await dispatcher.runOnce();
190
223
 
191
- const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job.id } });
224
+ const delivered = await prisma.formOutboxJob.findUnique({
225
+ where: { id: job.id },
226
+ });
192
227
  expect(delivered?.status).toBe('DONE');
228
+ expect(delivered?.claimedBy).toBeNull();
193
229
  expect(delivered?.attempts).toBe(0);
194
230
  expect(delivered?.lastError).toBeNull();
195
231
  spy.mockRestore();
@@ -206,6 +242,7 @@ describe('Forms transactional outbox (e2e)', () => {
206
242
  maxAttempts: 3,
207
243
  orgId: owner.orgId,
208
244
  actorUserId: owner.userId,
245
+ claimedBy: 'old-stale-claim',
209
246
  createdAt: staleTime,
210
247
  updatedAt: staleTime,
211
248
  },
@@ -213,12 +250,233 @@ describe('Forms transactional outbox (e2e)', () => {
213
250
 
214
251
  await dispatcher.runOnce();
215
252
 
216
- const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job.id } });
253
+ const delivered = await prisma.formOutboxJob.findUnique({
254
+ where: { id: job.id },
255
+ });
217
256
  expect(delivered?.status).toBe('DONE');
257
+ expect(delivered?.claimedBy).toBeNull();
218
258
  });
219
259
 
220
260
  // ── Helpers ────────────────────────────────────────────────────────────────
221
261
 
262
+ it('treats duplicate idempotency keys as enqueue no-ops', async () => {
263
+ const idempotencyKey = `dupe-${uniqueSuffix()}`;
264
+ await prisma.$transaction(async (tx) => {
265
+ const engineTx = tx as unknown as EngineTx;
266
+ await outboxStore.enqueue(
267
+ {
268
+ type: 'email',
269
+ payload: { to: 'dupe@example.com', template: 'test' },
270
+ idempotencyKey,
271
+ orgId: owner.orgId,
272
+ actorUserId: owner.userId,
273
+ },
274
+ engineTx,
275
+ );
276
+ await outboxStore.enqueue(
277
+ {
278
+ type: 'email',
279
+ payload: { to: 'dupe@example.com', template: 'test' },
280
+ idempotencyKey,
281
+ orgId: owner.orgId,
282
+ actorUserId: owner.userId,
283
+ },
284
+ engineTx,
285
+ );
286
+ });
287
+
288
+ await expect(prisma.formOutboxJob.count({ where: { idempotencyKey } })).resolves.toBe(1);
289
+ await prisma.formOutboxJob.updateMany({
290
+ where: { idempotencyKey },
291
+ data: {
292
+ status: 'FAILED',
293
+ claimedBy: null,
294
+ lastError: 'parked after duplicate idempotency test',
295
+ },
296
+ });
297
+ });
298
+
299
+ it('heartbeat keeps active PROCESSING jobs from stale reclaim', async () => {
300
+ const claimedBy = `heartbeat-${uniqueSuffix()}`;
301
+ const staleTime = new Date(Date.now() - 10 * 60 * 1000);
302
+ const job = await prisma.formOutboxJob.create({
303
+ data: {
304
+ type: 'email',
305
+ payload: { to: 'heartbeat@example.com', template: 'test' },
306
+ status: 'PROCESSING',
307
+ attempts: 0,
308
+ maxAttempts: 3,
309
+ orgId: owner.orgId,
310
+ actorUserId: owner.userId,
311
+ claimedBy,
312
+ createdAt: staleTime,
313
+ updatedAt: staleTime,
314
+ },
315
+ });
316
+
317
+ await expect(outboxStore.touchProcessing(job.id, claimedBy)).resolves.toBe(true);
318
+
319
+ const claimed = await outboxStore.claimDue(new Date(), 10);
320
+ expect(claimed.some((claimedJob) => claimedJob.id === job.id)).toBe(false);
321
+ });
322
+
323
+ it('guards terminal transitions with the claim token', async () => {
324
+ const jobs = await Promise.all(
325
+ ['done', 'retry', 'failed'].map((label) =>
326
+ prisma.formOutboxJob.create({
327
+ data: {
328
+ type: 'email',
329
+ payload: { to: `${label}@example.com`, template: 'test' },
330
+ status: 'PENDING',
331
+ attempts: 0,
332
+ maxAttempts: 3,
333
+ orgId: owner.orgId,
334
+ actorUserId: owner.userId,
335
+ },
336
+ }),
337
+ ),
338
+ );
339
+
340
+ const claimed = await outboxStore.claimDue(new Date(), 3);
341
+ expect(claimed).toHaveLength(3);
342
+ for (const job of claimed) {
343
+ expect(job.claimedBy).toEqual(expect.any(String));
344
+ }
345
+
346
+ const byId = new Map(claimed.map((job) => [job.id, job]));
347
+ const done = byId.get(jobs[0]!.id)!;
348
+ const retry = byId.get(jobs[1]!.id)!;
349
+ const failed = byId.get(jobs[2]!.id)!;
350
+
351
+ await expect(outboxStore.markDone(done.id, 'wrong-token')).resolves.toBe(false);
352
+ await expect(
353
+ outboxStore.markRetry(
354
+ retry.id,
355
+ 'wrong-token',
356
+ 1,
357
+ new Date(Date.now() + 30_000),
358
+ 'retry later',
359
+ ),
360
+ ).resolves.toBe(false);
361
+ await expect(outboxStore.markFailed(failed.id, 'wrong-token', 1, 'failed')).resolves.toBe(
362
+ false,
363
+ );
364
+
365
+ await expect(outboxStore.markDone(done.id, done.claimedBy!)).resolves.toBe(true);
366
+ await expect(
367
+ outboxStore.markRetry(
368
+ retry.id,
369
+ retry.claimedBy!,
370
+ 1,
371
+ new Date(Date.now() + 30_000),
372
+ 'retry later',
373
+ ),
374
+ ).resolves.toBe(true);
375
+ await expect(outboxStore.markFailed(failed.id, failed.claimedBy!, 1, 'failed')).resolves.toBe(
376
+ true,
377
+ );
378
+
379
+ await expect(prisma.formOutboxJob.findUnique({ where: { id: done.id } })).resolves.toMatchObject(
380
+ { status: 'DONE', claimedBy: null },
381
+ );
382
+ await expect(
383
+ prisma.formOutboxJob.findUnique({ where: { id: retry.id } }),
384
+ ).resolves.toMatchObject({ status: 'PENDING', claimedBy: null, attempts: 1 });
385
+ await expect(
386
+ prisma.formOutboxJob.findUnique({ where: { id: failed.id } }),
387
+ ).resolves.toMatchObject({ status: 'FAILED', claimedBy: null, attempts: 1 });
388
+ });
389
+
390
+ it('prevents an old worker token from settling a reclaimed stale job', async () => {
391
+ const staleTime = new Date(Date.now() - 10 * 60 * 1000);
392
+ const job = await prisma.formOutboxJob.create({
393
+ data: {
394
+ type: 'email',
395
+ payload: { to: 'reclaimed@example.com', template: 'test' },
396
+ status: 'PROCESSING',
397
+ attempts: 0,
398
+ maxAttempts: 3,
399
+ orgId: owner.orgId,
400
+ actorUserId: owner.userId,
401
+ claimedBy: 'old-worker',
402
+ createdAt: staleTime,
403
+ updatedAt: staleTime,
404
+ },
405
+ });
406
+
407
+ const claimed = await outboxStore.claimDue(new Date(), 1);
408
+ expect(claimed).toHaveLength(1);
409
+ expect(claimed[0]!.id).toBe(job.id);
410
+ expect(claimed[0]!.claimedBy).not.toBe('old-worker');
411
+
412
+ await expect(outboxStore.markDone(job.id, 'old-worker')).resolves.toBe(false);
413
+ await expect(outboxStore.markDone(job.id, claimed[0]!.claimedBy!)).resolves.toBe(true);
414
+ await expect(prisma.formOutboxJob.findUnique({ where: { id: job.id } })).resolves.toMatchObject(
415
+ { status: 'DONE', claimedBy: null },
416
+ );
417
+ });
418
+
419
+ it('heartbeats through the dispatcher while a handler is still running', async () => {
420
+ const job = await prisma.formOutboxJob.create({
421
+ data: {
422
+ type: 'slow-heartbeat',
423
+ payload: {},
424
+ status: 'PENDING',
425
+ attempts: 0,
426
+ maxAttempts: 3,
427
+ orgId: owner.orgId,
428
+ actorUserId: owner.userId,
429
+ },
430
+ });
431
+ const heartbeat = jest.spyOn(outboxStore, 'touchProcessing');
432
+
433
+ await dispatcher.runOnce();
434
+
435
+ expect(heartbeat).toHaveBeenCalled();
436
+ expect(heartbeat.mock.calls.some(([id, claimedBy]) => id === job.id && claimedBy)).toBe(true);
437
+ const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job.id } });
438
+ expect(delivered?.status).toBe('DONE');
439
+ expect(delivered?.claimedBy).toBeNull();
440
+ heartbeat.mockRestore();
441
+ });
442
+
443
+ it('does not double-claim due jobs across concurrent claimers', async () => {
444
+ await prisma.formOutboxJob.updateMany({
445
+ where: { status: { in: ['PENDING', 'PROCESSING'] } },
446
+ data: {
447
+ status: 'FAILED',
448
+ claimedBy: null,
449
+ lastError: 'parked before concurrent claim test',
450
+ },
451
+ });
452
+
453
+ await Promise.all(
454
+ Array.from({ length: 10 }, (_, index) =>
455
+ prisma.formOutboxJob.create({
456
+ data: {
457
+ type: 'email',
458
+ payload: { to: `claim-${index}@example.com`, template: 'test' },
459
+ status: 'PENDING',
460
+ attempts: 0,
461
+ maxAttempts: 3,
462
+ orgId: owner.orgId,
463
+ actorUserId: owner.userId,
464
+ },
465
+ }),
466
+ ),
467
+ );
468
+
469
+ const [first, second] = await Promise.all([
470
+ outboxStore.claimDue(new Date(), 5),
471
+ outboxStore.claimDue(new Date(), 5),
472
+ ]);
473
+
474
+ const claimedIds = [...first, ...second].map((job) => job.id);
475
+ expect(claimedIds).toHaveLength(10);
476
+ expect(new Set(claimedIds).size).toBe(10);
477
+ expect([...first, ...second].every((job) => typeof job.claimedBy === 'string')).toBe(true);
478
+ });
479
+
222
480
  function uniqueSuffix() {
223
481
  return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
224
482
  }
@@ -251,7 +509,10 @@ describe('Forms transactional outbox (e2e)', () => {
251
509
  type: 'select',
252
510
  name: 'ticketType',
253
511
  label: 'Ticket type',
254
- validators: { required: true, options: ['standard', 'student', 'vip'] },
512
+ validators: {
513
+ required: true,
514
+ options: ['standard', 'student', 'vip'],
515
+ },
255
516
  reportable: true,
256
517
  },
257
518
  ],
@@ -108,6 +108,30 @@ describe('Forms public/anonymous path (e2e)', () => {
108
108
  expect(errors.some((error) => error.code === 'SUBMISSION_CAP')).toBe(true);
109
109
  });
110
110
 
111
+ it('rejects captcha-enabled public forms when no verifier is configured', async () => {
112
+ const key = uniqueFormKey('pub-captcha-missing');
113
+ const created = await request(app.getHttpServer())
114
+ .post(`/organisations/${owner.orgId}/forms`)
115
+ .set('Authorization', `Bearer ${owner.accessToken}`)
116
+ .send({
117
+ definition: {
118
+ ...registrationDefinition(key),
119
+ settings: { access: 'public', captcha: true },
120
+ },
121
+ })
122
+ .expect(201);
123
+
124
+ const response = await request(app.getHttpServer())
125
+ .post(
126
+ `/organisations/${owner.orgId}/forms/${key}/versions/${created.body.version as number}/publish`,
127
+ )
128
+ .set('Authorization', `Bearer ${owner.accessToken}`)
129
+ .expect(422);
130
+
131
+ const issues = response.body.error.details.issues as Array<{ code: string }>;
132
+ expect(issues.map((issue) => issue.code)).toContain('CAPTCHA_NOT_CONFIGURED');
133
+ });
134
+
111
135
  it('runs the login milestone: anonymous authenticate with full redaction', async () => {
112
136
  await setOrgSetting(owner.accessToken, owner.orgId, 'forms.allowedDangerousActions', [
113
137
  'authenticate',
@@ -1,6 +1,6 @@
1
1
  import { INestApplication, ValidationPipe } from '@nestjs/common';
2
2
  import { Test } from '@nestjs/testing';
3
- import { ActionRegistry, DataSourceRegistry } from '@ftisindia/form-builder';
3
+ import { ActionRegistry } from '@ftisindia/form-builder';
4
4
  import request from 'supertest';
5
5
  import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
6
  import { PrismaService } from '../src/database/prisma/prisma.service';
@@ -51,15 +51,6 @@ describe('Forms submissions engine flow (e2e)', () => {
51
51
  execute: (ctx) => Promise.resolve({ ...ctx.data }),
52
52
  });
53
53
 
54
- const dataSources = app.get(DataSourceRegistry);
55
- dataSources.register({
56
- key: 'conference-tracks',
57
- fetch: () => Promise.resolve([
58
- { value: 'ai', label: 'Artificial Intelligence' },
59
- { value: 'ml', label: 'Machine Learning' },
60
- ]),
61
- });
62
-
63
54
  owner = await createUserAndOrg('sub-owner');
64
55
  });
65
56
 
@@ -263,7 +254,7 @@ describe('Forms submissions engine flow (e2e)', () => {
263
254
  await request(app.getHttpServer())
264
255
  .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
265
256
  .set('Authorization', `Bearer ${owner.accessToken}`)
266
- .send({ mode: 'submit', data: { track: 'ai' } })
257
+ .send({ mode: 'submit', data: { track: 'clinical-research' } })
267
258
  .expect(200);
268
259
  });
269
260
 
@@ -0,0 +1,146 @@
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
+
6
+ describe('Forms public route throttling (e2e)', () => {
7
+ let app: INestApplication;
8
+ let owner: { accessToken: string; orgId: string };
9
+ let formKey: string;
10
+
11
+ beforeAll(async () => {
12
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
13
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
14
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
15
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
16
+ process.env.NODE_ENV = 'test';
17
+ process.env.E2E_THROTTLE_ENABLED = 'true';
18
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
19
+
20
+ const { AppModule } = await import('../src/app.module');
21
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
22
+
23
+ app = moduleRef.createNestApplication();
24
+ app.useGlobalFilters(new HttpExceptionFilter());
25
+ app.useGlobalPipes(
26
+ new ValidationPipe({
27
+ forbidNonWhitelisted: true,
28
+ transform: true,
29
+ whitelist: true,
30
+ }),
31
+ );
32
+ await app.init();
33
+
34
+ owner = await createUserAndOrg('throttle-owner');
35
+ formKey = await publishDefinition(publicDefinition(uniqueFormKey('throttle')));
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await app.close();
40
+ delete process.env.E2E_THROTTLE_ENABLED;
41
+ });
42
+
43
+ it('limits anonymous public submissions tighter than the app default', async () => {
44
+ const payload = {
45
+ data: {
46
+ fullName: 'Throttle User',
47
+ email: 'throttle@example.com',
48
+ ticketType: 'standard',
49
+ },
50
+ };
51
+
52
+ for (let i = 0; i < 10; i += 1) {
53
+ await request(app.getHttpServer())
54
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
55
+ .send(payload)
56
+ .expect(200);
57
+ }
58
+
59
+ await request(app.getHttpServer())
60
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
61
+ .send(payload)
62
+ .expect(429);
63
+ });
64
+
65
+ function publicDefinition(key: string): Record<string, unknown> {
66
+ return {
67
+ key,
68
+ version: 1,
69
+ title: 'Throttled public form',
70
+ settings: { access: 'public' },
71
+ fields: [
72
+ {
73
+ type: 'text',
74
+ name: 'fullName',
75
+ validators: { required: true, maxLength: 120 },
76
+ },
77
+ {
78
+ type: 'email',
79
+ name: 'email',
80
+ validators: { required: true },
81
+ },
82
+ {
83
+ type: 'select',
84
+ name: 'ticketType',
85
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
86
+ },
87
+ ],
88
+ actions: {
89
+ submit: ['validateAll', 'persist'],
90
+ saveDraft: ['persistDraft'],
91
+ },
92
+ };
93
+ }
94
+
95
+ async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
96
+ const created = await request(app.getHttpServer())
97
+ .post(`/organisations/${owner.orgId}/forms`)
98
+ .set('Authorization', `Bearer ${owner.accessToken}`)
99
+ .send({ definition })
100
+ .expect(201);
101
+
102
+ await request(app.getHttpServer())
103
+ .post(
104
+ `/organisations/${owner.orgId}/forms/${definition.key as string}/versions/${created.body.version as number}/publish`,
105
+ )
106
+ .set('Authorization', `Bearer ${owner.accessToken}`)
107
+ .expect(200);
108
+
109
+ return definition.key as string;
110
+ }
111
+
112
+ async function createUserAndOrg(label: string) {
113
+ const suffix = uniqueSuffix();
114
+ const signup = await request(app.getHttpServer())
115
+ .post('/auth/signup')
116
+ .send({
117
+ email: `${label}-${suffix}@example.com`,
118
+ password: 'test-password-123',
119
+ displayName: label,
120
+ })
121
+ .expect(201);
122
+
123
+ const accessToken = signup.body.accessToken as string;
124
+ const org = await request(app.getHttpServer())
125
+ .post('/organisations')
126
+ .set('Authorization', `Bearer ${accessToken}`)
127
+ .send({
128
+ name: `${label} ${suffix}`,
129
+ slug: `${label}-${suffix}`,
130
+ })
131
+ .expect(201);
132
+
133
+ return {
134
+ accessToken,
135
+ orgId: org.body.organisation.id as string,
136
+ };
137
+ }
138
+
139
+ function uniqueSuffix() {
140
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
141
+ }
142
+
143
+ function uniqueFormKey(label: string) {
144
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
145
+ }
146
+ });