Ctrl K
ComunidadGuías…

src/components/export/data-export-card.render.test.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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.",
        }),
      ),
    );
  });
});