// @vitest-environment jsdom /** * Tests for BatchDetailModal (F7) — action footer. * * Coverage cases: * 1. Render batch in_progress → footer shows Cancel (no Download Output/Errors) * 2. Render batch completed with outputFileId → shows Download Output link with download attr * 3. Render batch failed + errorFileId + requestCountsFailed > 0 → shows Download Errors + Retry * 4. Render batch failed (no errorFileId, no failures) → Cancel does NOT appear * 5. Click Cancel → window.confirm → cancel hook called → onClose called * 5b. Cancel confirm dismissed → cancel NOT called * 6. Click Retry → window.confirm → retry hook called → onClose if newBatchId returned * 6b. Retry returns null → onClose NOT called * 7. actionError set → role="alert" shown in DOM * 8. Sanitization: error displayed via i18n key — never raw paths or stack traces * 9. Escape key → onClose called */ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // ── Mocks ────────────────────────────────────────────────────────────────────── // next-intl: return key as-is for straightforward assertions vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); // useBatchActions: fully controlled mock const mockCancel = vi.fn(); const mockRetry = vi.fn(); const mockDownloadHrefOutput = vi.fn((id: string | null | undefined) => id ? `/api/v1/files/${id}/content` : null, ); const mockDownloadHrefErrors = vi.fn((id: string | null | undefined) => id ? `/api/v1/files/${id}/content` : null, ); // Factory state — tests mutate this before each render const hookState = { cancelling: false, retrying: false, error: null as string | null, }; vi.mock( "../../../../../src/app/(dashboard)/dashboard/batch/components/useBatchActions", () => ({ useBatchActions: vi.fn(() => ({ cancel: mockCancel, retry: mockRetry, downloadHrefOutput: mockDownloadHrefOutput, downloadHrefErrors: mockDownloadHrefErrors, cancelling: hookState.cancelling, retrying: hookState.retrying, error: hookState.error, })), }), ); // ── Import after mocks ───────────────────────────────────────────────────────── const { default: BatchDetailModal } = await import( "../../../../../src/app/(dashboard)/dashboard/batch/BatchDetailModal" ); // ── Types ───────────────────────────────────────────────────────────────────── type BatchLike = Parameters[0]["batch"]; type FileLike = Parameters[0]["files"][number]; // ── Fixtures ────────────────────────────────────────────────────────────────── function makeBatch(overrides: Partial = {}): BatchLike { return { id: "batch_abc123", endpoint: "/v1/chat/completions", completionWindow: "24h", status: "in_progress", inputFileId: "file_input_1", outputFileId: null, errorFileId: null, createdAt: Math.floor(Date.now() / 1000) - 300, inProgressAt: Math.floor(Date.now() / 1000) - 200, expiresAt: null, finalizingAt: null, completedAt: null, failedAt: null, expiredAt: null, cancellingAt: null, cancelledAt: null, requestCountsTotal: 100, requestCountsCompleted: 40, requestCountsFailed: 0, metadata: null, errors: null, model: "gpt-4o-mini", usage: null, ...overrides, }; } const FILES: FileLike[] = [ { id: "file_input_1", filename: "input.jsonl", bytes: 10240, purpose: "batch", createdAt: 1700000000 }, { id: "file_output_1", filename: "output.jsonl", bytes: 20480, purpose: "batch_output", createdAt: 1700000100 }, { id: "file_error_1", filename: "errors.jsonl", bytes: 512, purpose: "batch_output", createdAt: 1700000100 }, ]; // ── Render helpers ──────────────────────────────────────────────────────────── type Container = { root: ReturnType; el: HTMLDivElement }; const containers: Container[] = []; function renderModal( batchOverrides: Partial = {}, onClose = vi.fn(), onActionDone?: () => void, ): { el: HTMLDivElement } { const batch = makeBatch(batchOverrides); const el = document.createElement("div"); document.body.appendChild(el); const root = createRoot(el); act(() => { root.render( , ); }); containers.push({ root, el }); return { el }; } // ── Setup / teardown ────────────────────────────────────────────────────────── beforeEach(() => { hookState.cancelling = false; hookState.retrying = false; hookState.error = null; mockCancel.mockReset(); mockRetry.mockReset(); mockDownloadHrefOutput.mockImplementation((id: string | null | undefined) => id ? `/api/v1/files/${id}/content` : null, ); mockDownloadHrefErrors.mockImplementation((id: string | null | undefined) => id ? `/api/v1/files/${id}/content` : null, ); }); afterEach(() => { for (const { root, el } of containers.splice(0)) { act(() => root.unmount()); el.remove(); } vi.clearAllMocks(); vi.restoreAllMocks(); }); // ── Tests ───────────────────────────────────────────────────────────────────── describe("BatchDetailModal — action footer (F7)", () => { // 1. in_progress → Cancel visible, no Download buttons it("shows Cancel button for in_progress batch without download buttons", () => { const { el } = renderModal({ status: "in_progress", outputFileId: null, errorFileId: null }); const buttons = Array.from(el.querySelectorAll("button")); const cancelBtn = buttons.find((b) => b.textContent?.includes("batchDetailActionCancel")); expect(cancelBtn).toBeTruthy(); expect(el.textContent).not.toContain("batchActionDownloadOutput"); expect(el.textContent).not.toContain("batchActionDownloadErrors"); }); // 2. completed + outputFileId → Download Output link with download attribute it("shows Download Output link when batch is completed with outputFileId", () => { const { el } = renderModal({ status: "completed", outputFileId: "file_output_1", errorFileId: null, requestCountsFailed: 0, }); const links = Array.from(el.querySelectorAll("a")); const outputLink = links.find((a) => a.textContent?.includes("batchActionDownloadOutput")); expect(outputLink).toBeTruthy(); expect(outputLink!.getAttribute("download")).not.toBeNull(); expect(outputLink!.getAttribute("href")).toBe("/api/v1/files/file_output_1/content"); // Cancel must NOT appear for completed const buttons = Array.from(el.querySelectorAll("button")); const cancelBtn = buttons.find((b) => b.textContent?.includes("batchDetailActionCancel")); expect(cancelBtn).toBeFalsy(); }); // 3. failed + errorFileId + failures → Download Errors + Retry button it("shows Download Errors and Retry when batch failed with errorFileId and failures", () => { const { el } = renderModal({ status: "failed", outputFileId: null, errorFileId: "file_error_1", requestCountsFailed: 5, }); const errLink = Array.from(el.querySelectorAll("a")).find((a) => a.textContent?.includes("batchActionDownloadErrors"), ); expect(errLink).toBeTruthy(); const retryBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("batchDetailActionRetry"), ); expect(retryBtn).toBeTruthy(); // Cancel must NOT appear for failed const cancelBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("batchDetailActionCancel"), ); expect(cancelBtn).toBeFalsy(); }); // 4. failed, no errorFileId, no failures → no footer action buttons it("does not show Cancel or Retry for failed batch without errors", () => { const { el } = renderModal({ status: "failed", outputFileId: null, errorFileId: null, requestCountsFailed: 0, }); const buttons = Array.from(el.querySelectorAll("button")); expect(buttons.find((b) => b.textContent?.includes("batchDetailActionCancel"))).toBeFalsy(); expect(buttons.find((b) => b.textContent?.includes("batchDetailActionRetry"))).toBeFalsy(); }); // 5. Click Cancel → confirm → cancel hook called → onClose called it("calls cancel hook then onClose after user confirms cancel", async () => { const onClose = vi.fn(); mockCancel.mockResolvedValueOnce(undefined); vi.spyOn(window, "confirm").mockReturnValueOnce(true); const { el } = renderModal({ status: "in_progress" }, onClose); const cancelBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("batchDetailActionCancel"), )!; await act(async () => { cancelBtn.click(); await Promise.resolve(); }); expect(window.confirm).toHaveBeenCalled(); expect(mockCancel).toHaveBeenCalledWith("batch_abc123"); expect(onClose).toHaveBeenCalled(); }); // 5b. Cancel confirm dismissed → cancel NOT called it("does not call cancel when user dismisses the confirm dialog", async () => { vi.spyOn(window, "confirm").mockReturnValueOnce(false); const { el } = renderModal({ status: "in_progress" }); const cancelBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("batchDetailActionCancel"), )!; await act(async () => { cancelBtn.click(); await Promise.resolve(); }); expect(mockCancel).not.toHaveBeenCalled(); }); // 6. Click Retry → confirm → retry called → onClose when newBatchId returned it("calls retry hook then onClose when retry returns a newBatchId", async () => { const onClose = vi.fn(); mockRetry.mockResolvedValueOnce({ newBatchId: "batch_new_xyz" }); vi.spyOn(window, "confirm").mockReturnValueOnce(true); const { el } = renderModal( { status: "failed", errorFileId: "file_error_1", requestCountsFailed: 3 }, onClose, ); const retryBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("batchDetailActionRetry"), )!; await act(async () => { retryBtn.click(); await Promise.resolve(); }); expect(window.confirm).toHaveBeenCalled(); expect(mockRetry).toHaveBeenCalledWith({ id: "batch_abc123", inputFileId: "file_input_1", errorFileId: "file_error_1", endpoint: "/v1/chat/completions", }); expect(onClose).toHaveBeenCalled(); }); // 6b. Retry returns null → onClose NOT called it("does not call onClose when retry returns null", async () => { const onClose = vi.fn(); mockRetry.mockResolvedValueOnce(null); vi.spyOn(window, "confirm").mockReturnValueOnce(true); const { el } = renderModal( { status: "failed", errorFileId: "file_error_1", requestCountsFailed: 2 }, onClose, ); const retryBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("batchDetailActionRetry"), )!; await act(async () => { retryBtn.click(); await Promise.resolve(); }); expect(mockRetry).toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); }); // 7. actionError set → role="alert" shown in DOM it("shows role=alert when hook provides an actionError", () => { hookState.error = "batchActionCancel"; const { el } = renderModal({ status: "in_progress" }); const alert = el.querySelector("[role='alert']"); expect(alert).not.toBeNull(); // t() mock returns key — confirms the i18n route is used, never raw err.message expect(alert!.textContent).toContain("batchActionCancel"); }); // 8. Sanitization: error text never leaks paths or stack traces it("never exposes stack traces or file paths in error display", () => { hookState.error = "batchActionCancel"; const { el } = renderModal({ status: "in_progress" }); const bodyText = el.textContent ?? ""; expect(bodyText).not.toContain("/home/"); expect(bodyText).not.toContain("at /"); expect(bodyText).not.toContain(".ts:"); expect(bodyText).not.toMatch(/Error:/); }); // 9. Escape key → onClose called it("calls onClose when the Escape key is pressed", () => { const onClose = vi.fn(); renderModal({}, onClose); const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); document.dispatchEvent(event); expect(onClose).toHaveBeenCalled(); }); });