Three new, tenant-agnostic P0 capabilities: - 0pt.query_source: bounded, timeout/row-capped ad-hoc SQL query execution against registered database sources, via a Fabric/OneLake adapter (Azure AD credential-based mssql connection), with AUD0 audit events and an app/api/0pt/sources/[sourceId]/query endpoint. - 0pt.document_ocr (document capture + extraction): mailbox-based document intake via Microsoft Graph, OCR/field extraction via Azure Document Intelligence, with capture/extraction API endpoints and services that never fabricate extracted values. - 0pt.write_external_record (external writeback): a generic writeback-adapter contract plus a concrete Odoo bills adapter that matches partners via Fabric, backing an external-writeback-service for pushing records to downstream systems of record. Known interim gaps: - src/adapters/local-blob-store.ts is an interim ST0RE substitute pending the real ST0RE artifact-store integration. - mssql is used by the Fabric/OneLake adapter but deliberately not yet added to package.json (pending `pnpm add mssql` once the concurrent session's package.json/dependency changes settle). A separate, concurrently-active session has unrelated in-flight changes in this same working tree (data-platform catalog/lineage/feature-store, dataset quality, lakehouse/Iceberg docs, deploy scaffolding, package.json/ pnpm-lock.yaml updates, etc.) — none of that is touched or included here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
137 lines
6.1 KiB
TypeScript
137 lines
6.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { DocumentIntelligenceAdapter } from "@/src/adapters/document-intelligence-adapter";
|
|
|
|
function jsonResponse(body: unknown, opts: { status?: number; headers?: Record<string, string> } = {}) {
|
|
return {
|
|
ok: (opts.status ?? 200) < 400,
|
|
status: opts.status ?? 200,
|
|
headers: { get: (k: string) => (opts.headers ?? {})[k] ?? null },
|
|
json: async () => body,
|
|
text: async () => JSON.stringify(body),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe("DocumentIntelligenceAdapter", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("isConfigured requires endpoint + apiKey", () => {
|
|
expect(DocumentIntelligenceAdapter.isConfigured({ endpoint: "https://x", apiKey: "k" })).toBe(true);
|
|
expect(DocumentIntelligenceAdapter.isConfigured({ endpoint: "https://x" })).toBe(false);
|
|
expect(DocumentIntelligenceAdapter.isConfigured({})).toBe(false);
|
|
});
|
|
|
|
it("normalizes structured + array fields and never fabricates a missing value", async () => {
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url.includes(":analyze")) {
|
|
return jsonResponse({}, { status: 202, headers: { "operation-location": "https://di.example.com/op/123" } });
|
|
}
|
|
if (url.includes("/op/123")) {
|
|
return jsonResponse({
|
|
status: "succeeded",
|
|
analyzeResult: {
|
|
content: "raw extracted text",
|
|
pages: [{}, {}],
|
|
documents: [
|
|
{
|
|
confidence: 0.93,
|
|
fields: {
|
|
VendorName: { type: "string", valueString: "Acme AB", confidence: 0.98 },
|
|
InvoiceTotal: { type: "currency", valueCurrency: { amount: 1234.5, currencyCode: "SEK" }, confidence: 0.91 },
|
|
// Field region detected but no value could be read — must stay blank, not fabricated.
|
|
DueDate: { type: "date", confidence: 0.4 },
|
|
Items: {
|
|
type: "array",
|
|
valueArray: [
|
|
{
|
|
type: "object",
|
|
valueObject: {
|
|
Description: { type: "string", valueString: "Widget", confidence: 0.9 },
|
|
Amount: { type: "currency", valueCurrency: { amount: 100 }, confidence: 0.85 },
|
|
Unit: { type: "string", confidence: 0.2 }, // present but empty -> found:false
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const adapter = new DocumentIntelligenceAdapter({ endpoint: "https://di.example.com", apiKey: "key123", pollIntervalMs: 1 });
|
|
const result = await adapter.analyzeDocument(Buffer.from("%PDF-1.4"), "prebuilt-invoice");
|
|
|
|
expect(result.modelId).toBe("prebuilt-invoice");
|
|
expect(result.content).toBe("raw extracted text");
|
|
expect(result.documentConfidence).toBe(0.93);
|
|
expect(result.pageCount).toBe(2);
|
|
|
|
expect(result.fields.VendorName).toEqual({ value: "Acme AB", confidence: 0.98, found: true });
|
|
expect(result.fields.InvoiceTotal).toEqual({ value: 1234.5, confidence: 0.91, found: true });
|
|
// Not fabricated: value stays null, but DI's own (low) confidence is still recorded.
|
|
expect(result.fields.DueDate).toEqual({ value: null, confidence: 0.4, found: false });
|
|
|
|
expect(result.arrayFields.Items).toHaveLength(1);
|
|
expect(result.arrayFields.Items[0].Description).toEqual({ value: "Widget", confidence: 0.9, found: true });
|
|
expect(result.arrayFields.Items[0].Amount).toEqual({ value: 100, confidence: 0.85, found: true });
|
|
expect(result.arrayFields.Items[0].Unit).toEqual({ value: null, confidence: 0.2, found: false });
|
|
});
|
|
|
|
it("retries once on 429 then proceeds", async () => {
|
|
let analyzeAttempts = 0;
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url.includes(":analyze")) {
|
|
analyzeAttempts++;
|
|
if (analyzeAttempts === 1) return jsonResponse({}, { status: 429 });
|
|
return jsonResponse({}, { status: 202, headers: { "operation-location": "https://di.example.com/op/retry" } });
|
|
}
|
|
if (url.includes("/op/retry")) {
|
|
return jsonResponse({ status: "succeeded", analyzeResult: { documents: [{ fields: {} }] } });
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const adapter = new DocumentIntelligenceAdapter({
|
|
endpoint: "https://di.example.com",
|
|
apiKey: "key123",
|
|
pollIntervalMs: 1,
|
|
rateLimitRetryMs: 1,
|
|
});
|
|
const result = await adapter.analyzeDocument(Buffer.from("x"), "prebuilt-receipt");
|
|
expect(analyzeAttempts).toBe(2);
|
|
expect(result.fields).toEqual({});
|
|
});
|
|
|
|
it("throws (never returns a fabricated result) when analyze status is failed", async () => {
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url.includes(":analyze")) {
|
|
return jsonResponse({}, { status: 202, headers: { "operation-location": "https://di.example.com/op/999" } });
|
|
}
|
|
if (url.includes("/op/999")) {
|
|
return jsonResponse({ status: "failed", error: { code: "InvalidDocument" } });
|
|
}
|
|
throw new Error("unexpected");
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const adapter = new DocumentIntelligenceAdapter({ endpoint: "https://di.example.com", apiKey: "key123", pollIntervalMs: 1 });
|
|
await expect(adapter.analyzeDocument(Buffer.from("x"), "prebuilt-invoice")).rejects.toThrow(/failed/i);
|
|
});
|
|
|
|
it("throws when the analyze request itself errors (5xx)", async () => {
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url.includes(":analyze")) return jsonResponse({}, { status: 500 });
|
|
throw new Error("unexpected");
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const adapter = new DocumentIntelligenceAdapter({ endpoint: "https://di.example.com", apiKey: "key123" });
|
|
await expect(adapter.analyzeDocument(Buffer.from("x"), "prebuilt-invoice")).rejects.toThrow(/500/);
|
|
});
|
|
});
|