@cosmicdrift/kumiko-bundled-features 0.310.0 → 0.312.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.
@@ -1,13 +1,22 @@
1
1
  // document-ingest-foundation — fileRef.created MSP integration test.
2
2
  //
3
3
  // Proves the end-to-end trigger flow: upload → fileRef.created →
4
- // documentIngest.requested, gated on mime/size (files-post-processing
5
- // pattern, kumiko-framework#1497).
4
+ // documentIngest.requested, gated on the mounted provider's mimeTypes/size
5
+ // cap. Mounts small test provider features instead of relying on any
6
+ // hardcoded allowlist/cap — there is none anymore.
6
7
 
7
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ createSystemUser,
13
+ defineFeature,
14
+ type TenantId,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
9
16
  import {
10
17
  createInMemoryFileProvider,
18
+ fileRefEntity,
19
+ fileRefsTable,
11
20
  type InMemoryFileProvider,
12
21
  } from "@cosmicdrift/kumiko-framework/files";
13
22
  import {
@@ -16,12 +25,83 @@ import {
16
25
  TestUsers,
17
26
  unsafeCreateEntityTable,
18
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
29
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
19
30
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
20
31
  import { createConfigFeature } from "../../config";
21
32
  import { createTenantFeature } from "../../tenant/feature";
22
33
  import { tenantEntity } from "../../tenant/schema/tenant";
23
34
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
35
+ import { documentExtractEntity, documentExtractsTable } from "../entity";
36
+ import { documentIngestRequestedPayloadSchema } from "../events";
24
37
  import { documentIngestFoundationFeature } from "../feature";
38
+ import { writeIngestPages } from "../pages";
39
+ import { documentIngestProviderTrigger, EXT_DOCUMENT_INGEST_PROVIDER } from "../providers";
40
+ import { writeDocumentExtractForLiveFileRef } from "../write-document-extract";
41
+
42
+ // Small test providers — mirror kumiko-enterprise's LiteParse shape without
43
+ // pulling it in: A claims pdf/png, B claims docx, each with a deliberately
44
+ // tiny cap so the "oversized" case doesn't need a multi-MB fixture.
45
+ const PROVIDER_A_NAME = "test-provider-a";
46
+ const PROVIDER_A_MAX_BYTES = 200;
47
+ const PROVIDER_B_NAME = "test-provider-b";
48
+ const PROVIDER_B_MAX_BYTES = 500;
49
+ const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
50
+
51
+ const providerAProcessed: Array<{ readonly fileRefId: string }> = [];
52
+ const providerBProcessed: Array<{ readonly fileRefId: string }> = [];
53
+
54
+ const testProviderAFeature = defineFeature("test-document-ingest-provider-a", (r) => {
55
+ r.requires("document-ingest-foundation");
56
+ r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, PROVIDER_A_NAME, {
57
+ mimeTypes: ["application/pdf", "image/png"],
58
+ maxFileBytes: PROVIDER_A_MAX_BYTES,
59
+ });
60
+ r.job(
61
+ "test-provider-a-worker",
62
+ { trigger: documentIngestProviderTrigger(PROVIDER_A_NAME), runIn: "worker" },
63
+ async (rawPayload, ctx) => {
64
+ const payload = documentIngestRequestedPayloadSchema.parse(rawPayload);
65
+ // Idempotent, mirrors LiteParse: a redelivered documentIngest.requested
66
+ // must not create a second extract for the same fileRef.
67
+ const existing = await ctx.db.selectMany(documentExtractsTable, {
68
+ fileRefId: payload.fileRefId,
69
+ });
70
+ if (existing.length === 0) {
71
+ await writeDocumentExtractForLiveFileRef({
72
+ tenantDb: ctx.db,
73
+ actor: ctx.systemUser,
74
+ fileRefId: payload.fileRefId,
75
+ storageKey: payload.storageKey,
76
+ pages: [{ pageNumber: 1, text: "invoice text" }],
77
+ meta: {
78
+ provider: PROVIDER_A_NAME,
79
+ ms: 1,
80
+ needsOcr: false,
81
+ pagesParsed: 1,
82
+ totalPages: 1,
83
+ },
84
+ });
85
+ }
86
+ providerAProcessed.push({ fileRefId: payload.fileRefId });
87
+ },
88
+ );
89
+ });
90
+
91
+ const testProviderBFeature = defineFeature("test-document-ingest-provider-b", (r) => {
92
+ r.requires("document-ingest-foundation");
93
+ r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, PROVIDER_B_NAME, {
94
+ mimeTypes: [DOCX_MIME_TYPE],
95
+ maxFileBytes: PROVIDER_B_MAX_BYTES,
96
+ });
97
+ r.job(
98
+ "test-provider-b-worker",
99
+ { trigger: documentIngestProviderTrigger(PROVIDER_B_NAME), runIn: "worker" },
100
+ async (payload) => {
101
+ providerBProcessed.push({ fileRefId: payload["fileRefId"] as string });
102
+ },
103
+ );
104
+ });
25
105
 
