@ftisindia/create-app 0.1.5 → 0.2.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 +28 -0
- package/template/README.md +51 -0
- package/template/_gitignore +6 -0
- package/template/_package.json +10 -1
- package/template/docs/FORMS.md +188 -0
- package/template/docs/FORMS_CHECKLIST.md +69 -0
- package/template/docs/REPORTS.md +255 -0
- package/template/docs/REPORTS_CHECKLIST.md +152 -0
- package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
- package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/schema.prisma +289 -0
- package/template/scripts/export-openapi.ts +85 -0
- package/template/scripts/gen-form.mjs +149 -0
- package/template/scripts/push-form.ts +124 -0
- package/template/src/app.module.ts +30 -8
- package/template/src/common/dto/membership-response.dto.ts +1 -0
- package/template/src/common/dto/role-summary.dto.ts +3 -3
- package/template/src/common/dto/user-summary.dto.ts +3 -3
- package/template/src/config/env.validation.ts +28 -0
- package/template/src/config/forms.config.ts +13 -0
- package/template/src/config/index.ts +2 -0
- package/template/src/config/openapi.ts +12 -0
- package/template/src/config/reports-secret.ts +15 -0
- package/template/src/config/reports.config.ts +18 -0
- package/template/src/main.ts +3 -12
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
- package/template/src/modules/access-control/types/permission-key.ts +27 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
- package/template/src/modules/auth/auth.module.ts +3 -1
- package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
- package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
- package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
- package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
- package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
- package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
- package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
- package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
- package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
- package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
- 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 +89 -0
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
- package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
- package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
- package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
- package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
- package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
- package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
- package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
- package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
- package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
- package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
- package/template/src/modules/forms/examples/login.form.json +24 -0
- package/template/src/modules/forms/examples/registration.form.json +44 -0
- package/template/src/modules/forms/forms.module.ts +228 -0
- package/template/src/modules/forms/forms.tokens.ts +6 -0
- package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
- package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
- package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
- package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
- package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
- package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
- package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
- package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
- package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
- package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
- package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
- package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
- package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
- package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
- package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
- package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
- package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
- package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
- package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
- package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
- package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
- package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
- package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
- package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
- package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
- package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
- package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
- package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
- package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
- package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
- package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
- package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
- package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
- package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
- package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
- package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
- package/template/src/modules/reports/examples/org-members.report.json +55 -0
- package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
- package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
- package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
- package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
- package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
- package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
- package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
- package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
- package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
- package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
- package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
- package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
- package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
- package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
- package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
- package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
- package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
- package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
- package/template/src/modules/reports/reports-forms.module.ts +33 -0
- package/template/src/modules/reports/reports.module.ts +335 -0
- package/template/src/modules/reports/reports.tokens.ts +11 -0
- package/template/src/modules/reports/sources/org-members.source.ts +112 -0
- package/template/src/modules/settings/types/setting-definitions.ts +94 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-definitions.e2e-spec.ts +394 -0
- package/template/test/forms-export.e2e-spec.ts +390 -0
- package/template/test/forms-files.e2e-spec.ts +345 -0
- package/template/test/forms-outbox.e2e-spec.ts +570 -0
- package/template/test/forms-permission-sync.spec.ts +27 -0
- package/template/test/forms-public.e2e-spec.ts +293 -0
- package/template/test/forms-schema-check.e2e-spec.ts +65 -0
- package/template/test/forms-submissions.e2e-spec.ts +500 -0
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +403 -0
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +381 -0
- package/template/test/reports-permission-sync.spec.ts +30 -0
- package/template/test/reports-query.e2e-spec.ts +402 -0
- package/template/test/reports-tiers.e2e-spec.ts +343 -0
- package/template/test/route-registry.validator.spec.ts +22 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import { ActionRegistry, OutboxHandlerRegistry } from '@ftisindia/form-builder';
|
|
4
|
+
import type { EngineTx } from '@ftisindia/form-builder';
|
|
5
|
+
import request from 'supertest';
|
|
6
|
+
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
7
|
+
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
8
|
+
import { AuditService } from '../src/modules/audit/application/services/audit.service';
|
|
9
|
+
import { OutboxDispatcherService } from '../src/modules/forms/application/services/outbox-dispatcher.service';
|
|
10
|
+
import { PrismaOutboxStore } from '../src/modules/forms/infrastructure/stores/prisma-outbox.store';
|
|
11
|
+
|
|
12
|
+
describe('Forms transactional outbox (e2e)', () => {
|
|
13
|
+
let app: INestApplication;
|
|
14
|
+
let prisma: PrismaService;
|
|
15
|
+
let dispatcher: OutboxDispatcherService;
|
|
16
|
+
let outboxStore: PrismaOutboxStore;
|
|
17
|
+
let owner: { accessToken: string; userId: string; orgId: string };
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
21
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
|
|
22
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
23
|
+
process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
process.env.FORMS_OUTBOX_ENABLED = 'false';
|
|
26
|
+
process.env.FORMS_OUTBOX_HEARTBEAT_MS = '25';
|
|
27
|
+
process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
|
|
28
|
+
|
|
29
|
+
const { AppModule } = await import('../src/app.module');
|
|
30
|
+
const moduleRef = await Test.createTestingModule({
|
|
31
|
+
imports: [AppModule],
|
|
32
|
+
}).compile();
|
|
33
|
+
|
|
34
|
+
app = moduleRef.createNestApplication();
|
|
35
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
36
|
+
app.useGlobalPipes(
|
|
37
|
+
new ValidationPipe({
|
|
38
|
+
forbidNonWhitelisted: true,
|
|
39
|
+
transform: true,
|
|
40
|
+
whitelist: true,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
await app.init();
|
|
44
|
+
prisma = app.get(PrismaService);
|
|
45
|
+
dispatcher = app.get(OutboxDispatcherService);
|
|
46
|
+
outboxStore = app.get(PrismaOutboxStore);
|
|
47
|
+
|
|
48
|
+
// Test-only extensions: a handler type that always throws, and a
|
|
49
|
+
// post-commit action that enqueues a job for it with a tight retry budget.
|
|
50
|
+
app.get(OutboxHandlerRegistry).register({
|
|
51
|
+
type: 'boom',
|
|
52
|
+
handle: () => {
|
|
53
|
+
return Promise.reject(new Error('boom handler failure'));
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
app.get(OutboxHandlerRegistry).register({
|
|
57
|
+
type: 'slow-heartbeat',
|
|
58
|
+
handle: () => new Promise((resolve) => setTimeout(resolve, 90)),
|
|
59
|
+
});
|
|
60
|
+
app.get(ActionRegistry).register({
|
|
61
|
+
name: 'enqueueBoom',
|
|
62
|
+
kind: 'post-commit',
|
|
63
|
+
execute: async (ctx) => {
|
|
64
|
+
await ctx.enqueue({ type: 'boom', payload: {}, maxAttempts: 2 });
|
|
65
|
+
return { queued: true };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Park stray PENDING jobs left by earlier suites/runs so runOnce() cycles
|
|
70
|
+
// in this suite deterministically claim the jobs created here (the claim
|
|
71
|
+
// batch is ordered oldest-first and capped).
|
|
72
|
+
await prisma.formOutboxJob.updateMany({
|
|
73
|
+
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
|
74
|
+
data: {
|
|
75
|
+
status: 'FAILED',
|
|
76
|
+
claimedBy: null,
|
|
77
|
+
lastError: 'parked by forms-outbox e2e setup',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
owner = await createUserAndOrg('outbox-owner');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await app.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('enqueues atomically with the submission and delivers via runOnce()', async () => {
|
|
89
|
+
const key = await publishDefinition(registrationDefinition(uniqueFormKey('outbox-email')));
|
|
90
|
+
|
|
91
|
+
const response = await request(app.getHttpServer())
|
|
92
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
93
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
94
|
+
.send({
|
|
95
|
+
mode: 'submit',
|
|
96
|
+
data: {
|
|
97
|
+
fullName: 'Outbox Tester',
|
|
98
|
+
email: 'outbox@example.com',
|
|
99
|
+
ticketType: 'standard',
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.expect(200);
|
|
103
|
+
|
|
104
|
+
const submissionId = response.body.submissionId as string;
|
|
105
|
+
expect(submissionId).toBeTruthy();
|
|
106
|
+
|
|
107
|
+
const job = await prisma.formOutboxJob.findFirst({
|
|
108
|
+
where: { idempotencyKey: { contains: submissionId } },
|
|
109
|
+
});
|
|
110
|
+
expect(job).not.toBeNull();
|
|
111
|
+
expect(job?.status).toBe('PENDING');
|
|
112
|
+
expect(job?.type).toBe('email');
|
|
113
|
+
|
|
114
|
+
await dispatcher.runOnce();
|
|
115
|
+
|
|
116
|
+
const delivered = await prisma.formOutboxJob.findUnique({
|
|
117
|
+
where: { id: job!.id },
|
|
118
|
+
});
|
|
119
|
+
expect(delivered?.status).toBe('DONE');
|
|
120
|
+
expect(delivered?.claimedBy).toBeNull();
|
|
121
|
+
|
|
122
|
+
const auditRow = await prisma.auditLog.findFirst({
|
|
123
|
+
where: { action: 'forms.outbox.delivered', targetId: job!.id },
|
|
124
|
+
});
|
|
125
|
+
expect(auditRow).not.toBeNull();
|
|
126
|
+
expect(auditRow?.orgId).toBe(owner.orgId);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('retries a failing handler with backoff, then parks the job at maxAttempts', async () => {
|
|
130
|
+
const key = uniqueFormKey('outbox-boom');
|
|
131
|
+
await publishDefinition({
|
|
132
|
+
...registrationDefinition(key),
|
|
133
|
+
actions: {
|
|
134
|
+
submit: ['validateAll', 'persist', 'enqueueBoom'],
|
|
135
|
+
saveDraft: ['persistDraft'],
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const response = await request(app.getHttpServer())
|
|
140
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
141
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
142
|
+
.send({
|
|
143
|
+
mode: 'submit',
|
|
144
|
+
data: {
|
|
145
|
+
fullName: 'Boom Tester',
|
|
146
|
+
email: 'boom@example.com',
|
|
147
|
+
ticketType: 'vip',
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
.expect(200);
|
|
151
|
+
|
|
152
|
+
const submissionId = response.body.submissionId as string;
|
|
153
|
+
const job = await prisma.formOutboxJob.findFirst({
|
|
154
|
+
where: { type: 'boom', idempotencyKey: { contains: submissionId } },
|
|
155
|
+
});
|
|
156
|
+
expect(job).not.toBeNull();
|
|
157
|
+
expect(job?.maxAttempts).toBe(2);
|
|
158
|
+
|
|
159
|
+
// First attempt fails -> retried: back to PENDING with a future runAfter.
|
|
160
|
+
await dispatcher.runOnce();
|
|
161
|
+
const retried = await prisma.formOutboxJob.findUnique({
|
|
162
|
+
where: { id: job!.id },
|
|
163
|
+
});
|
|
164
|
+
expect(retried?.status).toBe('PENDING');
|
|
165
|
+
expect(retried?.claimedBy).toBeNull();
|
|
166
|
+
expect(retried?.attempts).toBe(1);
|
|
167
|
+
expect(retried?.runAfter).not.toBeNull();
|
|
168
|
+
expect(retried!.runAfter!.getTime()).toBeGreaterThan(Date.now());
|
|
169
|
+
|
|
170
|
+
// Make the retry due, then fail again -> parked as FAILED at maxAttempts.
|
|
171
|
+
await prisma.formOutboxJob.update({
|
|
172
|
+
where: { id: job!.id },
|
|
173
|
+
data: { runAfter: new Date(Date.now() - 1000) },
|
|
174
|
+
});
|
|
175
|
+
await dispatcher.runOnce();
|
|
176
|
+
|
|
177
|
+
const parked = await prisma.formOutboxJob.findUnique({
|
|
178
|
+
where: { id: job!.id },
|
|
179
|
+
});
|
|
180
|
+
expect(parked?.status).toBe('FAILED');
|
|
181
|
+
expect(parked?.claimedBy).toBeNull();
|
|
182
|
+
expect(parked?.attempts).toBe(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('fails immediately when no handler is registered for the job type', async () => {
|
|
186
|
+
const orphan = await prisma.formOutboxJob.create({
|
|
187
|
+
data: {
|
|
188
|
+
type: `no-handler-${uniqueSuffix()}`,
|
|
189
|
+
payload: {},
|
|
190
|
+
status: 'PENDING',
|
|
191
|
+
attempts: 0,
|
|
192
|
+
maxAttempts: 5,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await dispatcher.runOnce();
|
|
197
|
+
|
|
198
|
+
const failed = await prisma.formOutboxJob.findUnique({
|
|
199
|
+
where: { id: orphan.id },
|
|
200
|
+
});
|
|
201
|
+
expect(failed?.status).toBe('FAILED');
|
|
202
|
+
expect(failed?.claimedBy).toBeNull();
|
|
203
|
+
expect(failed?.attempts).toBe(1);
|
|
204
|
+
expect(failed?.lastError).toMatch(/No handler registered/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('does not retry a delivered job when the success audit write fails', async () => {
|
|
208
|
+
const job = await prisma.formOutboxJob.create({
|
|
209
|
+
data: {
|
|
210
|
+
type: 'email',
|
|
211
|
+
payload: { to: 'audit-failure@example.com', template: 'test' },
|
|
212
|
+
status: 'PENDING',
|
|
213
|
+
attempts: 0,
|
|
214
|
+
maxAttempts: 3,
|
|
215
|
+
orgId: owner.orgId,
|
|
216
|
+
actorUserId: owner.userId,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const audit = app.get(AuditService);
|
|
220
|
+
const spy = jest.spyOn(audit, 'write').mockRejectedValueOnce(new Error('audit down'));
|
|
221
|
+
|
|
222
|
+
await dispatcher.runOnce();
|
|
223
|
+
|
|
224
|
+
const delivered = await prisma.formOutboxJob.findUnique({
|
|
225
|
+
where: { id: job.id },
|
|
226
|
+
});
|
|
227
|
+
expect(delivered?.status).toBe('DONE');
|
|
228
|
+
expect(delivered?.claimedBy).toBeNull();
|
|
229
|
+
expect(delivered?.attempts).toBe(0);
|
|
230
|
+
expect(delivered?.lastError).toBeNull();
|
|
231
|
+
spy.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('reclaims stale PROCESSING jobs', async () => {
|
|
235
|
+
const staleTime = new Date(Date.now() - 10 * 60 * 1000);
|
|
236
|
+
const job = await prisma.formOutboxJob.create({
|
|
237
|
+
data: {
|
|
238
|
+
type: 'email',
|
|
239
|
+
payload: { to: 'stale@example.com', template: 'test' },
|
|
240
|
+
status: 'PROCESSING',
|
|
241
|
+
attempts: 0,
|
|
242
|
+
maxAttempts: 3,
|
|
243
|
+
orgId: owner.orgId,
|
|
244
|
+
actorUserId: owner.userId,
|
|
245
|
+
claimedBy: 'old-stale-claim',
|
|
246
|
+
createdAt: staleTime,
|
|
247
|
+
updatedAt: staleTime,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await dispatcher.runOnce();
|
|
252
|
+
|
|
253
|
+
const delivered = await prisma.formOutboxJob.findUnique({
|
|
254
|
+
where: { id: job.id },
|
|
255
|
+
});
|
|
256
|
+
expect(delivered?.status).toBe('DONE');
|
|
257
|
+
expect(delivered?.claimedBy).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
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
|
+
|
|
480
|
+
function uniqueSuffix() {
|
|
481
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function uniqueFormKey(label: string) {
|
|
485
|
+
return `${label}-${uniqueSuffix()}`.toLowerCase();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function registrationDefinition(key: string): Record<string, unknown> {
|
|
489
|
+
return {
|
|
490
|
+
key,
|
|
491
|
+
version: 1,
|
|
492
|
+
title: 'Event registration',
|
|
493
|
+
fields: [
|
|
494
|
+
{
|
|
495
|
+
type: 'text',
|
|
496
|
+
name: 'fullName',
|
|
497
|
+
label: 'Full name',
|
|
498
|
+
validators: { required: true, maxLength: 120 },
|
|
499
|
+
reportable: true,
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
type: 'email',
|
|
503
|
+
name: 'email',
|
|
504
|
+
label: 'Email address',
|
|
505
|
+
validators: { required: true },
|
|
506
|
+
reportable: true,
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
type: 'select',
|
|
510
|
+
name: 'ticketType',
|
|
511
|
+
label: 'Ticket type',
|
|
512
|
+
validators: {
|
|
513
|
+
required: true,
|
|
514
|
+
options: ['standard', 'student', 'vip'],
|
|
515
|
+
},
|
|
516
|
+
reportable: true,
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
actions: {
|
|
520
|
+
submit: ['validateAll', 'persist', 'sendConfirmationEmail'],
|
|
521
|
+
saveDraft: ['persistDraft'],
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
|
|
527
|
+
const created = await request(app.getHttpServer())
|
|
528
|
+
.post(`/organisations/${owner.orgId}/forms`)
|
|
529
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
530
|
+
.send({ definition })
|
|
531
|
+
.expect(201);
|
|
532
|
+
|
|
533
|
+
await request(app.getHttpServer())
|
|
534
|
+
.post(
|
|
535
|
+
`/organisations/${owner.orgId}/forms/${created.body.key as string}/versions/${created.body.version as number}/publish`,
|
|
536
|
+
)
|
|
537
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
538
|
+
.expect(200);
|
|
539
|
+
|
|
540
|
+
return created.body.key as string;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function createUserAndOrg(label: string) {
|
|
544
|
+
const suffix = uniqueSuffix();
|
|
545
|
+
const signup = await request(app.getHttpServer())
|
|
546
|
+
.post('/auth/signup')
|
|
547
|
+
.send({
|
|
548
|
+
email: `${label}-${suffix}@example.com`,
|
|
549
|
+
password: 'test-password-123',
|
|
550
|
+
displayName: label,
|
|
551
|
+
})
|
|
552
|
+
.expect(201);
|
|
553
|
+
|
|
554
|
+
const accessToken = signup.body.accessToken as string;
|
|
555
|
+
const org = await request(app.getHttpServer())
|
|
556
|
+
.post('/organisations')
|
|
557
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
558
|
+
.send({
|
|
559
|
+
name: `${label} ${suffix}`,
|
|
560
|
+
slug: `${label}-${suffix}`,
|
|
561
|
+
})
|
|
562
|
+
.expect(201);
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
accessToken,
|
|
566
|
+
userId: signup.body.user.id as string,
|
|
567
|
+
orgId: org.body.organisation.id as string,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FORM_PERMISSION_KEYS } from '@ftisindia/form-builder';
|
|
2
|
+
import { permissionKeys } from '../src/modules/access-control/types/permission-key';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The template carries the form permission keys as literals (so the as-const
|
|
6
|
+
* tuple stays narrow and gen:module's insertion markers keep working); this
|
|
7
|
+
* test is the mechanical guarantee they can never drift from the engine's
|
|
8
|
+
* FORM_PERMISSION_KEYS export — in either direction.
|
|
9
|
+
*/
|
|
10
|
+
describe('forms permission sync', () => {
|
|
11
|
+
const templateFormKeys = permissionKeys.filter(
|
|
12
|
+
(key) =>
|
|
13
|
+
key.startsWith('forms.') ||
|
|
14
|
+
key.startsWith('formSubmissions.') ||
|
|
15
|
+
key.startsWith('formDataSources.'),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
it('contains every engine form permission key', () => {
|
|
19
|
+
for (const key of FORM_PERMISSION_KEYS) {
|
|
20
|
+
expect(permissionKeys).toContain(key);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('has no form keys the engine does not declare', () => {
|
|
25
|
+
expect([...templateFormKeys].sort()).toEqual([...FORM_PERMISSION_KEYS].sort());
|
|
26
|
+
});
|
|
27
|
+
});
|