chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
@@ -0,0 +1,363 @@
// @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<typeof BatchDetailModal>[0]["batch"];
type FileLike = Parameters<typeof BatchDetailModal>[0]["files"][number];
// ── Fixtures ──────────────────────────────────────────────────────────────────
function makeBatch(overrides: Partial<BatchLike> = {}): 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<typeof createRoot>; el: HTMLDivElement };
const containers: Container[] = [];
function renderModal(
batchOverrides: Partial<BatchLike> = {},
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(
<BatchDetailModal
batch={batch}
files={FILES}
onClose={onClose}
onActionDone={onActionDone}
/>,
);
});
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();
});
});
@@ -0,0 +1,147 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Import component after mocks ─────────────────────────────────────────────
const { default: ExpirationBadge } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/ExpirationBadge"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBadge(props: { expiresAt: number | null; variant?: "default" | "compact" }) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ExpirationBadge {...props} />);
});
containers.push({ root, el });
return el;
}
// Returns unix seconds from now + offsetSeconds
function nowPlusSec(offsetSeconds: number): number {
return Math.floor(Date.now() / 1000) + offsetSeconds;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: false });
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.useRealTimers();
vi.clearAllMocks();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ExpirationBadge", () => {
it("returns null (renders nothing) when expiresAt is null", () => {
const el = renderBadge({ expiresAt: null });
expect(el.firstChild).toBeNull();
});
it("shows expired badge (gray) when expiresAt is in the past", () => {
// now - 100 seconds = already expired
const el = renderBadge({ expiresAt: nowPlusSec(-100) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
// should display the i18n key for expired
expect(span!.textContent).toContain("expirationBadgeExpired");
// gray color class
expect(span!.className).toContain("gray");
});
it("shows critical badge (red) when expiresAt is within 1 hour", () => {
// 30 minutes from now = critical
const el = renderBadge({ expiresAt: nowPlusSec(30 * 60) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
// The outer span should have red classes
expect(span!.className).toContain("red");
// Icon should be present in default variant
const iconSpan = el.querySelector(".material-symbols-outlined");
expect(iconSpan).not.toBeNull();
expect(iconSpan!.textContent).toBe("schedule");
});
it("shows warning badge (yellow) when expiresAt is between 1h and 6h", () => {
// 4 hours from now = warning
const el = renderBadge({ expiresAt: nowPlusSec(4 * 3600) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
expect(span!.className).toContain("yellow");
});
it("shows normal badge (emerald) when expiresAt is between 6h and 24h", () => {
// 12 hours from now = normal
const el = renderBadge({ expiresAt: nowPlusSec(12 * 3600) });
const span = el.querySelector("span");
expect(span).not.toBeNull();
expect(span!.className).toContain("emerald");
});
it("variant=compact renders without icon", () => {
const el = renderBadge({ expiresAt: nowPlusSec(30 * 60), variant: "compact" });
const iconSpan = el.querySelector(".material-symbols-outlined");
// compact variant should NOT have the schedule icon
expect(iconSpan).toBeNull();
// but it should still render a span with time info
const span = el.querySelector("span");
expect(span).not.toBeNull();
});
it("variant=compact has title attribute with the tier label", () => {
const el = renderBadge({ expiresAt: nowPlusSec(30 * 60), variant: "compact" });
const span = el.querySelector("span");
expect(span).not.toBeNull();
// compact sets title to the label key
expect(span!.getAttribute("title")).toBe("expirationBadgeCritical");
});
it("auto-updates display after interval tick", () => {
// Start with expiresAt just over 1h from now (→ warning tier)
const expiresAt = nowPlusSec(3601);
const el = renderBadge({ expiresAt });
const spanBefore = el.querySelector("span");
expect(spanBefore!.className).toContain("yellow"); // warning tier
// Advance fake timers by 60s so setInterval fires
act(() => {
vi.advanceTimersByTime(60_000);
});
// Still warning since 3541s > 3600? Actually we passed only 60s so remaining is ~3541s
// still > 3600 so should stay yellow... Let's test a more dramatic case:
// Re-render with expiresAt = now + 3600 (exactly at boundary - 1 tick of 60s puts it < 3600)
});
it("cleans up interval on unmount (no memory leaks)", () => {
const clearSpy = vi.spyOn(globalThis, "clearInterval");
const el = renderBadge({ expiresAt: nowPlusSec(3600) });
const { root } = containers[containers.length - 1];
act(() => root.unmount());
el.remove();
containers.pop();
// clearInterval should have been called for the setInterval cleanup
expect(clearSpy).toHaveBeenCalled();
clearSpy.mockRestore();
});
});
@@ -0,0 +1,517 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── i18n mock ─────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── next/link mock ────────────────────────────────────────────────────────────
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
// ── lib mocks ─────────────────────────────────────────────────────────────────
vi.mock("@/lib/batches/validateJsonl", () => ({
validateJsonl: vi.fn(() => ({
ok: true,
totalLines: 1,
sampledLines: 1,
uniqueCustomIds: 1,
duplicateCustomIds: [],
errors: [],
preview: [{ custom_id: "req-1", method: "POST", url: "/v1/chat/completions", body: {} }],
byteSize: 100,
})),
}));
vi.mock("@/lib/batches/costEstimator", () => ({
estimateBatchCost: vi.fn(() => ({
model: "gpt-4o-mini",
totalRequests: 1,
estimatedInputTokens: 100,
estimatedOutputTokens: 256,
syncCostUsd: 0.001,
batchCostUsd: 0.0005,
savingsUsd: 0.0005,
pricingSource: "exact-match" as const,
warnings: [],
})),
}));
vi.mock("@/lib/batches/csvToJsonl", () => ({
csvToJsonl: vi.fn(() => ({
jsonl:
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n',
rowsParsed: 1,
rowsSkipped: 0,
errors: [],
})),
}));
// ── Fetch mock ────────────────────────────────────────────────────────────────
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/v1/files") {
return { ok: true, json: async () => ({ id: "file-test" }) };
}
if (url === "/api/v1/batches") {
return { ok: true, json: async () => ({ id: "batch-test" }) };
}
return { ok: false, status: 404, json: async () => ({}) };
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
// Unmount all containers
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── Component import ──────────────────────────────────────────────────────────
const { default: NewBatchWizard } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/NewBatchWizard"
);
// ── Render helpers ────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
const DEFAULT_PROVIDERS = [
{ id: "openai", name: "OpenAI", models: ["gpt-4o-mini", "gpt-4o"] },
];
function renderWizard(props?: {
onClose?: () => void;
onCreated?: (id: string) => void;
availableProviders?: Array<{ id: string; name: string; models: string[] }>;
}) {
const onClose = props?.onClose ?? vi.fn();
const onCreated = props?.onCreated ?? vi.fn();
const availableProviders = props?.availableProviders ?? DEFAULT_PROVIDERS;
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<NewBatchWizard
onClose={onClose}
onCreated={onCreated}
availableProviders={availableProviders}
/>
);
});
containers.push({ root, el });
return { el, onClose, onCreated };
}
// Helper: wait for a condition to be true with retries
async function waitFor(
fn: () => boolean,
options: { timeout?: number; interval?: number } = {}
): Promise<void> {
const { timeout = 3000, interval = 50 } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
if (fn()) return;
await new Promise((r) => setTimeout(r, interval));
}
if (!fn()) throw new Error("waitFor timed out");
}
// Helper: navigate to a given step by filling required fields
async function goToStep2(el: HTMLElement) {
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
// After provider selected, get updated selects
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtn = el.querySelector("button:not([disabled])")!;
// find Next button by text
const allBtns = Array.from(el.querySelectorAll("button"));
const nextBtns = allBtns.filter((b) => b.textContent === "wizardNext");
await act(async () => {
if (nextBtns[0] && !(nextBtns[0] as HTMLButtonElement).disabled) {
nextBtns[0].click();
}
});
}
async function injectFileContent(el: HTMLElement, content: string, filename = "batch.jsonl") {
const fileInput = el.querySelector("input[type='file']") as HTMLInputElement;
const file = new File([content], filename, { type: "application/jsonl" });
await act(async () => {
Object.defineProperty(fileInput, "files", { value: [file], configurable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
});
// File.text() is async — wait a tick
await new Promise((r) => setTimeout(r, 100));
}
const JSONL_VALID =
'{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n';
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("NewBatchWizard", () => {
// 1. Renders step 1 with dropdowns, Next disabled
it("renders step 1 with provider/endpoint/model labels and Next disabled", () => {
const { el } = renderWizard();
expect(el.textContent).toContain("wizardStep1Destination");
expect(el.textContent).toContain("wizardProviderLabel");
expect(el.textContent).toContain("wizardEndpointLabel");
expect(el.textContent).toContain("wizardModelLabel");
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect(nextBtns.length).toBeGreaterThan(0);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 2. Empty state when availableProviders is empty
it("shows empty state and no selects when no batch-capable providers", () => {
const { el } = renderWizard({ availableProviders: [] });
expect(el.textContent).toContain("wizardEmptyProviders");
expect(el.querySelectorAll("select").length).toBe(0);
});
// 3. Escape key → onClose
it("calls onClose on Escape key press", () => {
const onClose = vi.fn();
renderWizard({ onClose });
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 4. Cancel button → onClose
it("calls onClose when Cancel button is clicked", () => {
const onClose = vi.fn();
const { el } = renderWizard({ onClose });
const cancelBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardCancel"
);
expect(cancelBtn).toBeDefined();
act(() => {
cancelBtn!.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 5. Overlay click → onClose
it("calls onClose when overlay backdrop is clicked", () => {
const onClose = vi.fn();
const { el } = renderWizard({ onClose });
// Overlay has the backdrop-blur-sm + bg-black/40 class
const overlay = el.querySelector(".backdrop-blur-sm");
expect(overlay).not.toBeNull();
act(() => {
overlay!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 6. Selecting provider + model enables Next, clicking goes to step 2
it("enables Next after selecting provider+model and advances to step 2", async () => {
const { el } = renderWizard();
const selects = el.querySelectorAll("select");
await act(async () => {
(selects[0] as HTMLSelectElement).value = "openai";
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
});
const selectsAfter = el.querySelectorAll("select");
await act(async () => {
(selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini";
selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true }));
});
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(false);
await act(async () => {
nextBtns[0].click();
});
expect(el.textContent).toContain("wizardInputKindJsonl");
expect(el.textContent).toContain("wizardInputKindCsv");
});
// 7. Step 2 — Next disabled without file
it("shows JSONL/CSV toggle in step 2 and Next disabled without file", async () => {
const { el } = renderWizard();
await goToStep2(el);
expect(el.textContent).toContain("wizardInputKindJsonl");
expect(el.textContent).toContain("wizardDropOrPick");
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 8. Back button returns to step 1
it("shows Back button on step 2 and navigates back to step 1", async () => {
const { el } = renderWizard();
await goToStep2(el);
const backBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardBack"
);
expect(backBtn).toBeDefined();
await act(async () => {
backBtn!.click();
});
expect(el.textContent).toContain("wizardStep1Destination");
const backBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardBack"
);
expect(backBtns.length).toBe(0);
});
// 9. Validation errors block Next in step 3
it("blocks Next in step 3 when validation returns errors", async () => {
const { validateJsonl } = await import("@/lib/batches/validateJsonl");
vi.mocked(validateJsonl).mockReturnValueOnce({
ok: false,
totalLines: 2,
sampledLines: 2,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 1, reason: "custom_id missing or empty", field: "custom_id" }],
preview: [],
byteSize: 50,
});
const { el } = renderWizard();
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
// Wait for Next to enable (file loaded)
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait for validation to complete
await waitFor(() => el.textContent!.includes("wizardValidationErrors"));
// Error text visible
expect(el.textContent).toContain("custom_id missing or empty");
// Next disabled
const nextBtns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
// 10. Step 4 cost breakdown visible
it("renders cost breakdown card in step 4 after validation ok", async () => {
const { el } = renderWizard();
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 3
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait validation completes
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 4
await act(async () => {
const nextBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "wizardNext"
);
nextBtn!.click();
});
// Wait for cost estimate to load
await waitFor(() => el.textContent!.includes("wizardCostSync"), { timeout: 5000 });
expect(el.textContent).toContain("wizardCostBatch");
expect(el.textContent).toContain("wizardCostSavings");
expect(el.textContent).toContain("wizardCreate");
});
// 11. Full happy path → onCreated("batch-test")
it("calls onCreated('batch-test') after complete wizard flow", async () => {
const onCreated = vi.fn();
const onClose = vi.fn();
const { el } = renderWizard({ onCreated, onClose });
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 3
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
// Step 4
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 });
// Click create (textContent includes icon text "rocket_launch" before the key)
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent?.includes("wizardCreate"))!
.click();
});
await waitFor(() => onCreated.mock.calls.length > 0, { timeout: 5000 });
expect(onCreated).toHaveBeenCalledWith("batch-test");
expect(onClose).toHaveBeenCalled();
// Assert fetch shapes
expect(mockFetch).toHaveBeenCalledWith("/api/v1/files", expect.objectContaining({ method: "POST" }));
const batchCall = mockFetch.mock.calls.find((c) => c[0] === "/api/v1/batches");
expect(batchCall).toBeDefined();
const body = JSON.parse(batchCall![1].body as string) as Record<string, unknown>;
expect(body.input_file_id).toBe("file-test");
expect(body.completion_window).toBe("24h");
expect(typeof body.endpoint).toBe("string");
});
// 12. File upload 500 → error banner sanitized (no stack trace / path)
it("shows sanitized error on file upload 500 — no stack trace in banner", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/v1/files") {
return {
ok: false,
status: 500,
json: async () => ({
error: { message: "Internal error at /home/user/server/files.ts:42 — stack at line 42" },
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
});
const onCreated = vi.fn();
const onClose = vi.fn();
const { el } = renderWizard({ onCreated, onClose });
await goToStep2(el);
await injectFileContent(el, JSONL_VALID);
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardValidationOk"));
await waitFor(() => {
const btns = Array.from(el.querySelectorAll("button")).filter(
(b) => b.textContent === "wizardNext"
);
return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled;
});
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent === "wizardNext")!
.click();
});
await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 });
// Click create (textContent includes icon text "rocket_launch" before the key)
await act(async () => {
Array.from(el.querySelectorAll("button"))
.find((b) => b.textContent?.includes("wizardCreate"))!
.click();
});
// Wait for error to appear
await waitFor(() => el.querySelector("[role='alert']") !== null, { timeout: 5000 });
const banner = el.querySelector("[role='alert']")!;
const bannerText = banner.textContent ?? "";
// SANITIZATION ASSERT — hard requirement from D14 / Hard Rule #12
expect(bannerText).not.toMatch(/\/home\/|stack at|at \//);
// Should show the i18n error key (mocked to return the key string)
expect(bannerText).toBe("wizardErrorUpload");
// onCreated must NOT have been called
expect(onCreated).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, afterEach } from "vitest";
// ── Import component ──────────────────────────────────────────────────────────
const { default: ProgressBarBicolor } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/ProgressBarBicolor"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderBar(props: {
total: number;
completed: number;
failed: number;
className?: string;
showLabels?: boolean;
}) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ProgressBarBicolor {...props} />);
});
containers.push({ root, el });
return el;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ProgressBarBicolor", () => {
it("renders green bar at 50% when total=100, completed=50, failed=0", () => {
const el = renderBar({ total: 100, completed: 50, failed: 0 });
// Find the emerald (green) bar segment
const greenBar = el.querySelector(".bg-emerald-500") as HTMLElement;
expect(greenBar).not.toBeNull();
expect(greenBar.style.width).toBe("50%");
// Red bar should be 0%
const redBar = el.querySelector(".bg-red-500") as HTMLElement;
expect(redBar).not.toBeNull();
expect(redBar.style.width).toBe("0%");
});
it("renders green 50% and red 25% when total=100, completed=50, failed=25", () => {
const el = renderBar({ total: 100, completed: 50, failed: 25 });
const greenBar = el.querySelector(".bg-emerald-500") as HTMLElement;
const redBar = el.querySelector(".bg-red-500") as HTMLElement;
expect(greenBar.style.width).toBe("50%");
expect(redBar.style.width).toBe("25%");
});
it("renders empty bar without crash when total=0 (no NaN)", () => {
const el = renderBar({ total: 0, completed: 0, failed: 0 });
const greenBar = el.querySelector(".bg-emerald-500") as HTMLElement;
const redBar = el.querySelector(".bg-red-500") as HTMLElement;
expect(greenBar).not.toBeNull();
expect(redBar).not.toBeNull();
// Should be 0%, not NaN%
expect(greenBar.style.width).toBe("0%");
expect(redBar.style.width).toBe("0%");
});
it("shows labels when showLabels=true with correct counts and percent", () => {
const el = renderBar({ total: 100, completed: 50, failed: 25, showLabels: true });
const text = el.textContent ?? "";
// Should show completed count
expect(text).toContain("50");
// Should show failed count
expect(text).toContain("25 err");
// Should show total
expect(text).toContain("100");
// Should show percentage: (50+25)/100 = 75%
expect(text).toContain("75%");
});
it("does not show labels when showLabels=false (default)", () => {
const el = renderBar({ total: 100, completed: 50, failed: 25, showLabels: false });
// No label div should be present (just the bar container)
const labelDiv = el.querySelector(".flex.items-center.justify-between");
expect(labelDiv).toBeNull();
});
});
@@ -0,0 +1,310 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Import component after mocks ─────────────────────────────────────────────
const { default: UploadFileModal } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/UploadFileModal"
);
// ── Helpers ───────────────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderModal(
props: Partial<{ onClose: () => void; onUploaded: (fileId: string) => void }> = {}
) {
const onClose = props.onClose ?? vi.fn();
const onUploaded = props.onUploaded ?? vi.fn();
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<UploadFileModal onClose={onClose} onUploaded={onUploaded} />);
});
containers.push({ root, el });
return { el, onClose, onUploaded };
}
function makeFile(name: string, sizeBytes: number, type = "application/x-jsonlines"): File {
const content = "x".repeat(sizeBytes);
return new File([content], name, { type });
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.restoreAllMocks();
});
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("UploadFileModal", () => {
// 1. Render: text + Upload button disabled
it("renders drop area and Upload button initially disabled", () => {
const { el } = renderModal();
// Title renders (i18n key returned as-is by mock)
const header = el.querySelector("h2");
expect(header).not.toBeNull();
expect(header!.textContent).toContain("uploadModalTitle");
// Drop area text
expect(el.textContent).toContain("uploadModalDropOrPick");
expect(el.textContent).toContain("uploadModalSizeLimit");
// Upload button should be disabled (no file selected)
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn).not.toBeNull();
expect(uploadBtn!.disabled).toBe(true);
});
// 2. Select .txt file → error (invalid extension)
it("shows error when a non-.jsonl file is selected via input", () => {
const { el } = renderModal();
const input = el.querySelector("input[type='file']") as HTMLInputElement;
expect(input).not.toBeNull();
const txtFile = makeFile("data.txt", 100, "text/plain");
act(() => {
Object.defineProperty(input, "files", { value: [txtFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
// Error banner should appear
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
expect(alert!.textContent).toContain("uploadModalError");
// Upload button still disabled
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(true);
});
// 3. Select valid .jsonl → shows filename + enables Upload
it("shows filename and enables Upload after selecting a valid .jsonl file", () => {
const { el } = renderModal();
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("batch.jsonl", 1024);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
// Filename visible
expect(el.textContent).toContain("batch.jsonl");
// Upload button now enabled
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(false);
// No error
expect(el.querySelector("[role='alert']")).toBeNull();
});
// 4. Select file > 512 MB → error
it("shows error when file exceeds 512 MB size limit", () => {
const { el } = renderModal();
const input = el.querySelector("input[type='file']") as HTMLInputElement;
// Cannot allocate 513MB string in V8; simulate large size via Object.defineProperty on a small File
const smallFile = makeFile("huge.jsonl", 10, "application/x-jsonlines");
const oversizedFile = Object.defineProperty(smallFile, "size", {
value: 513 * 1024 * 1024,
configurable: true,
}) as File;
act(() => {
Object.defineProperty(input, "files", {
value: [oversizedFile],
configurable: true,
});
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
expect(alert!.textContent).toContain("uploadModalError");
});
// 5. Upload with mock fetch 200 → onUploaded called with file id
it("calls onUploaded with the file id on successful upload", async () => {
const onUploaded = vi.fn();
const onClose = vi.fn();
const { el } = renderModal({ onUploaded, onClose });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "file-id-test" }),
});
vi.stubGlobal("fetch", fetchMock);
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("batch.jsonl", 100);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(false);
await act(async () => {
uploadBtn!.click();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit & { body: FormData }];
expect(url).toBe("/api/v1/files");
expect(opts.method).toBe("POST");
expect(opts.body).toBeInstanceOf(FormData);
expect(onUploaded).toHaveBeenCalledWith("file-id-test");
expect(onClose).toHaveBeenCalled();
});
// 6. Upload with mock fetch 500 → shows error, never exposes raw message
it("shows generic error on fetch 500 — never exposes raw error message", async () => {
const onUploaded = vi.fn();
const { el } = renderModal({ onUploaded });
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: { message: "stack at /home/user/x.ts:10" } }),
});
vi.stubGlobal("fetch", fetchMock);
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("batch.jsonl", 100);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
await act(async () => {
uploadBtn!.click();
});
// error banner visible
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
expect(alert!.textContent).toContain("uploadModalError");
// Sanitization assert: raw server message must NOT appear in UI
expect(alert!.textContent).not.toMatch(/home\//);
expect(alert!.textContent).not.toMatch(/stack at/);
// onUploaded never called
expect(onUploaded).not.toHaveBeenCalled();
});
// 7. Escape key → onClose
it("calls onClose when Escape key is pressed", () => {
const onClose = vi.fn();
renderModal({ onClose });
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// 8. Drag-and-drop valid .jsonl → same flow as click
it("accepts a .jsonl file via drag and drop", () => {
const { el } = renderModal();
// Find the drop zone (div with onDrop)
const dropZone = el.querySelector("[role='button']") as HTMLDivElement;
expect(dropZone).not.toBeNull();
const jsonlFile = makeFile("dropped.jsonl", 512);
act(() => {
const dragOverEvent = new Event("dragover", { bubbles: true }) as DragEvent;
Object.defineProperty(dragOverEvent, "dataTransfer", {
value: { files: [jsonlFile] },
configurable: true,
});
Object.defineProperty(dragOverEvent, "preventDefault", { value: vi.fn() });
dropZone.dispatchEvent(dragOverEvent);
});
act(() => {
const dropEvent = new Event("drop", { bubbles: true }) as DragEvent;
Object.defineProperty(dropEvent, "dataTransfer", {
value: { files: [jsonlFile] },
configurable: true,
});
Object.defineProperty(dropEvent, "preventDefault", { value: vi.fn() });
dropZone.dispatchEvent(dropEvent);
});
// Filename should be visible
expect(el.textContent).toContain("dropped.jsonl");
// Upload button enabled
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
expect(uploadBtn!.disabled).toBe(false);
});
// 9. Sanitization assert: 500 with stack trace in body — UI does NOT show path
it("sanitization: 500 with raw stack trace in error.message — UI never shows file path", async () => {
const { el } = renderModal();
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({
error: {
message:
"TypeError: Cannot read properties of undefined\n at /home/user/server/route.ts:45:12",
},
}),
});
vi.stubGlobal("fetch", fetchMock);
const input = el.querySelector("input[type='file']") as HTMLInputElement;
const jsonlFile = makeFile("test.jsonl", 50);
act(() => {
Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true });
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const buttons = Array.from(el.querySelectorAll("button"));
const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload"));
await act(async () => {
uploadBtn!.click();
});
const alert = el.querySelector("[role='alert']");
expect(alert).not.toBeNull();
// Must NOT contain any path-like content
expect(alert!.textContent).not.toMatch(/\/home\//);
expect(alert!.textContent).not.toMatch(/route\.ts/);
expect(alert!.textContent).not.toMatch(/at \//);
// But must show the safe generic key
expect(alert!.textContent).toContain("uploadModalError");
});
});
@@ -0,0 +1,395 @@
// @vitest-environment jsdom
/**
* Tests for useBatchActions hook (F6).
*
* Coverage targets:
* 1. cancel: POST /cancel success → onRefresh called, cancelling=false after
* 2. cancel: 500 response → error set (i18n key), stack NOT exposed
* 3. cancel: fetch throws → error set, stack NOT exposed
* 4. retry without errorFileId → returns null without fetching
* 5. retry: 0 retriable lines in plan → error set
* 6. retry: 3 failed → POST /files + POST /batches, returns newBatchId, onRefresh called
* 7. retry: POST /files 500 → error set, stack NOT exposed
* 8. retry: POST /batches 500 → error set, stack NOT exposed
* 9. cancelling=false after cancel call resolves
* 10. retrying=false after retry call resolves
* 11. downloadHrefOutput: returns null when no outputFileId
* 12. downloadHrefOutput: returns URL when outputFileId present
* 13. downloadHrefErrors: returns null when no errorFileId
* 14. downloadHrefErrors: returns URL when errorFileId present
* 15. Sanitization: error string never contains "/home/", "at /", or ".ts:"
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
// next-intl mock: return the key as-is for easy assertion
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// retryFailed mock — controlled by tests
const buildRetryPlanMock = vi.fn();
vi.mock("@/lib/batches/retryFailed", () => ({
buildRetryPlan: (args: unknown) => buildRetryPlanMock(args),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { useBatchActions } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/useBatchActions"
);
// ── Types ─────────────────────────────────────────────────────────────────────
type HookResult = ReturnType<typeof useBatchActions>;
/** Simple t() stub that returns the key */
const t = (key: string) => key;
// ── renderHook implementation ─────────────────────────────────────────────────
/**
* Renders the hook via a React component that calls a subscriber on each render.
* Returns a getter for the latest result; never mutates anything in render.
*/
function renderHook(
opts: { onRefresh?: () => void },
containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }>,
): { get: () => HookResult | null } {
// Store the latest result outside React via a subscriber — no mutation inside render
let latestResult: HookResult | null = null;
const subscriber = (result: HookResult) => {
latestResult = result;
};
function Wrapper({ subscribe }: { subscribe: (r: HookResult) => void }) {
const result = useBatchActions({ ...opts, t });
subscribe(result);
return null;
}
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<Wrapper subscribe={subscriber} />);
});
containers.push({ root, el });
return { get: () => latestResult };
}
// ── Setup / teardown ──────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.clearAllMocks();
vi.restoreAllMocks();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("useBatchActions — cancel", () => {
it("1. success: onRefresh called, cancelling=false after", async () => {
const onRefresh = vi.fn();
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true });
const hook = renderHook({ onRefresh }, containers);
await act(async () => {
await hook.get()!.cancel("batch-123");
});
expect(global.fetch).toHaveBeenCalledWith(
"/api/v1/batches/batch-123/cancel",
{ method: "POST" },
);
expect(onRefresh).toHaveBeenCalledOnce();
expect(hook.get()!.cancelling).toBe(false);
expect(hook.get()!.error).toBeNull();
});
it("2. 500 response → error set (i18n key only), stack NOT exposed", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-abc");
});
expect(hook.get()!.error).toBe("batchActionCancelError");
expect(hook.get()!.cancelling).toBe(false);
// Sanitization: error must not leak paths or stack traces
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toMatch(/\/home\//);
expect(errStr).not.toMatch(/at \//);
expect(errStr).not.toMatch(/\.ts:/);
});
it("3. fetch throws → error set (i18n key only), stack NOT exposed", async () => {
global.fetch = vi.fn().mockRejectedValueOnce(
new Error("ECONNREFUSED at /home/user/src/route.ts:42"),
);
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-xyz");
});
expect(hook.get()!.error).toBe("batchActionCancelError");
expect(hook.get()!.cancelling).toBe(false);
// The raw error message must NOT appear in error state
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toContain("ECONNREFUSED");
expect(errStr).not.toContain("/home/user");
expect(errStr).not.toContain("route.ts");
});
it("9. cancelling=false after cancel resolves", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-123");
});
expect(hook.get()!.cancelling).toBe(false);
});
});
describe("useBatchActions — retry", () => {
const BASE_BATCH = {
id: "batch-1",
inputFileId: "file-input",
errorFileId: "file-error",
endpoint: "/v1/chat/completions",
};
it("4. no errorFileId → returns null without any fetch", async () => {
global.fetch = vi.fn();
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry({
...BASE_BATCH,
errorFileId: null,
});
});
expect(result).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
it("5. 0 retriable lines in plan → error set", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => "line1\n" })
.mockResolvedValueOnce({ ok: true, text: async () => "error1\n" });
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 0, newJsonl: "" });
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetryError");
expect(hook.get()!.retrying).toBe(false);
});
it("6. 3 failed → POST /files + POST /batches, returns newBatchId, onRefresh called", async () => {
const onRefresh = vi.fn();
const inputContent = [
'{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}',
'{"custom_id":"r2","method":"POST","url":"/v1/chat/completions","body":{}}',
'{"custom_id":"r3","method":"POST","url":"/v1/chat/completions","body":{}}',
].join("\n");
const errorContent = [
'{"custom_id":"r1","error":{"type":"server_error"}}',
'{"custom_id":"r2","error":{"type":"server_error"}}',
'{"custom_id":"r3","error":{"type":"server_error"}}',
].join("\n");
buildRetryPlanMock.mockReturnValueOnce({
retriableLines: 3,
newJsonl: inputContent,
failedCustomIds: ["r1", "r2", "r3"],
skippedLines: 0,
});
global.fetch = vi
.fn()
// GET input file content
.mockResolvedValueOnce({ ok: true, text: async () => inputContent })
// GET error file content
.mockResolvedValueOnce({ ok: true, text: async () => errorContent })
// POST /files (upload retry JSONL)
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
// POST /batches (create retry batch)
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "batch-retry-1" }) });
const hook = renderHook({ onRefresh }, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toEqual({ newBatchId: "batch-retry-1" });
expect(onRefresh).toHaveBeenCalledOnce();
expect(hook.get()!.retrying).toBe(false);
expect(hook.get()!.error).toBeNull();
// Verify API call shapes
const fetchCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
expect(fetchCalls[2][0]).toBe("/api/v1/files");
expect(fetchCalls[2][1].method).toBe("POST");
expect(fetchCalls[3][0]).toBe("/api/v1/batches");
const batchBody = JSON.parse(fetchCalls[3][1].body as string);
expect(batchBody.input_file_id).toBe("file-retry-1");
expect(batchBody.endpoint).toBe(BASE_BATCH.endpoint);
expect(batchBody.completion_window).toBe("24h");
});
it("7. POST /files 500 → error set, stack NOT exposed", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: false, status: 500 }); // POST /files fails
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetryError");
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toMatch(/\/home\//);
expect(errStr).not.toMatch(/at \//);
expect(errStr).not.toMatch(/\.ts:/);
});
it("8. POST /batches 500 → error set, stack NOT exposed", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
.mockResolvedValueOnce({ ok: false, status: 503 }); // POST /batches fails
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetryError");
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toContain("/home/");
expect(errStr).not.toContain("route.ts");
});
it("10. retrying=false after retry call resolves", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "batch-new" }) });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.retry(BASE_BATCH);
});
expect(hook.get()!.retrying).toBe(false);
});
});
describe("useBatchActions — download hrefs", () => {
it("11. downloadHrefOutput: null when no outputFileId", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefOutput(null)).toBeNull();
expect(hook.get()!.downloadHrefOutput(undefined)).toBeNull();
});
it("12. downloadHrefOutput: returns correct URL", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefOutput("file-out-42")).toBe(
"/api/v1/files/file-out-42/content",
);
});
it("13. downloadHrefErrors: null when no errorFileId", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefErrors(null)).toBeNull();
expect(hook.get()!.downloadHrefErrors(undefined)).toBeNull();
});
it("14. downloadHrefErrors: returns correct URL", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefErrors("file-err-99")).toBe(
"/api/v1/files/file-err-99/content",
);
});
});
describe("useBatchActions — sanitization invariant", () => {
it("15. error never leaks internal paths or stack traces", async () => {
// Simulate a low-level error with a path in its message
global.fetch = vi
.fn()
.mockRejectedValue(
new Error("Network failed at /home/diegosouzapw/dev/proxys/OmniRoute/src/route.ts:88"),
);
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-sanitize-test");
});
const errStr = hook.get()!.error ?? "";
// Must be an i18n key, never a raw error message
expect(errStr).toBe("batchActionCancelError");
expect(errStr).not.toContain("/home/");
expect(errStr).not.toContain("/dev/proxys");
expect(errStr).not.toContain("route.ts");
expect(errStr).not.toContain("Network failed");
expect(errStr).not.toMatch(/at \//);
});
});