chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 \//);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user