src/components/export/data-export-card.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DataExportCard } from "./data-export-card";
// The card holds no logic worth unit-testing - naming lives in `lib/api/export.ts` and
// is tested there. What only a render reaches is the wiring between four visually
// identical buttons and four different endpoints, and the two states a diver notices
// when it goes wrong: a spinner on the wrong row, or a failure that saves nothing and
// says nothing.
vi.mock("@/lib/api/export", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/export")>()),
exportAPI: { download: vi.fn() },
}));
// Which browser takes the ticket path is `lib/download.ts`'s business and tested
// there; here the blob path stands in for every browser, so `save` is what a diver
// gets handed.
vi.mock("@/lib/download", () => ({
saveApiDownload: vi.fn(),
}));
const toast = vi.fn();
vi.mock("@/components/ui/use-toast", () => ({
useToast: () => ({ toast }),
}));
const { exportAPI } = await import("@/lib/api/export");
const { saveApiDownload } = await import("@/lib/download");
const download = vi.mocked(exportAPI.download);
const saveApi = vi.mocked(saveApiDownload);
const save = vi.fn();
beforeEach(() => {
download.mockReset();
save.mockReset();
toast.mockReset();
saveApi.mockReset();
saveApi.mockImplementation(async (_path, fetchFile) => {
const { blob, filename } = await fetchFile();
save(blob, filename);
return 0;
});
});
// A row's button is queried by its accessible name, which is also what changes when the
// row goes busy - so `downloadButton` finding it at all is the assertion that it is
// idle, and `busyButton` that it is not.
function downloadButton(label: string) {
return screen.getByRole("button", { name: `Download ${label}` });
}
function busyButton(label: string) {
return screen.getByRole("button", { name: `Preparing ${label} export` });
}
// `aria-disabled`, not `disabled` - the card uses the advisory attribute so a busy
// button keeps focus and its changed name gets announced, which means jest-dom's
// `toBeDisabled`/`toBeEnabled` (which only read the real attribute) would pass on a
// button that had lost its guard entirely.
function expectInert(button: HTMLElement) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
function expectLive(button: HTMLElement) {
expect(button).not.toHaveAttribute("aria-disabled", "true");
expect(button).toBeEnabled();
}
// A download the test releases by hand, so the in-flight state can be asserted on.
function deferred() {
let release: (value: { blob: Blob; filename: string }) => void = () => {};
let reject: (reason: unknown) => void = () => {};
const promise = new Promise<{ blob: Blob; filename: string }>(
(resolve, fail) => {
release = resolve;
reject = fail;
},
);
return { promise, release, reject };
}
describe("DataExportCard", () => {
it("offers all four exports", () => {
render(<DataExportCard username="alex" />);
expect(downloadButton("DiveJSON")).toBeInTheDocument();
expect(downloadButton("UDDF")).toBeInTheDocument();
expect(downloadButton("Spreadsheet")).toBeInTheDocument();
expect(downloadButton("Full archive")).toBeInTheDocument();
});
it("says the archive carries the certification scans and the portrait", () => {
// The one sentence here that is a warning rather than a description. It is on the
// archive row specifically because that is the only file that contains them.
render(<DataExportCard username="alex" />);
const warning = screen.getByText(/personal documents/i).textContent!;
expect(warning).toMatch(
/card scans and the portrait are personal documents/,
);
// In the archive, and offered back by an import rather than restored silently.
expect(warning).toMatch(
/portrait is in it too, and importing the archive offers it back/,
);
});
// The drafts ride only in the archive, and the import leaves them out - so the
// DiveJSON row no longer holds "everything", and the archive row says both.
it("says the drafts are in the archive and not in the DiveJSON", () => {
render(<DataExportCard username="alex" />);
expect(
screen.getByText(/Your whole logbook in one file/),
).toHaveTextContent(
/Everything in the account except your dive drafts and the uploaded files/,
);
expect(screen.getByText(/Everything, as a zip/)).toHaveTextContent(
/your dive drafts, which an import leaves out/,
);
});
it.each([
// The label is the row's title and the format is the URL segment, which is exactly
// the pairing a fourth row could get wrong without any type disagreeing.
["DiveJSON", "divejson"],
["UDDF", "uddf"],
["Spreadsheet", "csv"],
["Full archive", "archive"],
])("fetches and saves the %s export", async (label, format) => {
const blob = new Blob(["bytes"]);
download.mockResolvedValue({ blob, filename: `export.${format}` });
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton(label));
await waitFor(() => expect(download).toHaveBeenCalledWith(format, "alex"));
expect(saveApi).toHaveBeenCalledWith(
`/export/${format}`,
expect.any(Function),
);
// The server's name, not one the card made up - the card never derives one.
expect(save).toHaveBeenCalledWith(blob, `export.${format}`);
});
it("only busies the row being fetched", async () => {
// A shared boolean would grey out all four buttons because one is running, which
// reads as "the whole card is broken" on a download that takes a while - and the
// archive is exactly the one that takes a while.
const archive = deferred();
download.mockReturnValue(archive.promise);
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton("Full archive"));
await waitFor(() => expectInert(busyButton("Full archive")));
expectLive(downloadButton("DiveJSON"));
expectLive(downloadButton("UDDF"));
expectLive(downloadButton("Spreadsheet"));
archive.release({ blob: new Blob(["zip"]), filename: "export.zip" });
await waitFor(() => expectLive(downloadButton("Full archive")));
});
it("ignores a second click on the row already fetching", async () => {
// `aria-disabled` is advisory - the button still receives the click - so the guard
// in `handleDownload` is the whole of what stops a double-click saving the same
// file twice. (Not what stops a second *request*: the API client dedupes in-flight
// GETs, so the duplicate would join the pending one. It is the second
// `downloadBlob` that reaches the diver, as two identical files.)
const archive = deferred();
download.mockReturnValue(archive.promise);
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton("Full archive"));
await waitFor(() => expect(busyButton("Full archive")).toBeInTheDocument());
await userEvent.click(busyButton("Full archive"));
expect(download).toHaveBeenCalledTimes(1);
archive.release({ blob: new Blob(["zip"]), filename: "export.zip" });
await waitFor(() => expectLive(downloadButton("Full archive")));
});
it("keeps each concurrent download's own busy state", async () => {
// The bug a single `ExportFormat | null` had: the other two buttons stay enabled on
// purpose, so a diver can start a second export while the first is still fetching -
// and then whichever request landed first cleared *both* spinners, leaving a row
// that is still fetching looking idle.
const archive = deferred();
const csv = deferred();
download.mockImplementation((format) =>
format === "archive" ? archive.promise : csv.promise,
);
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton("Full archive"));
await waitFor(() => expect(busyButton("Full archive")).toBeInTheDocument());
await userEvent.click(downloadButton("Spreadsheet"));
await waitFor(() => expect(busyButton("Spreadsheet")).toBeInTheDocument());
// The fast one finishes first. The slow one must not notice.
csv.release({ blob: new Blob(["csv"]), filename: "export.csv" });
await waitFor(() => expectLive(downloadButton("Spreadsheet")));
expectInert(busyButton("Full archive"));
archive.release({ blob: new Blob(["zip"]), filename: "export.zip" });
await waitFor(() => expectLive(downloadButton("Full archive")));
expect(save).toHaveBeenCalledTimes(2);
});
it("clears only the failing row when one of two concurrent downloads fails", async () => {
// The same race through the `catch`/`finally` path rather than the happy one: a 429
// on the second export must not re-enable the first row while it is still fetching.
const archive = deferred();
const csv = deferred();
download.mockImplementation((format) =>
format === "archive" ? archive.promise : csv.promise,
);
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton("Full archive"));
await waitFor(() => expect(busyButton("Full archive")).toBeInTheDocument());
await userEvent.click(downloadButton("Spreadsheet"));
await waitFor(() => expect(busyButton("Spreadsheet")).toBeInTheDocument());
csv.reject({
response: {
data: { detail: "Too many requests. Please try again later." },
},
});
await waitFor(() => expectLive(downloadButton("Spreadsheet")));
expect(toast).toHaveBeenCalledTimes(1);
expectInert(busyButton("Full archive"));
});
it("toasts the API's own message on failure and saves nothing", async () => {
// The 429 the export rate limit produces. Its `detail` reaches this point already
// unwrapped from the blob body by the client's interceptor, so what matters here
// is that the card shows it rather than its own generic sentence.
download.mockRejectedValue({
response: {
data: { detail: "Too many requests. Please try again later." },
},
});
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton("Full archive"));
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
description: "Too many requests. Please try again later.",
variant: "destructive",
}),
),
);
expect(save).not.toHaveBeenCalled();
// Re-enabled, so a rate limit that clears in a minute does not need a reload.
expectLive(downloadButton("Full archive"));
});
it("names the file in the fallback message rather than a lowercased title", async () => {
// Only reached when the API sends no `detail` at all, but "your uddf" reads like a
// typo where "your UDDF" reads like the file it is.
download.mockRejectedValue(new Error("network"));
render(<DataExportCard username="alex" />);
await userEvent.click(downloadButton("UDDF"));
await waitFor(() =>
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
description: "Could not export your UDDF. Please try again.",
}),
),
);
});
});