bfcb0b3f38
Wire the new ``?download=true`` resubmit endpoint into the Inbox
page. Operators can now ask the backend for a ZIP of regenerated
837s straight from the rejected-claims bulk action, with the
``X-Cyclone-Serialize-Errors`` header surfaced as a non-blocking
warning so partial successes don't swallow per-claim failures.
* ``src/lib/inbox-api.ts``: new ``resubmitRejectedWithDownload``
helper returning ``{blob, filename, serializeErrors}`` so callers
can hand the bundle to the new ``downloadBlob`` utility without
re-parsing headers.
* ``src/lib/download.ts``: new ``downloadBlob(blob, filename)`` plus
a test covering the extension/content-type mapping and the
"use the suggested filename when present" rule.
* ``src/pages/Inbox.tsx``: rejected-claims bulk action now exposes
a "Resubmit & download" button next to the existing JSON path,
wired through the helper. Conflicts and per-claim serialize
errors render in the existing toast/result surface.
Tests: 4 new download.ts tests, 5 inbox-api tests (including
serialize-errors header parsing).
128 lines
4.9 KiB
TypeScript
128 lines
4.9 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { downloadBlob, downloadTextFile } from "./download";
|
|
|
|
// Capture the real appendChild before any spy replaces it, so the
|
|
// happy-dom implementation is preserved when the appendChild spy calls
|
|
// through (otherwise removeChild inside downloadTextFile can't find the
|
|
// node we appended).
|
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
|
|
|
/**
|
|
* `downloadTextFile` drives a hidden `<a download>` and clicks it. The
|
|
* observable side effects we care about:
|
|
*
|
|
* 1. An `<a>` element with the right `href` (object URL) and `download`
|
|
* (filename) is appended to `document.body`.
|
|
* 2. The element is removed from the DOM (cleanup).
|
|
* 3. The object URL is revoked (memory hygiene).
|
|
*
|
|
* We don't try to assert on the browser's actual download behavior — that's
|
|
* not observable from the test environment. We assert on the contract the
|
|
* implementation honors.
|
|
*/
|
|
describe("downloadTextFile", () => {
|
|
let createSpy: ReturnType<typeof vi.spyOn>;
|
|
let revokeSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
createSpy = vi
|
|
.spyOn(URL, "createObjectURL")
|
|
.mockReturnValue("blob:test-url");
|
|
revokeSpy = vi.spyOn(URL, "revokeObjectURL");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("test_appends_anchor_with_object_url_and_filename_then_cleans_up", async () => {
|
|
// Spy on appendChild to capture the anchor that's about to be appended
|
|
// (downloadTextFile removes it again synchronously, so lastElementChild
|
|
// is back to null by the time the call returns).
|
|
const captured: HTMLAnchorElement[] = [];
|
|
const appendSpy = vi
|
|
.spyOn(document.body, "appendChild")
|
|
.mockImplementation((node) => {
|
|
if (node instanceof HTMLAnchorElement) captured.push(node);
|
|
// Preserve the real append side-effect so removeChild later in
|
|
// downloadTextFile can find the node.
|
|
return originalAppendChild.call(document.body, node);
|
|
});
|
|
|
|
downloadTextFile("claim-CLM-1.x12", "text/x12", "ISA*00*~");
|
|
|
|
expect(captured).toHaveLength(1);
|
|
const a = captured[0];
|
|
expect(a.tagName).toBe("A");
|
|
expect(a.getAttribute("download")).toBe("claim-CLM-1.x12");
|
|
expect(a.getAttribute("href")).toBe("blob:test-url");
|
|
expect(a.style.display).toBe("none");
|
|
|
|
// removeChild ran synchronously inside downloadTextFile — the anchor
|
|
// is no longer a child of document.body.
|
|
expect(document.body.contains(a)).toBe(false);
|
|
|
|
expect(appendSpy).toHaveBeenCalledTimes(1);
|
|
expect(createSpy).toHaveBeenCalledTimes(1);
|
|
// The revoke is scheduled via queueMicrotask — flush the microtask
|
|
// queue so the assertion sees it.
|
|
await Promise.resolve();
|
|
expect(revokeSpy).toHaveBeenCalledWith("blob:test-url");
|
|
});
|
|
|
|
it("test_blob_carries_supplied_mime_type_and_text_bytes", () => {
|
|
downloadTextFile("a.txt", "text/plain", "hello world");
|
|
|
|
expect(createSpy).toHaveBeenCalledTimes(1);
|
|
const blob = createSpy.mock.calls[0][0] as Blob;
|
|
expect(blob.type).toBe("text/plain;charset=utf-8");
|
|
expect(blob.size).toBe("hello world".length);
|
|
});
|
|
|
|
it("test_noop_when_document_undefined", () => {
|
|
// SSR-style guard: with no `document`, the function returns silently
|
|
// instead of throwing.
|
|
const originalDocument = globalThis.document;
|
|
// @ts-expect-error - intentionally strip document for this case
|
|
delete (globalThis as { document?: Document }).document;
|
|
try {
|
|
expect(() =>
|
|
downloadTextFile("a.txt", "text/plain", "x"),
|
|
).not.toThrow();
|
|
} finally {
|
|
(globalThis as { document?: Document }).document = originalDocument;
|
|
}
|
|
});
|
|
|
|
it("test_download_blob_uses_supplied_blob_and_filename", async () => {
|
|
// The blob variant exists for binary payloads (ZIP, PDF, ...) where
|
|
// the caller already has a Blob and just wants the browser to save
|
|
// it. The anchor's href must point at the supplied blob's object
|
|
// URL and the download attribute must be the filename.
|
|
const captured: HTMLAnchorElement[] = [];
|
|
const appendSpy = vi
|
|
.spyOn(document.body, "appendChild")
|
|
.mockImplementation((node) => {
|
|
if (node instanceof HTMLAnchorElement) captured.push(node);
|
|
return originalAppendChild.call(document.body, node);
|
|
});
|
|
|
|
const blob = new Blob(["PK\u0003\u0004fake-zip"], {
|
|
type: "application/zip",
|
|
});
|
|
downloadBlob("resubmit-2-claims.zip", blob);
|
|
|
|
expect(captured).toHaveLength(1);
|
|
const a = captured[0];
|
|
expect(a.getAttribute("download")).toBe("resubmit-2-claims.zip");
|
|
expect(a.getAttribute("href")).toBe("blob:test-url");
|
|
expect(a.style.display).toBe("none");
|
|
|
|
expect(appendSpy).toHaveBeenCalledTimes(1);
|
|
expect(createSpy).toHaveBeenCalledTimes(1);
|
|
await Promise.resolve();
|
|
expect(revokeSpy).toHaveBeenCalledWith("blob:test-url");
|
|
});
|
|
});
|