@elytracms/forms 0.0.0-stage → 0.0.95

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +28 -0
  3. package/dist/index.js +24 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/next/index.d.ts +20 -0
  6. package/dist/next/index.js +19 -0
  7. package/dist/next/index.js.map +1 -0
  8. package/dist/next/routes.d.ts +145 -0
  9. package/dist/next/routes.js +214 -0
  10. package/dist/next/routes.js.map +1 -0
  11. package/dist/next/submit.d.ts +83 -0
  12. package/dist/next/submit.js +137 -0
  13. package/dist/next/submit.js.map +1 -0
  14. package/dist/shared/delivery.d.ts +154 -0
  15. package/dist/shared/delivery.js +218 -0
  16. package/dist/shared/delivery.js.map +1 -0
  17. package/dist/shared/kind.d.ts +120 -0
  18. package/dist/shared/kind.js +98 -0
  19. package/dist/shared/kind.js.map +1 -0
  20. package/dist/shared/mail.d.ts +35 -0
  21. package/dist/shared/mail.js +47 -0
  22. package/dist/shared/mail.js.map +1 -0
  23. package/dist/shared/source.d.ts +70 -0
  24. package/dist/shared/source.js +40 -0
  25. package/dist/shared/source.js.map +1 -0
  26. package/dist/shared/submission.d.ts +83 -0
  27. package/dist/shared/submission.js +146 -0
  28. package/dist/shared/submission.js.map +1 -0
  29. package/dist/studio/appendix.d.ts +11 -0
  30. package/dist/studio/appendix.js +50 -0
  31. package/dist/studio/appendix.js.map +1 -0
  32. package/dist/studio/inbox.d.ts +39 -0
  33. package/dist/studio/inbox.js +211 -0
  34. package/dist/studio/inbox.js.map +1 -0
  35. package/dist/studio/index.d.ts +15 -0
  36. package/dist/studio/index.js +13 -0
  37. package/dist/studio/index.js.map +1 -0
  38. package/dist/studio/lens.d.ts +68 -0
  39. package/dist/studio/lens.js +149 -0
  40. package/dist/studio/lens.js.map +1 -0
  41. package/package.json +75 -3
  42. package/src/convex/forms.convex.test.ts +552 -0
  43. package/src/convex/index.ts +340 -0
  44. package/src/shared/delivery.test.ts +122 -0
  45. package/src/shared/delivery.ts +263 -0
  46. package/src/shared/kind.ts +120 -0
  47. package/src/shared/mail.ts +67 -0
  48. package/src/shared/source.test.ts +83 -0
  49. package/src/shared/source.ts +107 -0
  50. package/src/shared/submission.test.ts +97 -0
  51. package/src/shared/submission.ts +163 -0
  52. package/README.md +0 -3
