Ctrl K
ComunidadGuías…

frontend/src/hooks/useMultiFileProcessor.test.ts

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

Archivo original ↗Preguntar a KiBot
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { toast } from "sonner";
import { useMultiFileProcessor, type FileEntry, type ProcessOptions } from "./useMultiFileProcessor";
import { installNetwork } from "@/test/fake-network";
import { withUserMessage } from "@/lib/api";

vi.mock("sonner", () => ({ toast: { error: vi.fn(), message: vi.fn(), success: vi.fn() } }));

const RUN_EVENT = "privatools:tool-run";
type Detail = Record<string, unknown>;

function listen(): Detail[] {
  const seen: Detail[] = [];
  window.addEventListener(RUN_EVENT, event => seen.push((event as CustomEvent<Detail>).detail));
  return seen;
}
function file(name: string): File { return new File(["x"], name, { type: "text/plain" }); }
function options(localProcess: ProcessOptions["localProcess"]): ProcessOptions {
  return { endpoint: "noop", outputExt: "txt", outputSuffix: null, localProcess };
}

afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });

describe("useMultiFileProcessor usage events", () => {
  it("emits one single-mode tool run with the file count when every file succeeds", async () => {
    const seen = listen();
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => result.current.addFiles([file("a.txt"), file("b.txt")]));
    await act(() => result.current.run(options(async () => ({ blob: new Blob(["ok"]) }))));
    expect(result.current.doneCount).toBe(2);
    expect(seen).toEqual([{ mode: "single", outcome: "success", files: 2 }]);
  });

  it("reports partial and error outcomes without leaking names or messages", async () => {
    const seen = listen();
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => result.current.addFiles([file("secret-a.txt"), file("secret-b.txt")]));
    await act(() => result.current.run(options(async f => { if (f.name === "secret-b.txt") throw new Error("boom secret"); return { blob: new Blob(["ok"]) }; })));
    await act(() => result.current.run(options(async () => { throw new Error("boom secret"); }), true));
    // A local processor that throws failed in the browser.
    expect(seen).toEqual([
      { mode: "single", outcome: "partial", files: 2, errorKind: "browser" },
      { mode: "single", outcome: "error", files: 1, errorKind: "browser" },
    ]);
    expect(JSON.stringify(seen)).not.toContain("secret");
  });

  it("reports the category of a server refusal", async () => {
    const seen = listen();
    installNetwork({ uploadMs: 0, answerAfterMs: 0, status: 429, body: "slow down" });
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => result.current.addFiles([file("secret.txt")]));
    await act(() => result.current.run({ endpoint: "/compress", outputExt: "txt", outputSuffix: null, uploadOptions: { retry: { attempts: 0, backoffMs: 1 } } }));
    expect(seen).toEqual([{ mode: "single", outcome: "error", files: 1, errorKind: "rate_limited" }]);
  });

  it("counts a file returned unchanged as a failure of its kind, while the page shows it as done", async () => {
    const seen = listen();
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => result.current.addFiles([file("a.png"), file("b.png"), file("c.png")]));
    await act(() => result.current.run(options(async f => f.name === "a.png"
      ? { blob: new Blob(["cleaned"]) }
      : { blob: f, unchanged: f.name === "b.png" ? "bad_input" as const : "browser" as const })));
    expect(result.current.doneCount).toBe(3);
    expect(result.current.failedCount).toBe(0);
    expect(seen).toEqual([{ mode: "single", outcome: "partial", files: 3, errorKind: "bad_input" }]);

    act(() => result.current.reset());
    act(() => result.current.addFiles([file("d.png")]));
    await act(() => result.current.run(options(async f => ({ blob: f, unchanged: "bad_input" }))));
    expect(seen[1]).toEqual({ mode: "single", outcome: "error", files: 1, errorKind: "bad_input" });
  });

  it("names a real failure's category before an unchanged file's", async () => {
    const seen = listen();
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => result.current.addFiles([file("a.png"), file("b.png")]));
    await act(() => result.current.run(options(async f => {
      if (f.name === "a.png") throw Object.assign(new Error("too big"), { __kind: "too_large" });
      return { blob: f, unchanged: "bad_input" };
    })));
    expect(seen).toEqual([{ mode: "single", outcome: "error", files: 2, errorKind: "too_large" }]);
  });

  it("shows a message written for people as it is, and rewords others", async () => {
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => result.current.addFiles([file("a.png"), file("b.png")]));
    await act(() => result.current.run(options(async f => {
      // "damaged" would otherwise be reworded as a damaged PDF.
      if (f.name === "a.png") throw withUserMessage(new Error("This PNG file is damaged: a checksum does not match."));
      throw new Error("file is damaged");
    })));
    expect(result.current.entries.map(entry => entry.error)).toEqual([
      "This PNG file is damaged: a checksum does not match.",
      "This PDF is damaged. Try the Repair PDF tool first, then come back.",
    ]);
  });

  it("emits nothing when a run has no files to process", async () => {
    const seen = listen();
    const { result } = renderHook(() => useMultiFileProcessor());
    await act(() => result.current.run(options(async () => ({ blob: new Blob(["ok"]) }))));
    expect(seen).toEqual([]);
  });
});

