Skip to content

Commit f59aee9

Browse files
committed
test: cover the async-export surface fully and raise the coverage ratchet to match
The rebase's new frontend surface (asyncExport polling helper, the async exports API wrappers, getDocument) sat below the coverage ratchet. New tests pin: the schedule→poll→download happy path and its three filename arms, the failed-status throw (no download attempted), the 150-poll timeout, the non-test 2s poll cadence (fake timers + NODE_ENV stub), startUserExport's params-present and params-absent bodies, the encoded export-id round-trip, and the drag-payload legacy fallback in docTableSelection. Per the config's own rule ("floors only go up: when you add tests, raise them in the same PR"), statements moves 99 → 100 to match the new measurement. Branches stays at 97 (measured 97.66 — the fraction is the pre-existing dev-logging and `?? null` arms the config comment already carves out, not headroom worth gating away).
1 parent 5264b00 commit f59aee9

4 files changed

Lines changed: 205 additions & 2 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// runUserExport drives the three durable-export wrappers, so swap the API
4+
// module for hoisted spies the tests can re-use across a vi.resetModules()
5+
// (the poll-interval case re-imports the module under test).
6+
const { startUserExportMock, getUserExportStatusMock, downloadUserExportMock } =
7+
vi.hoisted(() => ({
8+
startUserExportMock: vi.fn(),
9+
getUserExportStatusMock: vi.fn(),
10+
downloadUserExportMock: vi.fn(),
11+
}));
12+
vi.mock("@/app/lib/mikeApi", () => ({
13+
startUserExport: startUserExportMock,
14+
getUserExportStatus: getUserExportStatusMock,
15+
downloadUserExport: downloadUserExportMock,
16+
}));
17+
18+
import { runUserExport } from "./asyncExport";
19+
20+
beforeEach(() => {
21+
startUserExportMock.mockResolvedValue({ export_id: "exp-1" });
22+
});
23+
24+
afterEach(() => {
25+
vi.useRealTimers();
26+
vi.unstubAllEnvs();
27+
vi.clearAllMocks();
28+
});
29+
30+
describe("runUserExport", () => {
31+
it("schedules the job, polls past pending, then downloads the artifact", async () => {
32+
const downloaded = new Blob(["csv-bytes"]);
33+
getUserExportStatusMock
34+
.mockResolvedValueOnce({ status: "pending" })
35+
.mockResolvedValueOnce({ status: "done", filename: "status.csv" });
36+
downloadUserExportMock.mockResolvedValue({
37+
blob: downloaded,
38+
filename: "download.csv",
39+
});
40+
41+
const { blob, filename } = await runUserExport("audit-csv", {
42+
q: "agreement",
43+
});
44+
45+
expect(startUserExportMock).toHaveBeenCalledWith("audit-csv", {
46+
q: "agreement",
47+
});
48+
expect(getUserExportStatusMock).toHaveBeenCalledTimes(2);
49+
expect(getUserExportStatusMock).toHaveBeenLastCalledWith("exp-1");
50+
expect(downloadUserExportMock).toHaveBeenCalledWith("exp-1");
51+
// The download's own content-disposition wins over the status record.
52+
expect(filename).toBe("download.csv");
53+
expect(blob).toBe(downloaded);
54+
});
55+
56+
it("falls back to the status filename when the download omits one", async () => {
57+
getUserExportStatusMock.mockResolvedValue({
58+
status: "done",
59+
filename: "status.csv",
60+
});
61+
downloadUserExportMock.mockResolvedValue({
62+
blob: new Blob(["z"]),
63+
filename: null,
64+
});
65+
66+
// No params: whole-account exports pass nothing through.
67+
await expect(runUserExport("account")).resolves.toMatchObject({
68+
filename: "status.csv",
69+
});
70+
expect(startUserExportMock).toHaveBeenCalledWith("account", undefined);
71+
});
72+
73+
it("keeps a null filename when neither half of the flow supplies one", async () => {
74+
getUserExportStatusMock.mockResolvedValue({
75+
status: "done",
76+
filename: null,
77+
});
78+
downloadUserExportMock.mockResolvedValue({
79+
blob: new Blob(["z"]),
80+
filename: null,
81+
});
82+
83+
await expect(runUserExport("chats")).resolves.toMatchObject({
84+
filename: null,
85+
});
86+
});
87+
88+
it("throws without downloading when the backend build fails", async () => {
89+
getUserExportStatusMock.mockResolvedValue({ status: "failed" });
90+
91+
await expect(runUserExport("documents-zip")).rejects.toThrow(
92+
"Export build failed",
93+
);
94+
expect(downloadUserExportMock).not.toHaveBeenCalled();
95+
});
96+
97+
it("gives up after the poll limit instead of polling forever", async () => {
98+
getUserExportStatusMock.mockResolvedValue({ status: "pending" });
99+
100+
await expect(runUserExport("tabular-reviews")).rejects.toThrow(
101+
"Export timed out",
102+
);
103+
expect(getUserExportStatusMock).toHaveBeenCalledTimes(150);
104+
expect(downloadUserExportMock).not.toHaveBeenCalled();
105+
});
106+
107+
it("polls at the interactive 2s rate outside the test environment", async () => {
108+
getUserExportStatusMock.mockResolvedValue({
109+
status: "done",
110+
filename: "slow.csv",
111+
});
112+
downloadUserExportMock.mockResolvedValue({
113+
blob: new Blob(["z"]),
114+
filename: null,
115+
});
116+
117+
// POLL_MS is picked at module load, so re-evaluate the module with a
118+
// non-test NODE_ENV to exercise the interactive interval.
119+
vi.resetModules();
120+
vi.stubEnv("NODE_ENV", "production");
121+
const { runUserExport: runProdExport } = await import("./asyncExport");
122+
vi.useFakeTimers();
123+
124+
const pending = runProdExport("account");
125+
await vi.advanceTimersByTimeAsync(1999);
126+
expect(getUserExportStatusMock).not.toHaveBeenCalled();
127+
128+
await vi.advanceTimersByTimeAsync(1);
129+
await expect(pending).resolves.toMatchObject({ filename: "slow.csv" });
130+
});
131+
});