26
106
  let stack: TestStack;
27
107
  let provider: InMemoryFileProvider;
@@ -41,6 +121,19 @@ const pngBytes = new Uint8Array([
41
121
  ...Array(64).fill(0),
42
122
  ]);
43
123
  const textBytes = new TextEncoder().encode("plain text, not a supported mime type");
124
+ const oversizedPdfBytes = new Uint8Array(PROVIDER_A_MAX_BYTES + 100);
125
+ // Real ZIP local-file-header signature — validateFileContent content-verifies
126
+ // docx uploads against it independent of options.accept.
127
+ const docxBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...Array(32).fill(0)]);
128
+
129
+ const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
130
+ entityName: "fileRef",
131
+ });
132
+ const documentExtractExecutorForSeed = createEventStoreExecutor(
133
+ documentExtractsTable,
134
+ documentExtractEntity,
135
+ { entityName: "document-extract" },
136
+ );
44
137
 
45
138
  beforeAll(async () => {
46
139
  provider = createInMemoryFileProvider();
@@ -51,8 +144,13 @@ beforeAll(async () => {
51
144
  createComplianceProfilesFeature(),
52
145
  createTenantLifecycleFeature(),
53
146
  documentIngestFoundationFeature,
147
+ testProviderAFeature,
148
+ testProviderBFeature,
54
149
  ],
55
150
  files: { storageProvider: provider },
151
+ // Distinct prefix: avoids sharing a BullMQ queue namespace with any other
152
+ // setupTestStack call on the same test Redis.
153
+ jobs: { consumerLane: "worker", queueNamePrefix: `document-ingest-foundation-${generateId()}` },
56
154
  });
57
155
  await unsafeCreateEntityTable(stack.db, tenantEntity);
58
156
  });
