@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.
- package/package.json +9 -9
- package/src/__tests__/extension-points-typed.test.ts +4 -0
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +16 -12
- package/src/delivery/__tests__/delivery.integration.test.ts +21 -29
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
- package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
- package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
- package/src/document-ingest-foundation/boot-checks.ts +58 -0
- package/src/document-ingest-foundation/changes.json +18 -0
- package/src/document-ingest-foundation/events.ts +3 -0
- package/src/document-ingest-foundation/executor.ts +12 -0
- package/src/document-ingest-foundation/feature.ts +125 -72
- package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
- package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
- package/src/document-ingest-foundation/index.ts +17 -0
- package/src/document-ingest-foundation/providers.ts +78 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
- package/src/document-ingest-foundation/write-document-extract.ts +48 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +10 -12
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +11 -9
|
@@ -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
|
|
5
|
-
//
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
153
|
-
|
|
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:
|
|
192
|
-
storageKey
|
|
339
|
+
fileRefId: id,
|
|
340
|
+
storageKey,
|
|
193
341
|
fileName: "huge.pdf",
|
|
194
342
|
mimeType: "application/pdf",
|
|
195
|
-
size:
|
|
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
|
+
});
|