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>
150 lines
5.5 KiB
TypeScript
150 lines
5.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { GraphMailboxAdapter } from "@/src/adapters/graph-mailbox-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("GraphMailboxAdapter", () => {
|
|
const config = {
|
|
tenantId: "tenant-1",
|
|
clientId: "client-1",
|
|
clientSecret: "secret-1",
|
|
mailboxAddress: "docs@customer.com",
|
|
folder: "inbox",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("isConfigured requires tenantId/clientId/clientSecret/mailboxAddress", () => {
|
|
expect(GraphMailboxAdapter.isConfigured(config)).toBe(true);
|
|
expect(GraphMailboxAdapter.isConfigured({ ...config, clientSecret: "" })).toBe(false);
|
|
expect(GraphMailboxAdapter.isConfigured({ ...config, mailboxAddress: "" })).toBe(false);
|
|
});
|
|
|
|
it("polls messages across pages, following @odata.nextLink up to maxPages", async () => {
|
|
const calls: string[] = [];
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
calls.push(url);
|
|
if (url.includes("login.microsoftonline.com")) {
|
|
return jsonResponse({ access_token: "tok123", expires_in: 3600 });
|
|
}
|
|
if (url.includes("/messages?")) {
|
|
return jsonResponse({
|
|
value: [
|
|
{
|
|
id: "msg-1",
|
|
internetMessageId: "<msg-1@customer.com>",
|
|
conversationId: "conv-1",
|
|
receivedDateTime: "2026-07-01T10:00:00Z",
|
|
from: { emailAddress: { name: "Vendor AB", address: "vendor@example.com" } },
|
|
toRecipients: [{ emailAddress: { address: "docs@customer.com" } }],
|
|
subject: "Document 1",
|
|
bodyPreview: "preview",
|
|
body: { content: "<p>Hello <b>World</b></p>" },
|
|
hasAttachments: true,
|
|
webLink: "https://outlook.office.com/mail/1",
|
|
},
|
|
],
|
|
"@odata.nextLink": "https://graph.microsoft.com/v1.0/next-page",
|
|
});
|
|
}
|
|
if (url.includes("next-page")) {
|
|
return jsonResponse({
|
|
value: [
|
|
{
|
|
id: "msg-2",
|
|
receivedDateTime: "2026-07-01T11:00:00Z",
|
|
from: { emailAddress: { name: "Vendor 2", address: "vendor2@example.com" } },
|
|
toRecipients: [],
|
|
subject: "Document 2",
|
|
hasAttachments: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const adapter = new GraphMailboxAdapter(config);
|
|
const messages = await adapter.pollMessages({ top: 50, maxPages: 2 });
|
|
|
|
expect(messages).toHaveLength(2);
|
|
expect(messages[0].id).toBe("msg-1");
|
|
expect(messages[0].senderEmail).toBe("vendor@example.com");
|
|
expect(messages[0].bodyText).toBe("Hello World");
|
|
expect(messages[1].id).toBe("msg-2");
|
|
expect(messages[1].hasAttachments).toBe(false);
|
|
expect(calls.some((u) => u.includes("mailFolders/inbox/messages"))).toBe(true);
|
|
});
|
|
|
|
it("lists only file attachments and flags PDFs by content-type or filename", async () => {
|
|
const pdfBase64 = Buffer.from("%PDF-1.4 fake pdf bytes").toString("base64");
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url.includes("login.microsoftonline.com")) {
|
|
return jsonResponse({ access_token: "tok123", expires_in: 3600 });
|
|
}
|
|
if (url.includes("/attachments")) {
|
|
return jsonResponse({
|
|
value: [
|
|
{
|
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
id: "att-1",
|
|
name: "invoice.pdf",
|
|
contentType: "application/pdf",
|
|
size: 123,
|
|
contentBytes: pdfBase64,
|
|
},
|
|
{
|
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
id: "att-2",
|
|
name: "logo.png",
|
|
contentType: "image/png",
|
|
size: 50,
|
|
contentBytes: Buffer.from("not-a-pdf").toString("base64"),
|
|
},
|
|
{
|
|
// No mime type but a .pdf filename should still be detected.
|
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
id: "att-3",
|
|
name: "scan.PDF",
|
|
contentType: "application/octet-stream",
|
|
size: 999,
|
|
contentBytes: pdfBase64,
|
|
},
|
|
{
|
|
// Non-file attachment (e.g. embedded email) must be skipped entirely.
|
|
"@odata.type": "#microsoft.graph.itemAttachment",
|
|
id: "att-4",
|
|
name: "embedded-email",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const adapter = new GraphMailboxAdapter(config);
|
|
const attachments = await adapter.listAttachments("msg-1");
|
|
|
|
expect(attachments).toHaveLength(3); // itemAttachment skipped
|
|
const pdf = attachments.find((a) => a.name === "invoice.pdf")!;
|
|
expect(pdf.isPdf).toBe(true);
|
|
expect(pdf.contentBytes.toString()).toContain("%PDF-1.4");
|
|
const png = attachments.find((a) => a.name === "logo.png")!;
|
|
expect(png.isPdf).toBe(false);
|
|
const scanByExt = attachments.find((a) => a.name === "scan.PDF")!;
|
|
expect(scanByExt.isPdf).toBe(true);
|
|
});
|
|
});
|