mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(documents): retire the v1.25 extraction-review surface
The parked inbound-documents view (upload form + staged-fact review UI) and its helpers leave the tree; /documents renders only the vault. The extraction routes stay dark server-side with no UI entry points, per the release plan. Orphaned i18n keys left with the locale rewrite in the previous commit; the reverse-coverage guard pins the result. Plan-Task: W2-T7
This commit is contained in:
1 parent
b8009d2e73
commit
2b7a6544d6
4 files changed
-2181
No files matched your search
@@ -1,375 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
ExtractedFactDto,
|
||||
InboundDocumentDetailDto,
|
||||
InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* v1.25 — the Documents library.
|
||||
*
|
||||
* The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and
|
||||
* assert the static markup. Interaction-driven paths (the exact request params,
|
||||
* the provider-unsupported classification) are covered by the pure-helper unit
|
||||
* tests in `library-utils.test.ts`; here we assert the rendered library, the
|
||||
* detail/review surface, the edit form, and the inline provider note.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
AlreadyConfirmedNote,
|
||||
DocumentDetail,
|
||||
DocumentMetaEditForm,
|
||||
InboundDocumentsView,
|
||||
ProviderUnsupportedNote,
|
||||
} from "../inbound-documents-view";
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "LAB_RESULT",
|
||||
title: null,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2026-06-20",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
servingClass: "inline",
|
||||
createdAt: "2026-06-20T08:00:00.000Z",
|
||||
updatedAt: "2026-06-20T08:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
seed?.(queryClient);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<InboundDocumentsView> library", () => {
|
||||
it("renders the grouped list with titles, badges, and load-more", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{
|
||||
pages: [
|
||||
{
|
||||
documents: [
|
||||
doc("d1", {
|
||||
title: "Cardiology report",
|
||||
documentDate: "2026-06-20",
|
||||
pendingCount: 2,
|
||||
factCount: 3,
|
||||
}),
|
||||
doc("d2", {
|
||||
title: "Blood panel",
|
||||
documentDate: "2026-06-20",
|
||||
}),
|
||||
],
|
||||
nextCursor: "cursor-next",
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Library + toolbar shells render.
|
||||
expect(html).toContain('data-slot="documents-library"');
|
||||
expect(html).toContain('data-slot="documents-toolbar"');
|
||||
// Date group header + rows.
|
||||
expect(html).toContain('data-slot="documents-date-group"');
|
||||
expect(html).toContain('data-slot="documents-row"');
|
||||
// Titles surface (title preferred over filename).
|
||||
expect(html).toContain("Cardiology report");
|
||||
expect(html).toContain("Blood panel");
|
||||
// Kind + status badges from the EN bundle.
|
||||
expect(html).toContain("Lab result");
|
||||
expect(html).toContain("Stored");
|
||||
// pendingCount badge.
|
||||
expect(html).toContain("2 to review");
|
||||
// nextCursor → load-more button.
|
||||
expect(html).toContain("Load more");
|
||||
// A11Y-M2: the row's aria-controls points at the detail region id.
|
||||
expect(html).toContain('aria-controls="documents-detail-d1"');
|
||||
// QoL: accepted formats + size ceiling are shown by the file field.
|
||||
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
|
||||
});
|
||||
|
||||
it("disables the upload button and shows a hint until a file is chosen", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
// A11Y-M4: with no file staged, an inline hint explains why upload is
|
||||
// disabled (the hint only renders on the !hasFile / not-pending path).
|
||||
expect(html).toContain("Choose a file to upload.");
|
||||
});
|
||||
|
||||
it("renders the document-date range filter inputs", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
// PERF-L: from/to date inputs are wired into the toolbar.
|
||||
expect(html).toContain('id="doc-from"');
|
||||
expect(html).toContain('id="doc-to"');
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no documents", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
expect(html).toContain("No documents yet.");
|
||||
expect(html).not.toContain('data-slot="documents-row"');
|
||||
});
|
||||
});
|
||||
|
||||
function fact(id: string): ExtractedFactDto {
|
||||
return {
|
||||
id,
|
||||
factType: "OBSERVATION",
|
||||
status: "PENDING",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
data: {
|
||||
label: "LDL Cholesterol",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 95,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2026-06-20",
|
||||
},
|
||||
provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 },
|
||||
committedRecordId: null,
|
||||
committedRecordType: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<DocumentDetail>", () => {
|
||||
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
|
||||
return {
|
||||
...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }),
|
||||
facts: [fact("f1")],
|
||||
...over,
|
||||
} as InboundDocumentDetailDto;
|
||||
}
|
||||
|
||||
it("renders the original link, edit + extract controls, and the review facts", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-detail"');
|
||||
// Original view link points at the raw-bytes route.
|
||||
expect(html).toContain("/api/documents/inbound/d1/original");
|
||||
expect(html).toContain('data-slot="documents-edit-trigger"');
|
||||
// Extract button is present (status is not CONFIRMED).
|
||||
expect(html).toContain('data-slot="documents-extract"');
|
||||
expect(html).toContain("Extract facts");
|
||||
// The staged fact's transcription summary renders for review.
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
expect(html).toContain("Save approved facts");
|
||||
});
|
||||
|
||||
it("hides the extract button once the document is confirmed", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", facts: [] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).not.toContain('data-slot="documents-extract"');
|
||||
});
|
||||
|
||||
it("hides the extract button once any fact is approved (PERF-H1 gate)", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).not.toContain('data-slot="documents-extract"');
|
||||
});
|
||||
|
||||
it("threads the region id onto the detail card for aria-controls", () => {
|
||||
const html = render(
|
||||
<DocumentDetail
|
||||
documentId="d1"
|
||||
regionId="documents-detail-d1"
|
||||
onClosed={() => {}}
|
||||
/>,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
expect(html).toContain('id="documents-detail-d1"');
|
||||
});
|
||||
|
||||
it("falls back to the kind label when title and filename are absent", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ title: null, filename: null, kind: "IMAGING" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).toContain("Imaging");
|
||||
});
|
||||
|
||||
it("shows the committed facts summary once approved, not the empty note", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M6: provenance stays visible — the approved facts are listed read-only…
|
||||
expect(html).toContain('data-slot="documents-committed"');
|
||||
expect(html).toContain("Approved from this document");
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
// …instead of stranding the user on "Nothing left to review."
|
||||
expect(html).not.toContain("Nothing left to review.");
|
||||
});
|
||||
|
||||
it("explains why a low-confidence fact can't be approved yet", () => {
|
||||
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ facts: [lowConf] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M7: the disabled Approve is tied to a plain-language reason.
|
||||
expect(html).toContain("Edit this fact to approve it.");
|
||||
expect(html).toContain("Needs review");
|
||||
});
|
||||
|
||||
it("offers a confirm step before discarding", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
// A11Y-M3: discard is gated behind an AlertDialog trigger, not one click.
|
||||
expect(html).toContain('data-slot="documents-discard-trigger"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentMetaEditForm>", () => {
|
||||
it("renders the metadata fields seeded from the document", () => {
|
||||
const html = render(
|
||||
<DocumentMetaEditForm
|
||||
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
|
||||
onSaved={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-edit-form"');
|
||||
expect(html).toContain('value="Old title"');
|
||||
expect(html).toContain('value="2026-06-20"');
|
||||
expect(html).toContain("Save");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ProviderUnsupportedNote>", () => {
|
||||
it("renders the calm inline note as a polite live region (A11Y-H1)", () => {
|
||||
const html = render(<ProviderUnsupportedNote />);
|
||||
expect(html).toContain('data-slot="documents-provider-note"');
|
||||
expect(html).toContain("Configure a document-scan provider");
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AlreadyConfirmedNote>", () => {
|
||||
it("renders a calm polite live region when extraction is refused", () => {
|
||||
const html = render(<AlreadyConfirmedNote />);
|
||||
expect(html).toContain('data-slot="documents-already-confirmed-note"');
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain("already been reviewed");
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import {
|
||||
buildDocumentListSearch,
|
||||
classifyUploadError,
|
||||
commitFailureReasonKey,
|
||||
confirmFailureReasonKey,
|
||||
documentDateKey,
|
||||
formatDateGroupLabel,
|
||||
groupDocumentsByDate,
|
||||
isAlreadyConfirmedError,
|
||||
isLocalOcrDisabledError,
|
||||
isProviderUnsupportedError,
|
||||
} from "../library-utils";
|
||||
|
||||
/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't
|
||||
* literal `t()` call sites, so the call-site coverage guard can't see them —
|
||||
* assert here that each one actually resolves to a string). */
|
||||
function resolveKey(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, part) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "OTHER",
|
||||
title: null,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
servingClass: "inline",
|
||||
createdAt: "2026-06-20T08:00:00.000Z",
|
||||
updatedAt: "2026-06-20T08:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDocumentListSearch", () => {
|
||||
it("emits the active filters, sort, order, and limit", () => {
|
||||
const qs = buildDocumentListSearch(
|
||||
{
|
||||
q: "cardio",
|
||||
kind: "LAB_RESULT",
|
||||
from: "2026-01-01",
|
||||
to: "2026-06-01",
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
},
|
||||
null,
|
||||
25,
|
||||
);
|
||||
const sp = new URLSearchParams(qs);
|
||||
expect(sp.get("q")).toBe("cardio");
|
||||
expect(sp.get("kind")).toBe("LAB_RESULT");
|
||||
expect(sp.get("from")).toBe("2026-01-01");
|
||||
expect(sp.get("to")).toBe("2026-06-01");
|
||||
expect(sp.get("sort")).toBe("title");
|
||||
expect(sp.get("order")).toBe("asc");
|
||||
expect(sp.get("limit")).toBe("25");
|
||||
expect(sp.has("cursor")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits empty filters and appends the keyset cursor when present", () => {
|
||||
const qs = buildDocumentListSearch(
|
||||
{ sort: "documentDate", order: "desc" },
|
||||
"cursor-123",
|
||||
);
|
||||
const sp = new URLSearchParams(qs);
|
||||
expect(sp.has("q")).toBe(false);
|
||||
expect(sp.has("kind")).toBe(false);
|
||||
expect(sp.get("sort")).toBe("documentDate");
|
||||
expect(sp.get("order")).toBe("desc");
|
||||
expect(sp.get("cursor")).toBe("cursor-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentDateKey", () => {
|
||||
it("prefers the user filing date", () => {
|
||||
expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe(
|
||||
"2026-03-15",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the upload day from createdAt", () => {
|
||||
expect(
|
||||
documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })),
|
||||
).toBe("2026-06-20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupDocumentsByDate", () => {
|
||||
it("buckets consecutive same-date documents, preserving server order", () => {
|
||||
const groups = groupDocumentsByDate([
|
||||
doc("a", { documentDate: "2026-06-20" }),
|
||||
doc("b", { documentDate: "2026-06-20" }),
|
||||
doc("c", { documentDate: "2026-06-19" }),
|
||||
]);
|
||||
expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]);
|
||||
expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for no documents", () => {
|
||||
expect(groupDocumentsByDate([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProviderUnsupportedError", () => {
|
||||
it("matches the extract 422 provider-unsupported signal", () => {
|
||||
const err = new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
expect(isProviderUnsupportedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isProviderUnsupportedError(
|
||||
new ApiError("rate", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isProviderUnsupportedError(new Error("boom"))).toBe(false);
|
||||
expect(isProviderUnsupportedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAlreadyConfirmedError", () => {
|
||||
it("matches the extract 409 partly-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 409, {
|
||||
errorCode: "documents.inbound.alreadyPartlyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the extract 422 fully-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 422, {
|
||||
errorCode: "documents.inbound.alreadyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isAlreadyConfirmedError(
|
||||
new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false);
|
||||
expect(isAlreadyConfirmedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyUploadError", () => {
|
||||
it("maps each upload error code to its kind", () => {
|
||||
const code = (c: string) =>
|
||||
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
|
||||
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
|
||||
expect(code("documents.inbound.fileType")).toBe("fileType");
|
||||
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
|
||||
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when the body carries no error code", () => {
|
||||
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
|
||||
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
|
||||
"fileType",
|
||||
);
|
||||
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
|
||||
"rateLimited",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns generic for unknown errors and non-API errors", () => {
|
||||
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
|
||||
expect(classifyUploadError(new Error("network"))).toBe("generic");
|
||||
expect(classifyUploadError(null)).toBe("generic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalOcrDisabledError", () => {
|
||||
it("matches the extract 422 local-OCR-disabled signal", () => {
|
||||
const err = new ApiError("local ocr off", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
expect(isLocalOcrDisabledError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isLocalOcrDisabledError(
|
||||
new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false);
|
||||
expect(isLocalOcrDisabledError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitFailureReasonKey", () => {
|
||||
it("maps each commit-failure code to a reason key that resolves", () => {
|
||||
const cases: Record<string, string> = {
|
||||
"observation.unitMismatch": "documents.review.commitError.unitMismatch",
|
||||
"observation.unitRequired": "documents.review.commitError.unitRequired",
|
||||
"something.unknown": "documents.review.commitError.generic",
|
||||
};
|
||||
for (const [code, key] of Object.entries(cases)) {
|
||||
expect(commitFailureReasonKey(code)).toBe(key);
|
||||
// Every reason the toast can surface resolves to a real string.
|
||||
expect(typeof resolveKey(enMessages, key)).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmFailureReasonKey", () => {
|
||||
it("surfaces the shared reason when every failed fact agrees", () => {
|
||||
const key = confirmFailureReasonKey([
|
||||
{ reason: "observation.unitMismatch" },
|
||||
{ reason: "observation.unitMismatch" },
|
||||
]);
|
||||
expect(key).toBe("documents.review.commitError.unitMismatch");
|
||||
expect(typeof resolveKey(enMessages, key)).toBe("string");
|
||||
});
|
||||
|
||||
it("falls back to the generic reason when failures disagree", () => {
|
||||
const key = confirmFailureReasonKey([
|
||||
{ reason: "observation.unitMismatch" },
|
||||
{ reason: "observation.unitRequired" },
|
||||
]);
|
||||
expect(key).toBe("documents.review.commitError.generic");
|
||||
});
|
||||
|
||||
it("uses the single failure's reason for a one-item batch", () => {
|
||||
expect(
|
||||
confirmFailureReasonKey([{ reason: "observation.unitRequired" }]),
|
||||
).toBe("documents.review.commitError.unitRequired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateGroupLabel", () => {
|
||||
it("delegates the calendar day to the supplied app formatter", () => {
|
||||
const seen: string[] = [];
|
||||
const formatDate = (value: string) => {
|
||||
seen.push(value);
|
||||
return "FORMATTED";
|
||||
};
|
||||
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
|
||||
// Handed over as a noon-UTC instant so the day never shifts under the tz.
|
||||
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
|
||||
});
|
||||
|
||||
it("returns a malformed key unchanged without calling the formatter", () => {
|
||||
const formatDate = () => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* v1.25 — pure helpers for the documents library (search-param building, date
|
||||
* grouping, provider-unsupported classification). Kept out of the client
|
||||
* component so they stay trivially unit-testable without a render harness.
|
||||
*/
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { DocumentListParams } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Build the `/api/documents/inbound` query string from the active filters and
|
||||
* a keyset cursor. Empty / undefined fields are omitted so the URL stays clean
|
||||
* and the server applies its own defaults. The cursor is the `nextCursor` the
|
||||
* previous page returned (null on the first page).
|
||||
*/
|
||||
export function buildDocumentListSearch(
|
||||
params: DocumentListParams,
|
||||
cursor: string | null,
|
||||
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim());
|
||||
if (params.kind) sp.set("kind", params.kind);
|
||||
if (params.from) sp.set("from", params.from);
|
||||
if (params.to) sp.set("to", params.to);
|
||||
sp.set("sort", params.sort);
|
||||
sp.set("order", params.order);
|
||||
sp.set("limit", String(limit));
|
||||
if (cursor) sp.set("cursor", cursor);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** The date a document files under — its user filing date, else its upload day. */
|
||||
export function documentDateKey(doc: InboundDocumentDto): string {
|
||||
if (doc.documentDate) return doc.documentDate;
|
||||
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
|
||||
return doc.createdAt.slice(0, 10);
|
||||
}
|
||||
|
||||
export interface DocumentDateGroup {
|
||||
/** The YYYY-MM-DD key the group files under. */
|
||||
key: string;
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an already-sorted document list into consecutive date buckets,
|
||||
* preserving the server's sort order. A document's bucket is its `documentDate`
|
||||
* (fallback: its upload day). The first time a date appears it opens a new
|
||||
* group; later documents on the same date join it. Order is preserved exactly
|
||||
* as received, so the server stays the single source of truth for sorting.
|
||||
*/
|
||||
export function groupDocumentsByDate(
|
||||
documents: InboundDocumentDto[],
|
||||
): DocumentDateGroup[] {
|
||||
const groups: DocumentDateGroup[] = [];
|
||||
let current: DocumentDateGroup | null = null;
|
||||
for (const doc of documents) {
|
||||
const key = documentDateKey(doc);
|
||||
if (!current || current.key !== key) {
|
||||
current = { key, documents: [doc] };
|
||||
groups.push(current);
|
||||
} else {
|
||||
current.documents.push(doc);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's 422 "no document-scan provider
|
||||
* configured" signal. The UI surfaces this as a calm inline note (configure a
|
||||
* provider) rather than a hard error toast — the stored document is untouched.
|
||||
*/
|
||||
export function isProviderUnsupportedError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.providerUnsupported"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's "already confirmed" signal. Two
|
||||
* shapes reach here:
|
||||
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
|
||||
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
|
||||
* can fire Extract against a document a second tab just finished confirming).
|
||||
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
|
||||
* inline note rather than a hard error toast, and hides the Extract control to
|
||||
* match.
|
||||
*/
|
||||
export function isAlreadyConfirmedError(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) return false;
|
||||
const code = error.meta?.errorCode;
|
||||
return (
|
||||
code === "documents.inbound.alreadyPartlyConfirmed" ||
|
||||
code === "documents.inbound.alreadyConfirmed"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an extract failure is the route's "local OCR not enabled" signal
|
||||
* (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses
|
||||
* when in-browser OCR is switched off in settings; surface a clear message that
|
||||
* names the setting rather than the generic "couldn't extract" toast.
|
||||
*/
|
||||
export function isLocalOcrDisabledError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.localOcrDisabled"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a per-fact commit-failure code to the i18n key for its plain-language
|
||||
* reason. The confirm route returns HTTP 200 even when it rejects a fact into
|
||||
* its `failed[]` array (e.g. a stated unit that disagrees with the saved
|
||||
* marker), so the client must surface why — otherwise the fact silently stays
|
||||
* PENDING with the user told nothing.
|
||||
*/
|
||||
export function commitFailureReasonKey(code: string): string {
|
||||
switch (code) {
|
||||
case "observation.unitMismatch":
|
||||
return "documents.review.commitError.unitMismatch";
|
||||
case "observation.unitRequired":
|
||||
return "documents.review.commitError.unitRequired";
|
||||
default:
|
||||
return "documents.review.commitError.generic";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the single reason key that best describes a batch of failed facts: the
|
||||
* shared reason when every failure agrees, else a generic fallback. Used to
|
||||
* build the partial-failure toast after a confirm that rejected one or more
|
||||
* facts server-side.
|
||||
*/
|
||||
export function confirmFailureReasonKey(failed: { reason: string }[]): string {
|
||||
const codes = new Set(failed.map((f) => f.reason));
|
||||
if (codes.size === 1) return commitFailureReasonKey([...codes][0]);
|
||||
return "documents.review.commitError.generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
|
||||
* oversized file is rejected before it is sent, instead of after a full upload
|
||||
* round-trips and returns 413.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The class of an upload (`POST /api/documents/inbound`) failure. The route
|
||||
* reports precise, actionable codes the generic toast would throw away; this
|
||||
* maps them to a stable kind so the view can show the right message and keep
|
||||
* every `t()` key a literal call site.
|
||||
*/
|
||||
export type UploadErrorKind =
|
||||
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
|
||||
|
||||
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
|
||||
export function classifyUploadError(error: unknown): UploadErrorKind {
|
||||
if (!(error instanceof ApiError)) return "generic";
|
||||
switch (error.meta?.errorCode) {
|
||||
case "documents.inbound.fileTooLarge":
|
||||
return "tooLarge";
|
||||
case "documents.inbound.fileType":
|
||||
return "fileType";
|
||||
case "documents.inbound.rateLimited":
|
||||
return "rateLimited";
|
||||
case "documents.inbound.invalidMetadata":
|
||||
return "invalidMetadata";
|
||||
}
|
||||
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
|
||||
// fall back to the HTTP code so size / type / rate-limit still classify.
|
||||
if (error.status === 413) return "tooLarge";
|
||||
if (error.status === 415) return "fileType";
|
||||
if (error.status === 429) return "rateLimited";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD group key into a date label via the app's own date
|
||||
* formatter (`useFormatters().date`), so the group headers follow the user's
|
||||
* date-format preference (numeric) like the rest of the app instead of a textual
|
||||
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
|
||||
* the calendar day never shifts under the display timezone.
|
||||
*/
|
||||
export function formatDateGroupLabel(
|
||||
key: string,
|
||||
formatDate: (value: string) => string,
|
||||
): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
|
||||
return formatDate(`${key}T12:00:00.000Z`);
|
||||
}
|
||||
Reference in new issue
Block a user