@@ -63,9 +161,11 @@ afterAll(async () => {
63
161
 
64
162
  beforeEach(async () => {
65
163
  provider.clear();
164
+ providerAProcessed.length = 0;
165
+ providerBProcessed.length = 0;
66
166
  stack.events.reset();
67
167
  await asRawClient(stack.db).unsafe(
68
- `TRUNCATE kumiko_events, kumiko_event_consumers, file_refs RESTART IDENTITY CASCADE`,
168
+ `TRUNCATE kumiko_events, kumiko_event_consumers, file_refs, read_document_extracts RESTART IDENTITY CASCADE`,
69
169
  );
70
170
  await stack.eventDispatcher?.ensureRegistered();
71
171
  });
@@ -104,8 +204,45 @@ async function loadIngestSkippedEvents(): Promise<{ payload: Record<string, unkn
104
204
  return rows as { payload: Record<string, unknown> }[];
105
205
  }
106
206
 
207
+ async function loadIngestRequestedEventsForFileRef(
208
+ fileRefId: string,
209
+ ): Promise<{ payload: Record<string, unknown> }[]> {
210
+ const rows = await asRawClient(stack.db).unsafe(
211
+ `SELECT payload FROM kumiko_events WHERE type = $1 AND payload->>'fileRefId' = $2 ORDER BY id ASC`,
212
+ ["document-ingest-foundation:event:document-ingest-requested", fileRefId],
213
+ );
214
+ return rows as { payload: Record<string, unknown> }[];
215
+ }
216
+
217
+ async function loadIngestSkippedEventsForFileRef(
218
+ fileRefId: string,
219
+ ): Promise<{ payload: Record<string, unknown> }[]> {
220
+ const rows = await asRawClient(stack.db).unsafe(
221
+ `SELECT payload FROM kumiko_events WHERE type = $1 AND payload->>'fileRefId' = $2 ORDER BY id ASC`,
222
+ ["document-ingest-foundation:event:document-ingest-skipped", fileRefId],
223
+ );
224
+ return rows as { payload: Record<string, unknown> }[];
225
+ }
226
+
227
+ async function seedDocumentExtract(tenantId: TenantId, fileRefId: string): Promise<string> {
228
+ const user = createSystemUser(tenantId);
229
+ const tdb = createTenantDb(stack.db, tenantId);
230
+ const result = await documentExtractExecutorForSeed.create(
231
+ {
232
+ fileRefId,
233
+ storageKey: `s3://bucket/${fileRefId}`,
234
+ pages: writeIngestPages([{ pageNumber: 1, text: "invoice text" }]),
235
+ meta: { provider: PROVIDER_A_NAME, ms: 1, needsOcr: false, pagesParsed: 1, totalPages: 1 },
236
+ },
237
+ user,
238
+ tdb,
239
+ );
240
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.message}`);
241
+ return String(result.data.id);
242
+ }
243
+
107
244
  describe("fileRef.created → documentIngest.requested", () => {
108
- test("PDF upload requests ingest with the fileRef pointer, no binary", async () => {
245
+ test("PDF upload requests ingest with the fileRef pointer, no binary, tagged with the winning provider", async () => {
109
246
  const { id, storageKey } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
110
247
 
111
248
  await stack.eventDispatcher?.runOnce();
@@ -118,10 +255,11 @@ describe("fileRef.created → documentIngest.requested", () => {
118
255
  fileName: "invoice.pdf",
119
256
  mimeType: "application/pdf",
120
257
  size: pdfBytes.length,
258
+ provider: PROVIDER_A_NAME,
121
259
  });
122
260
  });
123
261
 
124
- test("image/png upload also requests ingest (Phase-1 mime allowlist)", async () => {
262
+ test("image/png upload also requests ingest — same provider claims both mimeTypes", async () => {
125
263
  await uploadFile("scan.png", pngBytes, "image/png");
126
264
 
127
265
  await stack.eventDispatcher?.runOnce();
@@ -129,7 +267,47 @@ describe("fileRef.created → documentIngest.requested", () => {
129
267
  expect(await loadIngestRequestedEvents()).toHaveLength(1);
130
268
  });
131
269
 
132
- test("unsupported mime type is skipped — no ingest requested, skip is observable", async () => {
270
+ test("provider's job actually receives the request via the where-filtered trigger", async () => {
271
+ const { id } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
272
+
273
+ await waitFor(async () => {
274
+ await stack.eventDispatcher?.runOnce();
275
+ expect(providerAProcessed).toHaveLength(1);
276
+ });
277
+
278
+ expect(providerAProcessed).toEqual([{ fileRefId: id }]);
279
+ });
280
+
281
+ test("two mounted providers stay isolated — a pdf only reaches provider A's job, a docx only reaches provider B's job", async () => {
282
+ const { id: pdfId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
283
+ const { id: docxId } = await uploadFile("report.docx", docxBytes, DOCX_MIME_TYPE);
284
+
285
+ await stack.eventDispatcher?.runOnce();
286
+
287
+ const requested = await loadIngestRequestedEvents();
288
+ expect(requested).toHaveLength(2);
289
+ expect(requested).toEqual(
290
+ expect.arrayContaining([
291
+ expect.objectContaining({
292
+ payload: expect.objectContaining({ fileRefId: pdfId, provider: PROVIDER_A_NAME }),
293
+ }),
294
+ expect.objectContaining({
295
+ payload: expect.objectContaining({ fileRefId: docxId, provider: PROVIDER_B_NAME }),
296
+ }),
297
+ ]),
298
+ );
299
+
300
+ await waitFor(async () => {
301
+ await stack.eventDispatcher?.runOnce();
302
+ expect(providerAProcessed).toHaveLength(1);
303
+ expect(providerBProcessed).toHaveLength(1);
304
+ });
305
+
306
+ expect(providerAProcessed).toEqual([{ fileRefId: pdfId }]);
307
+ expect(providerBProcessed).toEqual([{ fileRefId: docxId }]);
308
+ });
309
+
310
+ test("unsupported mime type is skipped — no provider claims it, no ingest requested", async () => {
133
311
  const { id, storageKey } = await uploadFile("notes.txt", textBytes, "text/plain");
134
312
 
135
313
  await stack.eventDispatcher?.runOnce();
@@ -139,7 +317,7 @@ describe("fileRef.created → documentIngest.requested", () => {
139
317
  expect(skipped).toHaveLength(1);
140
318
  // mimeType not pinned exactly: the upload route/File API append a
141
319
  // charset suffix ("text/plain;charset=utf-8") that isn't this MSP's
142
- // concern — the allowlist-miss is.
320
+ // concern — the no-provider-claims-it miss is.
143
321
  expect(skipped[0]?.payload).toMatchObject({
144
322
  fileRefId: id,
145
323
  storageKey,
@@ -149,38 +327,8 @@ describe("fileRef.created → documentIngest.requested", () => {
149
327
  expect(String(skipped[0]?.payload["mimeType"])).toStartWith("text/plain");
150
328
  });
151
329
 
152
- test("oversized file is skipped before the mime check — no ingest requested, skip is observable", async () => {
153
- // Real uploads can't exceed file-routes.ts' 10mb unconstrained-upload
154
- // default, well below this feature's 25mb domain cap — so an oversized
155
- // fileRef.created is inserted directly (bypassing the upload route) to
156
- // prove the MSP's own size-check fires independently of it.
157
- const oversizedSize = 26 * 1024 * 1024;
158
- await asRawClient(stack.db).unsafe(
159
- `
160
- INSERT INTO kumiko_events
161
- (tenant_id, aggregate_type, aggregate_id, version, type, payload, metadata, created_at, created_by)
162
- VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
163
- `,
164
- [
165
- admin.tenantId,
166
- "fileRef",
167
- "00000000-0000-4000-8000-0000000000ff",
168
- 1,
169
- "fileRef.created",
170
- JSON.stringify({
171
- storageKey: "huge.pdf",
172
- fileName: "huge.pdf",
173
- mimeType: "application/pdf",
174
- size: oversizedSize,
175
- }),
176
- // MSP-apply's ctx.unsafeAppendEvent stamps the new event's actor from
177
- // the TRIGGERING event's metadata.userId (server.ts) — an empty
178
- // metadata object here leaves it undefined and the skip-event insert
179
- // rejects with "UNDEFINED_VALUE".
180
- JSON.stringify({ userId: admin.id }),
181
- admin.id,
182
- ],
183
- );
330
+ test("file over the winning provider's maxFileBytes is skipped as file-too-large", async () => {
331
+ const { id, storageKey } = await uploadFile("huge.pdf", oversizedPdfBytes, "application/pdf");
184
332
 
185
333
  await stack.eventDispatcher?.runOnce();
186
334
 
@@ -188,12 +336,442 @@ describe("fileRef.created → documentIngest.requested", () => {
188
336
  const skipped = await loadIngestSkippedEvents();
189
337
  expect(skipped).toHaveLength(1);
190
338
  expect(skipped[0]?.payload).toEqual({
191
- fileRefId: "00000000-0000-4000-8000-0000000000ff",
192
- storageKey: "huge.pdf",
339
+ fileRefId: id,
340
+ storageKey,
193
341
  fileName: "huge.pdf",
194
342
  mimeType: "application/pdf",
195
- size: oversizedSize,
343
+ size: oversizedPdfBytes.length,
196
344
  reason: "file-too-large",
197
345
  });
198
346
  });
199
347
  });
348
+
349
+ describe("fileRef delete/forget cleanup", () => {
350
+ test("fileRef.deleted forgets the associated documentExtract row, leaving other fileRefs' extracts untouched", async () => {
351
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
352
+ const extractId = await seedDocumentExtract(admin.tenantId, fileRefId);
353
+ const { id: otherFileRefId } = await uploadFile("other.pdf", pdfBytes, "application/pdf");
354
+ const otherExtractId = await seedDocumentExtract(admin.tenantId, otherFileRefId);
355
+
356
+ const token = await stack.jwt.sign(admin);
357
+ const res = await stack.app.request(`/api/files/${fileRefId}`, {
358
+ method: "DELETE",
359
+ headers: { Authorization: `Bearer ${token}` },
360
+ });
361
+ expect(res.status).toBe(200);
362
+
363
+ await stack.eventDispatcher?.runOnce();
364
+
365
+ expect(await selectMany(stack.db, documentExtractsTable, { id: extractId })).toHaveLength(0);
366
+ expect(await selectMany(stack.db, documentExtractsTable, { id: otherExtractId })).toHaveLength(
367
+ 1,
368
+ );
369
+ });
370
+
371
+ test("fileRef.forgotten forgets the associated documentExtract row, leaving other fileRefs' extracts untouched", async () => {
372
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
373
+ const extractId = await seedDocumentExtract(admin.tenantId, fileRefId);
374
+ const { id: otherFileRefId } = await uploadFile("other.pdf", pdfBytes, "application/pdf");
375
+ const otherExtractId = await seedDocumentExtract(admin.tenantId, otherFileRefId);
376
+
377
+ const user = createSystemUser(admin.tenantId);
378
+ const tdb = createTenantDb(stack.db, admin.tenantId);
379
+ const result = await fileRefExecutor.forget({ id: fileRefId }, user, tdb);
380
+ if (!result.isSuccess) throw new Error(`fileRef forget failed: ${result.error.message}`);
381
+
382
+ await stack.eventDispatcher?.runOnce();
383
+
384
+ expect(await selectMany(stack.db, documentExtractsTable, { id: extractId })).toHaveLength(0);
385
+ expect(await selectMany(stack.db, documentExtractsTable, { id: otherExtractId })).toHaveLength(
386
+ 1,
387
+ );
388
+ });
389
+
390
+ test("fileRef.deleted with no documentExtract row leaves the cleanup consumer healthy", async () => {
391
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
392
+
393
+ const token = await stack.jwt.sign(admin);
394
+ const res = await stack.app.request(`/api/files/${fileRefId}`, {
395
+ method: "DELETE",
396
+ headers: { Authorization: `Bearer ${token}` },
397
+ });
398
+ expect(res.status).toBe(200);
399
+
400
+ // fileRef.deleted is the latest event at this point (fileRef.created
401
+ // already happened during upload) — its id is the bar the cleanup
402
+ // consumer's cursor must clear to prove it actually processed it,
403
+ // not just an earlier event, without throwing.
404
+ const [deletedEventRow] = (await asRawClient(stack.db).unsafe(
405
+ `SELECT id FROM kumiko_events ORDER BY id DESC LIMIT 1`,
406
+ )) as { id: string }[];
407
+ if (!deletedEventRow) throw new Error("fileRef.deleted event not found");
408
+
409
+ await stack.eventDispatcher?.runOnce();
410
+
411
+ const rows = await asRawClient(stack.db).unsafe(
412
+ `SELECT status, last_error, last_processed_event_id FROM kumiko_event_consumers WHERE name = $1`,
413
+ ["document-ingest-foundation:projection:forget-extract-with-file-ref"],
414
+ );
415
+ const consumer = (
416
+ rows as { status: string; last_error: string | null; last_processed_event_id: string }[]
417
+ )[0];
418
+ expect(consumer?.last_error).toBeNull();
419
+ // bigint columns round-trip as strings through the pg driver.
420
+ expect(Number(consumer?.last_processed_event_id)).toBeGreaterThanOrEqual(
421
+ Number(deletedEventRow.id),
422
+ );
423
+ });
424
+ });
425
+
426
+ async function deleteFileRef(fileRefId: string): Promise<void> {
427
+ const token = await stack.jwt.sign(admin);
428
+ const res = await stack.app.request(`/api/files/${fileRefId}`, {
429
+ method: "DELETE",
430
+ headers: { Authorization: `Bearer ${token}` },
431
+ });
432
+ expect(res.status).toBe(200);
433
+ }
434
+
435
+ describe("fileRef.restored → re-ingest", () => {
436
+ test("delete → runOnce → restore → waitFor re-requests ingest and produces a fresh extract", async () => {
437
+ const { id: fileRefId, storageKey } = await uploadFile(
438
+ "invoice.pdf",
439
+ pdfBytes,
440
+ "application/pdf",
441
+ );
442
+
443
+ await waitFor(async () => {
444
+ await stack.eventDispatcher?.runOnce();
445
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
446
+ });
447
+
448
+ await deleteFileRef(fileRefId);
449
+ await stack.eventDispatcher?.runOnce();
450
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(0);
451
+
452
+ const user = createSystemUser(admin.tenantId);
453
+ const tdb = createTenantDb(stack.db, admin.tenantId);
454
+ const restoreResult = await fileRefExecutor.restore({ id: fileRefId }, user, tdb);
455
+ if (!restoreResult.isSuccess) {
456
+ throw new Error(`restore failed: ${restoreResult.error.message}`);
457
+ }
458
+
459
+ await waitFor(async () => {
460
+ await stack.eventDispatcher?.runOnce();
461
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
462
+ });
463
+
464
+ const requested = await loadIngestRequestedEventsForFileRef(fileRefId);
465
+ expect(requested).toHaveLength(2);
466
+ expect(requested[1]?.payload).toEqual({
467
+ fileRefId,
468
+ storageKey,
469
+ fileName: "invoice.pdf",
470
+ mimeType: "application/pdf",
471
+ size: pdfBytes.length,
472
+ provider: PROVIDER_A_NAME,
473
+ });
474
+ });
475
+
476
+ test("delete → restore with no dispatcher pass in between still ends with exactly one live extract", async () => {
477
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
478
+
479
+ await waitFor(async () => {
480
+ await stack.eventDispatcher?.runOnce();
481
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
482
+ });
483
+
484
+ // No runOnce between delete and restore: the forget-extract-with-file-ref
485
+ // consumer only sees fileRef.deleted once it next runs, by which point
486
+ // the fileRef may already be live again — its own separate cursor makes
487
+ // this race possible on every real deploy, not just in this test.
488
+ await deleteFileRef(fileRefId);
489
+
490
+ const user = createSystemUser(admin.tenantId);
491
+ const tdb = createTenantDb(stack.db, admin.tenantId);
492
+ const restoreResult = await fileRefExecutor.restore({ id: fileRefId }, user, tdb);
493
+ if (!restoreResult.isSuccess) {
494
+ throw new Error(`restore failed: ${restoreResult.error.message}`);
495
+ }
496
+
497
+ await waitFor(async () => {
498
+ await stack.eventDispatcher?.runOnce();
499
+ expect(providerAProcessed.filter((p) => p.fileRefId === fileRefId)).toHaveLength(2);
500
+ });
501
+
502
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
503
+ });
504
+
505
+ test("delete → restore reconciled in the same pass the job already won leaves the extract intact", async () => {
506
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
507
+ const extractId = await seedDocumentExtract(admin.tenantId, fileRefId);
508
+
509
+ await deleteFileRef(fileRefId);
510
+
511
+ const user = createSystemUser(admin.tenantId);
512
+ const tdb = createTenantDb(stack.db, admin.tenantId);
513
+ const restoreResult = await fileRefExecutor.restore({ id: fileRefId }, user, tdb);
514
+ if (!restoreResult.isSuccess) {
515
+ throw new Error(`restore failed: ${restoreResult.error.message}`);
516
+ }
517
+
518
+ // The seeded extract stands in for a provider job that won the race: it
519
+ // exists for the now-live fileRef before the forget consumer ever sees
520
+ // the still-pending fileRef.deleted.
521
+ await stack.eventDispatcher?.runOnce();
522
+
523
+ expect(await selectMany(stack.db, documentExtractsTable, { id: extractId })).toHaveLength(1);
524
+ });
525
+
526
+ test("delete → restore of an unsupported mime type produces two skipped events", async () => {
527
+ const { id: fileRefId } = await uploadFile("notes.txt", textBytes, "text/plain");
528
+
529
+ await stack.eventDispatcher?.runOnce();
530
+ expect(await loadIngestSkippedEventsForFileRef(fileRefId)).toHaveLength(1);
531
+
532
+ await deleteFileRef(fileRefId);
533
+
534
+ const user = createSystemUser(admin.tenantId);
535
+ const tdb = createTenantDb(stack.db, admin.tenantId);
536
+ const restoreResult = await fileRefExecutor.restore({ id: fileRefId }, user, tdb);
537
+ if (!restoreResult.isSuccess) {
538
+ throw new Error(`restore failed: ${restoreResult.error.message}`);
539
+ }
540
+
541
+ await stack.eventDispatcher?.runOnce();
542
+
543
+ const skipped = await loadIngestSkippedEventsForFileRef(fileRefId);
544
+ expect(skipped).toHaveLength(2);
545
+ expect(skipped.every((row) => row.payload["reason"] === "unsupported-mime-type")).toBe(true);
546
+ });
547
+
548
+ test("forget makes restore fail — no re-ingest, only the original upload's requested event survives", async () => {
549
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
550
+
551
+ await waitFor(async () => {
552
+ await stack.eventDispatcher?.runOnce();
553
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
554
+ });
555
+
556
+ const user = createSystemUser(admin.tenantId);
557
+ const tdb = createTenantDb(stack.db, admin.tenantId);
558
+ const forgetResult = await fileRefExecutor.forget({ id: fileRefId }, user, tdb);
559
+ if (!forgetResult.isSuccess) {
560
+ throw new Error(`forget failed: ${forgetResult.error.message}`);
561
+ }
562
+
563
+ await stack.eventDispatcher?.runOnce();
564
+
565
+ const restoreResult = await fileRefExecutor.restore({ id: fileRefId }, user, tdb);
566
+ expect(restoreResult.isSuccess).toBe(false);
567
+ // code, not instanceof: WriteResult.error is always a plain WriteErrorInfo
568
+ // object (JSON-serializable for the dispatcher's idempotency-key storage),
569
+ // never a KumikoError instance.
570
+ if (!restoreResult.isSuccess) {
571
+ expect(restoreResult.error).toMatchObject({ code: "not_found" });
572
+ }
573
+
574
+ await stack.eventDispatcher?.runOnce();
575
+
576
+ expect(await loadIngestRequestedEventsForFileRef(fileRefId)).toHaveLength(1);
577
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(0);
578
+ });
579
+ });
580
+
581
+ describe("orphan extract guard", () => {
582
+ const testMeta = {
583
+ provider: PROVIDER_A_NAME,
584
+ ms: 1,
585
+ needsOcr: false,
586
+ pagesParsed: 1,
587
+ totalPages: 1,
588
+ };
589
+
590
+ test("writeDocumentExtractForLiveFileRef writes for a live fileRef, skips (no row) for a deleted or forgotten one", async () => {
591
+ const tdb = createTenantDb(stack.db, admin.tenantId);
592
+ const actor = createSystemUser(admin.tenantId);
593
+
594
+ const { id: liveFileRefId, storageKey: liveStorageKey } = await uploadFile(
595
+ "invoice.pdf",
596
+ pdfBytes,
597
+ "application/pdf",
598
+ );
599
+ const writtenResult = await writeDocumentExtractForLiveFileRef({
600
+ tenantDb: tdb,
601
+ actor,
602
+ fileRefId: liveFileRefId,
603
+ storageKey: liveStorageKey,
604
+ pages: [{ pageNumber: 1, text: "invoice text" }],
605
+ meta: testMeta,
606
+ });
607
+ expect(writtenResult.kind).toBe("written");
608
+ expect(
609
+ await selectMany(stack.db, documentExtractsTable, { fileRefId: liveFileRefId }),
610
+ ).toHaveLength(1);
611
+
612
+ const { id: deletedFileRefId, storageKey: deletedStorageKey } = await uploadFile(
613
+ "deleted.pdf",
614
+ pdfBytes,
615
+ "application/pdf",
616
+ );
617
+ await deleteFileRef(deletedFileRefId);
618
+ const skippedForDeleted = await writeDocumentExtractForLiveFileRef({
619
+ tenantDb: tdb,
620
+ actor,
621
+ fileRefId: deletedFileRefId,
622
+ storageKey: deletedStorageKey,
623
+ pages: [{ pageNumber: 1, text: "invoice text" }],
624
+ meta: testMeta,
625
+ });
626
+ expect(skippedForDeleted).toEqual({ kind: "skipped", reason: "file_ref_deleted" });
627
+ expect(
628
+ await selectMany(stack.db, documentExtractsTable, { fileRefId: deletedFileRefId }),
629
+ ).toHaveLength(0);
630
+
631
+ const { id: forgottenFileRefId, storageKey: forgottenStorageKey } = await uploadFile(
632
+ "forgotten.pdf",
633
+ pdfBytes,
634
+ "application/pdf",
635
+ );
636
+ const forgetResult = await fileRefExecutor.forget({ id: forgottenFileRefId }, actor, tdb);
637
+ if (!forgetResult.isSuccess) throw new Error(`forget failed: ${forgetResult.error.message}`);
638
+ const skippedForForgotten = await writeDocumentExtractForLiveFileRef({
639
+ tenantDb: tdb,
640
+ actor,
641
+ fileRefId: forgottenFileRefId,
642
+ storageKey: forgottenStorageKey,
643
+ pages: [{ pageNumber: 1, text: "invoice text" }],
644
+ meta: testMeta,
645
+ });
646
+ expect(skippedForForgotten).toEqual({ kind: "skipped", reason: "file_ref_deleted" });
647
+ expect(
648
+ await selectMany(stack.db, documentExtractsTable, { fileRefId: forgottenFileRefId }),
649
+ ).toHaveLength(0);
650
+ });
651
+
652
+ test("a provider write bypassing the helper, landing after fileRef.deleted was already processed, still gets forgotten", async () => {
653
+ const { id: fileRefId, storageKey } = await uploadFile(
654
+ "invoice.pdf",
655
+ pdfBytes,
656
+ "application/pdf",
657
+ );
658
+ await deleteFileRef(fileRefId);
659
+ // forget-extract-with-file-ref has already processed fileRef.deleted at
660
+ // this point — a provider write landing after is exactly the race the
661
+ // documentExtract.created handler exists for.
662
+ await stack.eventDispatcher?.runOnce();
663
+
664
+ const user = createSystemUser(admin.tenantId);
665
+ const tdb = createTenantDb(stack.db, admin.tenantId);
666
+ const lateWriteResult = await documentExtractExecutorForSeed.create(
667
+ {
668
+ fileRefId,
669
+ storageKey,
670
+ pages: writeIngestPages([{ pageNumber: 1, text: "invoice text" }]),
671
+ meta: testMeta,
672
+ },
673
+ user,
674
+ tdb,
675
+ );
676
+ if (!lateWriteResult.isSuccess) {
677
+ throw new Error(`late write failed: ${lateWriteResult.error.message}`);
678
+ }
679
+
680
+ await waitFor(async () => {
681
+ await stack.eventDispatcher?.runOnce();
682
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(0);
683
+ });
684
+ });
685
+
686
+ test("a provider write bypassing the helper, landing after fileRef.forgotten was already processed, still gets forgotten", async () => {
687
+ const { id: fileRefId, storageKey } = await uploadFile(
688
+ "invoice.pdf",
689
+ pdfBytes,
690
+ "application/pdf",
691
+ );
692
+ const user = createSystemUser(admin.tenantId);
693
+ const tdb = createTenantDb(stack.db, admin.tenantId);
694
+ const forgetResult = await fileRefExecutor.forget({ id: fileRefId }, user, tdb);
695
+ if (!forgetResult.isSuccess) throw new Error(`forget failed: ${forgetResult.error.message}`);
696
+ await stack.eventDispatcher?.runOnce();
697
+
698
+ const lateWriteResult = await documentExtractExecutorForSeed.create(
699
+ {
700
+ fileRefId,
701
+ storageKey,
702
+ pages: writeIngestPages([{ pageNumber: 1, text: "invoice text" }]),
703
+ meta: testMeta,
704
+ },
705
+ user,
706
+ tdb,
707
+ );
708
+ if (!lateWriteResult.isSuccess) {
709
+ throw new Error(`late write failed: ${lateWriteResult.error.message}`);
710
+ }
711
+
712
+ await waitFor(async () => {
713
+ await stack.eventDispatcher?.runOnce();
714
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(0);
715
+ });
716
+ });
717
+ });
718
+
719
+ describe("fileRef.created — no provider mounted", () => {
720
+ let noProviderStack: TestStack;
721
+ let noProviderFileProvider: InMemoryFileProvider;
722
+
723
+ beforeAll(async () => {
724
+ noProviderFileProvider = createInMemoryFileProvider();
725
+ noProviderStack = await setupTestStack({
726
+ features: [
727
+ createConfigFeature(),
728
+ createTenantFeature(),
729
+ createComplianceProfilesFeature(),
730
+ createTenantLifecycleFeature(),
731
+ documentIngestFoundationFeature,
732
+ ],
733
+ files: { storageProvider: noProviderFileProvider },
734
+ jobs: {
735
+ consumerLane: "worker",
736
+ queueNamePrefix: `document-ingest-foundation-no-provider-${generateId()}`,
737
+ },
738
+ });
739
+ await unsafeCreateEntityTable(noProviderStack.db, tenantEntity);
740
+ });
741
+
742
+ afterAll(async () => {
743
+ await noProviderStack.cleanup();
744
+ });
745
+
746
+ test("pdf upload is skipped as unsupported-mime-type — mounting the feature with zero providers is valid", async () => {
747
+ const token = await noProviderStack.jwt.sign(admin);
748
+ const formData = new FormData();
749
+ formData.append(
750
+ "file",
751
+ new File([Buffer.from(pdfBytes)], "invoice.pdf", { type: "application/pdf" }),
752
+ );
753
+ const uploadRes = await noProviderStack.app.request("/api/files", {
754
+ method: "POST",
755
+ headers: { Authorization: `Bearer ${token}` },
756
+ body: formData,
757
+ });
758
+ expect(uploadRes.status).toBe(201);
759
+
760
+ await noProviderStack.eventDispatcher?.runOnce();
761
+
762
+ const requested = await asRawClient(noProviderStack.db).unsafe(
763
+ `SELECT payload FROM kumiko_events WHERE type = $1`,
764
+ ["document-ingest-foundation:event:document-ingest-requested"],
765
+ );
766
+ expect(requested).toHaveLength(0);
767
+
768
+ const skipped = await asRawClient(noProviderStack.db).unsafe(
769
+ `SELECT payload FROM kumiko_events WHERE type = $1`,
770
+ ["document-ingest-foundation:event:document-ingest-skipped"],
771
+ );
772
+ expect(skipped).toHaveLength(1);
773
+ expect((skipped as { payload: Record<string, unknown> }[])[0]?.payload).toMatchObject({
774
+ reason: "unsupported-mime-type",
775
+ });
776
+ });
777
+ });