@@ -0,0 +1,552 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import { makeFunctionReference } from 'convex/server'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
7
+ import { INGEST_RECEIVER_PATH, signedIngestRequest } from '@elytracms/core/ingest'
8
+ import type { IngestReceiverBody } from '@elytracms/core/ingest'
9
+ import { InMemoryRecordStore } from '@elytracms/core/persistence'
10
+ import type { RecordRepository } from '@elytracms/core/persistence'
11
+ import schema from '../../../studio/convex/schema'
12
+ import { defineStudioHttp } from '../../../studio/convex/http'
13
+ import { ConvexPersistenceAdapter } from '../../../studio/src/lib/persistence/convex-adapter'
14
+ import { createIngestRoute } from '../../../next/src/ingest'
15
+ import * as formsModule from './index'
16
+ import { submissionRecordKind } from '../shared/kind'
17
+ import type { DeliveryEntry, SubmissionRecord } from '../shared/kind'
18
+ import { DELIVERY_MAX_ATTEMPTS, FORMS_DELIVERY_URL_ENV } from '../shared/delivery'
19
+ import { FORMS_HONEYPOT_KEY, FORMS_SOURCE_KEY, FORMS_TARGET_FIELD } from '../shared/submission'
20
+ import { defineFormSource } from '../shared/source'
21
+ import { createFormPostRoute, createFormsDeliveryRoute } from '../next/routes'
22
+ import type { FormMail } from '../next/routes'
23
+
24
+ /**
25
+ * EC-613 — the forms addon's Convex half on convex-test, over the ENGINE's schema and receiver
26
+ * (`defineStudioHttp` with the addon's handler registered, exactly the consumer's `http.ts`
27
+ * line): the receiver's submissions and uploads, the scheduled delivery per row (fake timers +
28
+ * `finishAllScheduledFunctions`, the site reached through a stubbed `fetch` that runs the
29
+ * addon's own delivery route with a fake TRANSPORT — no real timer, no real network), the
30
+ * retry, the per-recipient log, the editor's retry, the multi-session write, the uploads'
31
+ * lifecycle, and the kind's contract in both adapters.
32
+ */
33
+
34
+ const SECRET = 'ec613-ingest-secret'
35
+ const DELIVERY_URL = 'https://site.test/api/forms'
36
+ const PROJECT_ID = 'forms-test'
37
+ const ADMIN = 'user_admin'
38
+ const T0 = '2026-10-26T12:00:00.000Z'
39
+
40
+ function env(): Record<string, string | undefined> {
41
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
42
+ }
43
+
44
+ beforeEach(() => {
45
+ vi.useFakeTimers()
46
+ vi.setSystemTime(new Date(T0))
47
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
48
+ env()[FORMS_DELIVERY_URL_ENV] = DELIVERY_URL
49
+ })
50
+ afterEach(() => {
51
+ vi.useRealTimers()
52
+ vi.unstubAllGlobals()
53
+ delete env()['ELYTRA_INGEST_SECRET']
54
+ delete env()[FORMS_DELIVERY_URL_ENV]
55
+ })
56
+
57
+ // --- The harness: the engine's schema + receiver, the addon's module -----------------------
58
+
59
+ const silentAuth = { addHttpRoutes: () => {} }
60
+ // convex-test finds the module root by the `_generated` key; the consumer's two files are
61
+ // `forms.ts` (the one re-export line) and `http.ts` (the handler registered).
62
+ const modules = {
63
+ '/convex/_generated/api.ts': async () => ({}),
64
+ '/convex/forms.ts': async () => formsModule,
65
+ '/convex/http.ts': async () => ({
66
+ default: defineStudioHttp({ auth: silentAuth, ingest: { submission: formsModule.formsIngest } }),
67
+ }),
68
+ }
69
+
70
+ async function backend() {
71
+ const t = convexTest(schema, modules)
72
+ await t.run(async (ctx) => {
73
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: T0, updatedAt: T0 })
74
+ })
75
+ return t
76
+ }
77
+ type Backend = Awaited<ReturnType<typeof backend>>
78
+
79
+ async function post(t: Backend, body: IngestReceiverBody) {
80
+ const signed = await signedIngestRequest(body, SECRET)
81
+ return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
82
+ }
83
+
84
+ async function rows(t: Backend): Promise<SubmissionRecord[]> {
85
+ return (await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === 'submission'))).map(
86
+ (row) => ({ ...(row.data as object), id: row.recordId }) as unknown as SubmissionRecord,
87
+ )
88
+ }
89
+
90
+ async function scheduled(t: Backend) {
91
+ return t.run(async (ctx) =>
92
+ (await ctx.db.system.query('_scheduled_functions').collect()).map((job) => ({
93
+ name: job.name,
94
+ args: job.args[0] as Record<string, unknown>,
95
+ state: job.state.kind,
96
+ })),
97
+ )
98
+ }
99
+
100
+ /** Store a file as an upload URL would (convex-test records size only; the real upload records the type). */
101
+ async function storeFile(t: Backend, bytes: number, contentType: string): Promise<string> {
102
+ return t.run(async (ctx) => {
103
+ const id = await ctx.storage.store(new Blob([new Uint8Array(bytes)], { type: contentType }))
104
+ await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
105
+ return id
106
+ })
107
+ }
108
+
109
+ // --- The site: the addon's delivery route over a fake transport ----------------------------
110
+
111
+ interface ContactForm {
112
+ internalId: string
113
+ title: string
114
+ recipients: string[]
115
+ fields: { name: string; label: string }[]
116
+ }
117
+ const contactSource = defineFormSource<ContactForm>({
118
+ collection: 'form',
119
+ key: 'internalId',
120
+ title: (form) => form.title,
121
+ recipients: (form) => form.recipients,
122
+ fields: (form) => form.fields,
123
+ })
124
+ const CONTACT = {
125
+ id: 'form-1',
126
+ values: {
127
+ internalId: 'contact',
128
+ title: 'Contact',
129
+ recipients: ['shop@example.test', 'owner@example.test'],
130
+ fields: [{ name: 'email', label: 'E-mail' }, { name: 'message', label: 'Message' }],
131
+ },
132
+ }
133
+
134
+ /** The site, reached by the deployment's `fetch`: the delivery route with a scripted transport. */
135
+ function site(script: (mail: FormMail, call: number) => void = () => undefined) {
136
+ const mails: FormMail[] = []
137
+ let calls = 0
138
+ const route = createFormsDeliveryRoute<ContactForm>({
139
+ secret: SECRET,
140
+ source: contactSource,
141
+ forms: async () => [CONTACT],
142
+ transport: async (mail) => {
143
+ calls += 1
144
+ script(mail, calls)
145
+ mails.push(mail)
146
+ return { id: `msg_${calls}` }
147
+ },
148
+ })
149
+ const fetches: string[] = []
150
+ vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {
151
+ fetches.push(url)
152
+ return route.POST(new Request(url, init))
153
+ })
154
+ return { mails, fetches }
155
+ }
156
+
157
+ const SUBMISSION = {
158
+ kind: 'submission',
159
+ target: 'contact',
160
+ data: {
161
+ message: 'Hello, do you deliver?',
162
+ email: 'ada@example.test',
163
+ [FORMS_SOURCE_KEY]: 'https://site.test/contact',
164
+ [FORMS_HONEYPOT_KEY]: '',
165
+ },
166
+ } as const
167
+
168
+ const runAll = (t: Backend) => t.finishAllScheduledFunctions(vi.runAllTimers)
169
+ const log = (row: SubmissionRecord): DeliveryEntry[] => row.deliveryLog ?? []
170
+
171
+ describe('a submission through the receiver (EC-613 on EC-617)', () => {
172
+ it('lands a row, schedules ONE delivery; drained, the site mails every recipient and the log says so', async () => {
173
+ const t = await backend()
174
+ const { mails } = site()
175
+ const answer = await post(t, SUBMISSION)
176
+ expect(answer.status).toBe(200)
177
+ const [row] = await rows(t)
178
+ expect(row).toMatchObject({
179
+ form: 'contact',
180
+ status: 'new',
181
+ spam: 'clean',
182
+ deliveryState: 'pending',
183
+ sourceUrl: 'https://site.test/contact',
184
+ submittedAt: T0,
185
+ payload: [
186
+ { name: 'email', value: 'ada@example.test' },
187
+ { name: 'message', value: 'Hello, do you deliver?' },
188
+ ],
189
+ })
190
+ expect(await answer.json()).toEqual({ ok: true, id: row!.id })
191
+ // Control (the critic's): not drained, the delivery is scheduled and has not run.
192
+ expect(await scheduled(t)).toEqual([{ name: 'forms:deliver', args: { submissionId: row!.id, attempt: 1 }, state: 'pending' }])
193
+ expect(mails).toEqual([])
194
+
195
+ await runAll(t)
196
+ const [delivered] = await rows(t)
197
+ expect(delivered!.deliveryState).toBe('sent')
198
+ expect(log(delivered!).map((line) => [line.to, line.ok, line.id])).toEqual([
199
+ ['shop@example.test', true, 'msg_1'],
200
+ ['owner@example.test', true, 'msg_2'],
201
+ ])
202
+ expect(mails.map((mail) => mail.to)).toEqual(['shop@example.test', 'owner@example.test'])
203
+ expect(mails[0]).toMatchObject({
204
+ subject: 'New submission: Contact',
205
+ replyTo: 'ada@example.test',
206
+ form: { key: 'contact', title: 'Contact' },
207
+ })
208
+ expect(mails[0]!.text.split('\n').slice(0, 2)).toEqual(['E-mail: ada@example.test', 'Message: Hello, do you deliver?'])
209
+ })
210
+
211
+ it('one recipient fails: only it is retried after a minute; the other is mailed exactly once', async () => {
212
+ const t = await backend()
213
+ const { mails } = site((mail, call) => {
214
+ if (mail.to === 'owner@example.test' && call === 2) throw new Error('451 mailbox busy')
215
+ })
216
+ await post(t, SUBMISSION)
217
+ vi.advanceTimersByTime(0)
218
+ await t.finishInProgressScheduledFunctions()
219
+ const [afterFirst] = await rows(t)
220
+ expect(afterFirst!.deliveryState).toBe('pending')
221
+ expect(log(afterFirst!).map((line) => [line.to, line.ok, line.error, line.retryAt])).toEqual([
222
+ ['shop@example.test', true, undefined, undefined],
223
+ ['owner@example.test', false, '451 mailbox busy', '2026-10-26T12:01:00.000Z'],
224
+ ])
225
+ expect((await scheduled(t)).filter((job) => job.state === 'pending')).toEqual([
226
+ { name: 'forms:deliver', args: { submissionId: afterFirst!.id, attempt: 2 }, state: 'pending' },
227
+ ])
228
+
229
+ await runAll(t)
230
+ const [done] = await rows(t)
231
+ expect(done!.deliveryState).toBe('sent')
232
+ expect(mails.filter((mail) => mail.to === 'shop@example.test')).toHaveLength(1)
233
+ expect(mails.filter((mail) => mail.to === 'owner@example.test')).toHaveLength(1)
234
+ })
235
+
236
+ it('a site that stays down: every attempt logged, then FAILED — a visible issue on a row that exists', async () => {
237
+ const t = await backend()
238
+ let tries = 0
239
+ vi.stubGlobal('fetch', async () => {
240
+ tries += 1
241
+ throw new TypeError('fetch failed: ECONNREFUSED')
242
+ })
243
+ await post(t, SUBMISSION)
244
+ await runAll(t)
245
+ const [row] = await rows(t)
246
+ expect(tries).toBe(DELIVERY_MAX_ATTEMPTS)
247
+ expect(row!.deliveryState).toBe('failed')
248
+ expect(log(row!).map((line) => line.attempt)).toEqual([1, 2, 3, 4, 5])
249
+ expect(log(row!).every((line) => !line.ok && /ECONNREFUSED/.test(line.error ?? ''))).toBe(true)
250
+ expect(log(row!).at(-1)!.retryAt).toBeUndefined()
251
+ })
252
+
253
+ it('no delivery route configured → failed at once, naming the variable; nothing is fetched', async () => {
254
+ delete env()[FORMS_DELIVERY_URL_ENV]
255
+ const t = await backend()
256
+ const { fetches } = site()
257
+ await post(t, SUBMISSION)
258
+ await runAll(t)
259
+ const [row] = await rows(t)
260
+ expect(row!.deliveryState).toBe('failed')
261
+ expect(log(row!)[0]!.error).toMatch(/ELYTRA_FORMS_DELIVERY_URL/)
262
+ expect(fetches).toEqual([])
263
+ })
264
+
265
+ it('a form the site does not know → 404 → failed, not retried (content, not the network)', async () => {
266
+ const t = await backend()
267
+ const { fetches } = site()
268
+ await post(t, { ...SUBMISSION, target: 'retired-form' })
269
+ await runAll(t)
270
+ const [row] = await rows(t)
271
+ expect(row!.deliveryState).toBe('failed')
272
+ expect(log(row!)[0]!.error).toMatch(/HTTP 404: No published form “retired-form”/)
273
+ expect(fetches).toHaveLength(1)
274
+ })
275
+
276
+ it('a filled honeypot: the visitor gets the same 200, the row is kept as spam, nothing is delivered', async () => {
277
+ const t = await backend()
278
+ const { fetches } = site()
279
+ const answer = await post(t, { ...SUBMISSION, data: { ...SUBMISSION.data, [FORMS_HONEYPOT_KEY]: 'http://buy.test' } })
280
+ expect(answer.status).toBe(200)
281
+ const [row] = await rows(t)
282
+ expect(row).toMatchObject({ spam: 'honeypot', status: 'spam', deliveryState: 'skipped' })
283
+ expect(await scheduled(t)).toEqual([])
284
+ await runAll(t)
285
+ expect(fetches).toEqual([])
286
+ })
287
+
288
+ it('an invalid payload is refused 422 with its issues, and nothing is stored or scheduled', async () => {
289
+ const t = await backend()
290
+ const answer = await post(t, { kind: 'submission', target: 'contact', data: { nested: { no: 1 } } })
291
+ expect(answer.status).toBe(422)
292
+ expect(await answer.json()).toMatchObject({ issues: [{ path: ['data', 'nested'] }] })
293
+ expect(await rows(t)).toEqual([])
294
+ expect(await scheduled(t)).toEqual([])
295
+ })
296
+
297
+ it('the stored row measures far below Convex’s 16-level ceiling', async () => {
298
+ const t = await backend()
299
+ site()
300
+ await post(t, { ...SUBMISSION, data: { ...SUBMISSION.data, topics: ['a', 'b'] } })
301
+ await runAll(t)
302
+ const stored = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((row) => row.kind === 'submission'))
303
+ const { _id: _ignored, _creationTime: _time, ...document } = stored as Record<string, unknown>
304
+ expect(measureMaxDepth(document).depth).toBeLessThanOrEqual(6)
305
+ expect(measureMaxDepth(document).depth).toBeLessThan(CONVEX_MAX_NESTING_DEPTH)
306
+ })
307
+ })
308
+
309
+ // --- The studio's writes ---------------------------------------------------------------------
310
+
311
+ function adapterClient(t: Backend): ConvexHttpClient {
312
+ const asAdmin = t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
313
+ return {
314
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
315
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin.mutation(ref, args ?? {}),
316
+ } as unknown as ConvexHttpClient
317
+ }
318
+
319
+ describe('the editor’s writes (the kind’s save)', () => {
320
+ it('a retry: moving a failed row INTO pending schedules a new run; a save that keeps the state schedules nothing', async () => {
321
+ delete env()[FORMS_DELIVERY_URL_ENV]
322
+ const t = await backend()
323
+ await post(t, SUBMISSION)
324
+ await runAll(t)
325
+ const [failed] = await rows(t)
326
+ expect(failed!.deliveryState).toBe('failed')
327
+
328
+ env()[FORMS_DELIVERY_URL_ENV] = DELIVERY_URL
329
+ const { mails } = site()
330
+ const repo = new ConvexPersistenceAdapter(adapterClient(t), PROJECT_ID).records(submissionRecordKind)
331
+ await repo.save(PROJECT_ID, { id: failed!.id, status: 'open' })
332
+ expect((await scheduled(t)).filter((job) => job.state === 'pending')).toEqual([])
333
+ await repo.save(PROJECT_ID, { id: failed!.id, deliveryState: 'pending' })
334
+ expect((await scheduled(t)).filter((job) => job.state === 'pending')).toEqual([
335
+ { name: 'forms:deliver', args: { submissionId: failed!.id, attempt: 1 }, state: 'pending' },
336
+ ])
337
+ await runAll(t)
338
+ const [sent] = await rows(t)
339
+ expect(sent).toMatchObject({ deliveryState: 'sent', status: 'open' })
340
+ expect(mails).toHaveLength(2)
341
+ })
342
+
343
+ it('multi-session: an editor’s change and the delivery’s log merge per column — and the stale whole-record write (control) is what would clobber it', async () => {
344
+ const t = await backend()
345
+ site()
346
+ await post(t, SUBMISSION)
347
+ const repo = new ConvexPersistenceAdapter(adapterClient(t), PROJECT_ID).records(submissionRecordKind)
348
+ const [loaded] = await rows(t)
349
+ // The editor's view is loaded while the delivery is still pending…
350
+ const editorsView = await repo.get(PROJECT_ID, loaded!.id)
351
+ expect(editorsView.deliveryState).toBe('pending')
352
+ // …the delivery runs and logs…
353
+ await runAll(t)
354
+ // …and the inbox saves ONLY the column the editor changed.
355
+ await repo.save(PROJECT_ID, { id: loaded!.id, status: 'done', assignee: 'Grace' })
356
+ const [merged] = await rows(t)
357
+ expect(merged).toMatchObject({ status: 'done', assignee: 'Grace', deliveryState: 'sent' })
358
+ expect(log(merged!)).toHaveLength(2)
359
+
360
+ // Control: the same edit sent as the WHOLE record the editor loaded (the generic rail's
361
+ // write, `lensDocumentToRecordWrite`) puts the stale `pending` back over the delivered row
362
+ // — and so schedules a SECOND delivery (duplicate mail). Which is why the inbox never sends it.
363
+ const { id: _id, projectId: _p, createdAt: _c, updatedAt: _u, ...stale } = editorsView
364
+ await repo.save(PROJECT_ID, { ...stale, id: loaded!.id, status: 'done' })
365
+ const [clobbered] = await rows(t)
366
+ expect(clobbered!.deliveryState).toBe('pending')
367
+ expect((await scheduled(t)).filter((job) => job.state === 'pending')).toEqual([
368
+ { name: 'forms:deliver', args: { submissionId: loaded!.id, attempt: 1 }, state: 'pending' },
369
+ ])
370
+ })
371
+ })
372
+
373
+ // --- Uploads -----------------------------------------------------------------------------------
374
+
375
+ async function declareUpload(t: Backend, contentType: string, size: number, field = 'cv') {
376
+ const answer = await post(t, { kind: 'upload', target: 'contact', field, contentType, size })
377
+ return { status: answer.status, body: (await answer.json()) as { uploadId?: string; uploadUrl?: string } }
378
+ }
379
+
380
+ describe('uploads, claimed by the submission', () => {
381
+ it('a claimed file is on the row (name, type, size, URL), its URL reaches the mail; removing the row deletes it', async () => {
382
+ const t = await backend()
383
+ const { mails } = site()
384
+ const { status, body } = await declareUpload(t, 'application/pdf', 900)
385
+ expect(status).toBe(200)
386
+ expect(typeof body.uploadUrl).toBe('string')
387
+ const storageId = await storeFile(t, 800, 'application/pdf')
388
+ const answer = await post(t, {
389
+ ...SUBMISSION,
390
+ data: { ...SUBMISSION.data, cv: 'lebenslauf.pdf' },
391
+ uploads: [{ uploadId: body.uploadId!, storageId }],
392
+ })
393
+ expect(answer.status).toBe(200)
394
+ const [row] = await rows(t)
395
+ expect(row!.files).toEqual([
396
+ { field: 'cv', name: 'lebenslauf.pdf', storageId, contentType: 'application/pdf', size: 800, url: expect.any(String) },
397
+ ])
398
+ await runAll(t)
399
+ expect(mails[0]!.attachments).toEqual([
400
+ { filename: 'lebenslauf.pdf', contentType: 'application/pdf', size: 800, url: row!.files![0]!.url },
401
+ ])
402
+
403
+ const repo = new ConvexPersistenceAdapter(adapterClient(t), PROJECT_ID).records(submissionRecordKind)
404
+ await repo.remove(PROJECT_ID, row!.id)
405
+ expect(await rows(t)).toEqual([])
406
+ expect(await t.run((ctx) => ctx.storage.get(storageId as never))).toBeNull()
407
+ })
408
+
409
+ it('a claim that fails refuses the submission AND deletes the files it had already claimed', async () => {
410
+ const t = await backend()
411
+ site()
412
+ const good = await declareUpload(t, 'image/png', 500, 'photo')
413
+ const goodFile = await storeFile(t, 400, 'image/png')
414
+ const liar = await declareUpload(t, 'image/png', 500, 'photo')
415
+ const wrongType = await storeFile(t, 10, 'text/html')
416
+ const answer = await post(t, {
417
+ ...SUBMISSION,
418
+ uploads: [
419
+ { uploadId: good.body.uploadId!, storageId: goodFile },
420
+ { uploadId: liar.body.uploadId!, storageId: wrongType },
421
+ ],
422
+ })
423
+ expect(answer.status).toBe(415)
424
+ expect(await rows(t)).toEqual([])
425
+ expect(await t.run((ctx) => ctx.storage.get(goodFile as never))).toBeNull() // claimed, then released
426
+ expect(await t.run((ctx) => ctx.storage.get(wrongType as never))).toBeNull() // EC-617's claim deletes a mismatch
427
+ expect(await scheduled(t)).toEqual([])
428
+ })
429
+
430
+ it('the forms upload policy refuses what a browser would run (HTML, SVG) and anything over 10 MiB', async () => {
431
+ const t = await backend()
432
+ expect((await declareUpload(t, 'text/html', 10)).status).toBe(415)
433
+ expect((await declareUpload(t, 'image/svg+xml', 10)).status).toBe(415)
434
+ expect((await declareUpload(t, 'application/pdf', 10)).status).toBe(200) // control
435
+ })
436
+
437
+ it('unclaimed uploads past their life are listed for the developer (their bytes cannot be located)', async () => {
438
+ const t = await backend()
439
+ await declareUpload(t, 'application/pdf', 10)
440
+ const unclaimed = makeFunctionReference<'query', Record<string, never>, unknown>('forms:unclaimedUploads')
441
+ const query = t.query as unknown as (ref: unknown, args: Record<string, never>) => Promise<unknown>
442
+ const read = () => query(unclaimed, {})
443
+ expect(await read()).toMatchObject({ count: 0 })
444
+ vi.setSystemTime(new Date(Date.parse(T0) + 2 * 60 * 60 * 1000))
445
+ expect(await read()).toMatchObject({
446
+ count: 1,
447
+ uploads: [{ form: 'contact', field: 'cv' }],
448
+ })
449
+ })
450
+ })
451
+
452
+ // --- End to end: the site's no-JS form post → the ingest route → the receiver --------------------
453
+
454
+ describe('the site’s form post, end to end', () => {
455
+ it('a multipart post with a file → 303 ?form=ok, the row carries the file, the delivery mails it', async () => {
456
+ const t = await backend()
457
+ const viaReceiver = (async (url: string, init: RequestInit) => t.fetch(new URL(url).pathname, init)) as unknown as typeof fetch
458
+ const ingest = createIngestRoute({ secret: SECRET, siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
459
+ // The bytes' destination: the issued storage URL (convex-test stores them directly).
460
+ const storeBytes = (async (_url: string, init: RequestInit) => {
461
+ const blob = init.body as Blob
462
+ const storageId = await storeFile(t, blob.size, String(new Headers(init.headers).get('content-type')))
463
+ return Response.json({ storageId })
464
+ }) as unknown as typeof fetch
465
+ const formPost = createFormPostRoute({ ingest, fetchImpl: storeBytes })
466
+
467
+ const form = new FormData()
468
+ form.set(FORMS_TARGET_FIELD, 'contact')
469
+ form.set('email', 'ada@example.test')
470
+ form.set('message', 'Please call back.')
471
+ form.set('website', '')
472
+ form.set('cv', new File([new Uint8Array(300)], 'cv.pdf', { type: 'application/pdf' }))
473
+ const answer = await formPost.POST(
474
+ new Request('https://site.test/api/form', {
475
+ method: 'POST',
476
+ headers: { origin: 'https://site.test', referer: 'https://site.test/contact' },
477
+ body: form,
478
+ }),
479
+ )
480
+ expect(answer.status).toBe(303)
481
+ expect(answer.headers.get('location')).toBe('https://site.test/contact?form=ok')
482
+ const [row] = await rows(t)
483
+ expect(row).toMatchObject({
484
+ form: 'contact',
485
+ sourceUrl: 'https://site.test/contact',
486
+ payload: [
487
+ { name: 'cv', value: 'cv.pdf' },
488
+ { name: 'email', value: 'ada@example.test' },
489
+ { name: 'message', value: 'Please call back.' },
490
+ ],
491
+ files: [{ field: 'cv', name: 'cv.pdf', contentType: 'application/pdf', size: 300 }],
492
+ })
493
+
494
+ const { mails } = site()
495
+ await runAll(t)
496
+ expect(mails.map((mail) => mail.attachments.map((file) => file.filename))).toEqual([['cv.pdf'], ['cv.pdf']])
497
+
498
+ // Control: a post naming no form is refused before anything is sent.
499
+ const bare = new FormData()
500
+ bare.set('email', 'x@example.test')
501
+ const refused = await formPost.POST(new Request('https://site.test/api/form', { method: 'POST', body: bare }))
502
+ expect(refused.headers.get('location')).toBe('https://site.test/?form=invalid')
503
+ expect(await rows(t)).toHaveLength(1)
504
+ })
505
+ })
506
+
507
+ // --- The kind's contract in both adapters --------------------------------------------------------
508
+
509
+ async function contract(repo: RecordRepository<typeof submissionRecordKind>) {
510
+ const base = { payload: [{ name: 'email', value: 'a@x.test' }], spam: 'clean' as const, deliveryState: 'sent' as const }
511
+ await repo.save(PROJECT_ID, { ...base, id: 'submission_a', form: 'contact', submittedAt: '2026-10-01T00:00:00.000Z' })
512
+ await repo.save(PROJECT_ID, { ...base, id: 'submission_b', form: 'jobs', submittedAt: '2026-10-02T00:00:00.000Z' })
513
+ await repo.save(PROJECT_ID, { ...base, id: 'submission_c', form: 'contact', status: 'done', submittedAt: '2026-10-03T00:00:00.000Z' })
514
+ const ids = (list: readonly { id: string }[]) => list.map((record) => record.id)
515
+ const edited = await repo.save(PROJECT_ID, { id: 'submission_a', notes: 'called back' })
516
+ const removedThen = async () => {
517
+ await repo.remove(PROJECT_ID, 'submission_b')
518
+ return ids(await repo.list(PROJECT_ID))
519
+ }
520
+ return {
521
+ all: ids(await repo.list(PROJECT_ID)),
522
+ newestFirst: ids(await repo.list(PROJECT_ID, { order: 'desc' })),
523
+ byForm: ids(await repo.list(PROJECT_ID, { where: { form: 'contact' } })),
524
+ byStatus: ids(await repo.list(PROJECT_ID, { where: { status: 'new' } })),
525
+ byBoth: ids(await repo.list(PROJECT_ID, { where: { form: 'contact', status: 'done' } })),
526
+ edited: { status: edited.status, notes: edited.notes, payload: edited.payload },
527
+ unknownKey: await repo.list(PROJECT_ID, { where: { assignee: 'x' } }).then(
528
+ () => 'accepted',
529
+ (error: Error) => error.name,
530
+ ),
531
+ afterRemove: await removedThen(),
532
+ }
533
+ }
534
+
535
+ describe('the submission kind’s contract — the same answers in both adapters', () => {
536
+ it('in memory and on Convex: create defaults, per-column edit, the two key slots, time order, remove', async () => {
537
+ const memory = await contract(new InMemoryRecordStore().records(submissionRecordKind))
538
+ const t = await backend()
539
+ const convex = await contract(new ConvexPersistenceAdapter(adapterClient(t), PROJECT_ID).records(submissionRecordKind))
540
+ expect(memory).toEqual({
541
+ all: ['submission_a', 'submission_b', 'submission_c'],
542
+ newestFirst: ['submission_c', 'submission_b', 'submission_a'],
543
+ byForm: ['submission_a', 'submission_c'],
544
+ byStatus: ['submission_a', 'submission_b'],
545
+ byBoth: ['submission_c'],
546
+ edited: { status: 'new', notes: 'called back', payload: [{ name: 'email', value: 'a@x.test' }] },
547
+ unknownKey: 'PersistenceValidationError',
548
+ afterRemove: ['submission_a', 'submission_c'],
549
+ })
550
+ expect(convex).toEqual(memory)
551
+ })
552
+ })