@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.
- package/package.json +1 -1
- package/template/.env.example +3 -0
- package/template/_package.json +7 -3
- package/template/docs/FORMS.md +87 -18
- package/template/docs/FORMS_CHECKLIST.md +36 -26
- package/template/docs/REPORTS.md +22 -4
- package/template/docs/REPORTS_CHECKLIST.md +150 -95
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
- package/template/prisma/schema.prisma +13 -6
- package/template/scripts/push-report.ts +123 -0
- package/template/src/app.module.ts +4 -3
- package/template/src/common/swagger/api-error-responses.ts +8 -2
- package/template/src/config/env.validation.ts +3 -0
- package/template/src/config/forms.config.ts +1 -0
- package/template/src/config/reports.config.ts +2 -0
- package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
- package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
- package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
- package/template/src/modules/forms/forms.module.ts +12 -2
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
- package/template/src/modules/settings/types/setting-definitions.ts +4 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +42 -20
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-submissions.e2e-spec.ts +2 -11
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +150 -8
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +13 -0
- package/template/test/reports-query.e2e-spec.ts +52 -0
- package/template/test/reports-tiers.e2e-spec.ts +106 -20
- 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: {
|
|
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: {
|
|
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({
|
|
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: {
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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: {
|
|
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
|
|
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: '
|
|
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
|
+
});
|