frontend/src/app/lib/docTableSelection.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,16 @@ describe("DocTable document drag payload", () => {
9393
}),
9494
).toEqual([]);
9595
});
96+
97+
it("reads a drag that carries only the legacy single-row payload", () => {
98+
// A drag started by an older tab (or any non-DocTable source) sets no
99+
// multi-row type at all, so getData returns "" and the JSON.parse must
100+
// be skipped rather than attempted on an empty string.
101+
expect(
102+
readDocumentDragPayload({
103+
getData: (type: string) =>
104+
type === SINGLE_DOCUMENT_DRAG_TYPE ? "legacy" : "",
105+
}),
106+
).toEqual(["legacy"]);
107+
});
96108
});

frontend/src/app/lib/mikeApi.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
deleteWorkflowReferenceFile,
4242
deleteWorkflowShare,
4343
downloadDocumentsZip,
44+
downloadUserExport,
4445
exportAccountData,
4546
exportAuditHistory,
4647
exportChatData,
@@ -51,6 +52,7 @@ import {
5152
getChat,
5253
getAuditHistory,
5354
getPanelDocument,
55+
getDocument,
5456
getDocumentUrl,
5557
getLibrary,
5658
getLibraryLevels,
@@ -70,6 +72,7 @@ import {
7072
getTabularChats,
7173
getTabularReview,
7274
getTabularReviewPeople,
75+
getUserExportStatus,
7376
getUserProfile,
7477
getWorkflow,
7578
getWorkflowAddon,
@@ -124,6 +127,7 @@ import {
124127
setMcpToolEnabled,
125128
shareWorkflow,
126129
startMcpConnectorOAuth,
130+
startUserExport,
127131
streamChat,
128132
streamProjectChat,
129133
streamTabularChat,
@@ -2238,12 +2242,48 @@ describe("thin endpoint wrappers", () => {
22382242
method: "PATCH",
22392243
body: { filename: "renamed.docx" },
22402244
},
2245+
// Async (durable) exports. `params` is optional: the filtered exports
2246+
// send it, the whole-account ones must omit the key entirely so the
2247+
// backend's discriminated payload stays valid.
2248+
{
2249+
name: "startUserExport (with params)",
2250+
call: () =>
2251+
startUserExport("audit-csv", {
2252+
q: "agreement",
2253+
sort_dir: "desc",
2254+
}),
2255+
url: "/user/exports",
2256+
method: "POST",
2257+
body: {
2258+
type: "audit-csv",
2259+
params: { q: "agreement", sort_dir: "desc" },
2260+
},
2261+
},
2262+
{
2263+
name: "startUserExport (params omitted)",
2264+
call: () => startUserExport("account"),
2265+
url: "/user/exports",
2266+
method: "POST",
2267+
body: { type: "account" },
2268+
},
2269+
{
2270+
// Export ids come back from the API, so encode them the same way
2271+
// every other path segment is encoded.
2272+
name: "getUserExportStatus",
2273+
call: () => getUserExportStatus("exp/1"),
2274+
url: "/user/exports/exp%2F1",
2275+
},
22412276
// Standalone documents & versions
22422277
{
22432278
name: "listStandaloneDocuments",
22442279
call: () => listStandaloneDocuments(),
22452280
url: "/single-documents",
22462281
},
2282+
{
2283+
name: "getDocument",
2284+
call: () => getDocument("d1"),
2285+
url: "/single-documents/d1",
2286+
},
22472287
{
22482288
name: "deleteDocument",
22492289
call: () => deleteDocument("d1"),
@@ -2627,4 +2667,24 @@ describe("unwrapping and blob wrappers", () => {
26272667
"http://localhost:3001/user/tabular-reviews/export",
26282668
);
26292669
});
2670+
2671+
it("downloadUserExport streams the finished artifact by encoded id", async () => {
2672+
fetchMock.mockResolvedValue(
2673+
new Response("csv-bytes", {
2674+
status: 200,
2675+
headers: {
2676+
"content-disposition":
2677+
'attachment; filename="history.csv"',
2678+
},
2679+
}),
2680+
);
2681+
2682+
const { blob, filename } = await downloadUserExport("exp/1");
2683+
2684+
expect(lastFetchCall().url).toBe(
2685+
"http://localhost:3001/user/exports/exp%2F1/download",
2686+
);
2687+
expect(filename).toBe("history.csv");
2688+
expect(await blob.text()).toBe("csv-bytes");
2689+
});
26302690
});

frontend/vitest.config.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,13 @@ export default defineConfig({
5656
// effectively fully tested: every mikeApi endpoint wrapper has a
5757
// route/method/body assertion, and the remaining gap is only the
5858
// dev-logging branch and a couple of `?? null` default arms.
59-
// Measured on this tree: 99.81% statements, 97.09% branches,
59+
// Measured on this tree: 100% statements, 97.66% branches,
6060
// 100% functions, 100% lines. The floors are those measurements
6161
// rounded down to whole percentages, so a real drop fails CI.
6262
// Floors only go up: when you add tests, raise them in the same
6363
// PR. Backlog + per-area status: docs/frontend-testing.md.
6464
thresholds: {
65-
statements: 99,
65+
statements: 100,
6666
branches: 97,
6767
functions: 100,
6868
lines: 100,

0 commit comments

Comments
 (0)