const isPdf = (f: File) => f.name.toLowerCase().endsWith(".pdf");
const httpError = (status: number) => Object.assign(new Error(`HTTP ${status}`), { __status: status });

describe("useMultiFileProcessor refused files", () => {
  it("never drops a refused file silently: it is returned and named in a toast with the tool that takes it", () => {
    vi.mocked(toast.error).mockClear();
    window.history.pushState({}, "", "/tool/compress-pdf");
    const { result } = renderHook(() => useMultiFileProcessor());
    let refused: File[] = [];
    act(() => { refused = result.current.addFiles([file("report.pdf"), file("holiday.png")], isPdf); });
    expect(result.current.entries.map(entry => entry.name)).toEqual(["report.pdf"]);
    expect(refused.map(f => f.name)).toEqual(["holiday.png"]);
    expect(toast.error).toHaveBeenCalledTimes(1);
    const [headline, details] = vi.mocked(toast.error).mock.calls[0] as unknown as [string, { description: string; action: { label: string } }];
    expect(headline).toBe("holiday.png wasn’t added.");
    expect(details.description).toBe("Compress PDF takes PDF files. Try Image Compressor for PNG files.");
    expect(details.action.label).toBe("Open Image Compressor");
    window.history.pushState({}, "", "/");
  });

  it("stays quiet when every file is taken, or when the caller shows the refusal itself", () => {
    vi.mocked(toast.error).mockClear();
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => { result.current.addFiles([file("a.pdf")], isPdf); });
    act(() => { result.current.addFiles([file("b.png")], isPdf, { report: false }); });
    expect(toast.error).not.toHaveBeenCalled();
    expect(result.current.entries).toHaveLength(1);
  });
});

describe("useMultiFileProcessor retries", () => {
  it("marks which failures another attempt could fix, and retries only those with \"transient\"", async () => {
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => { result.current.addFiles([file("flaky.txt"), file("broken.txt")]); });
    const attempts: string[] = [];
    const process = options(async f => { attempts.push(f.name); throw f.name === "flaky.txt" ? httpError(503) : httpError(400); });
    await act(() => result.current.run(process));
    expect(result.current.entries.map(entry => [entry.name, entry.errorKind, entry.retryable]))
      .toEqual([["flaky.txt", "server", true], ["broken.txt", "bad_input", false]]);
    expect(result.current.retryableCount).toBe(1);
    attempts.length = 0;
    await act(() => result.current.run(options(async () => ({ blob: new Blob(["ok"]) })), "transient"));
    expect(result.current.entries.map(entry => [entry.name, entry.status])).toEqual([["flaky.txt", "done"], ["broken.txt", "failed"]]);
    expect(result.current.retryableCount).toBe(0);
  });
});

describe("useMultiFileProcessor's run result", () => {
  it("resolves with the files the run took, as it left them, which a callback made before the run cannot see", async () => {
    const { result } = renderHook(() => useMultiFileProcessor());
    act(() => { result.current.addFiles([file("done.txt"), file("refused.txt")]); });
    // What a callback made in this render holds: Bates Numbering read its counts after the run.
    const before = result.current;
    let ran: FileEntry[] = [];
    await act(async () => {
      ran = await before.run(options(async f => { if (f.name === "refused.txt") throw httpError(400); return { blob: new Blob(["ok"]) }; }));
    });
    expect(ran.map(entry => [entry.name, entry.status])).toEqual([["done.txt", "done"], ["refused.txt", "failed"]]);
    expect(before.doneCount).toBe(0);
    let again: FileEntry[] = [];
    await act(async () => { again = await result.current.run(options(async () => ({ blob: new Blob(["ok"]) })), true); });
    expect(again.map(entry => [entry.name, entry.status])).toEqual([["refused.txt", "done"]]);
  });
});