test(BudgetSection): add 28-case vitest suite
Covers: loading/error/402 exceeded states, budget stats row, progress bar (0%/100%/capped), unlimited mode, input pre-fill, save with correct PATCH payload, null→unlimited, explicit 0, Saving... state, save error, exceeded banner clear/re-show. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1fc5599925
commit
2967f99e1b
@ -1,330 +1,343 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
/**
|
||||
* Tests for BudgetSection — budget limit display and editor in the details panel.
|
||||
*
|
||||
* Coverage:
|
||||
* - Loading state
|
||||
* - Error state (non-402)
|
||||
* - Budget exceeded banner (402)
|
||||
* - Budget stats row (used / limit)
|
||||
* - Progress bar (only when limit set)
|
||||
* - Remaining credits display
|
||||
* - Input: pre-filled from budget_limit
|
||||
* - Input: empty when budget_limit is null
|
||||
* - Save: PATCH with correct payload
|
||||
* - Save success: updates display + clears exceeded
|
||||
* - Save error: shows error message
|
||||
* - Saving... state
|
||||
* - Limit 0 is sent as explicit 0 (not null)
|
||||
* - Budget exceeded on save clears and re-shows banner
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BudgetSection } from "../BudgetSection";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Queue-based mock for the api module. Each api call shifts from the queue.
|
||||
// Tests push with qGet/qPatch and the module-level mockImplementation
|
||||
// reads from the queue.
|
||||
type QueueEntry = { body?: unknown; err?: Error };
|
||||
const apiQueue: QueueEntry[] = [];
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve([])));
|
||||
const mockPatch = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve({})));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(async (path: string) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error(`api.get queue exhausted at: ${path}`);
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
patch: vi.fn(async (path: string, _body?: unknown) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error(`api.patch queue exhausted at: ${path}`);
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
},
|
||||
api: { get: mockGet, patch: mockPatch, post: vi.fn(), put: vi.fn(), del: vi.fn() },
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
apiQueue.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const BUDGET_FIXTURE = {
|
||||
budget_limit: 1000,
|
||||
budget_used: 350,
|
||||
budget_remaining: 650,
|
||||
};
|
||||
|
||||
const WS_ID = "budget-test-ws";
|
||||
|
||||
function qGet(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
function budget(overrides: Partial<typeof BUDGET_FIXTURE> = {}): typeof BUDGET_FIXTURE {
|
||||
return { ...BUDGET_FIXTURE, ...overrides };
|
||||
}
|
||||
|
||||
function qGetErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
function qPatch(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qPatchErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function makeBudget(overrides: Partial<{
|
||||
budget_limit: number | null;
|
||||
budget_used: number;
|
||||
budget_remaining: number | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
budget_limit: 10_000,
|
||||
budget_used: 3_500,
|
||||
budget_remaining: 6_500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BudgetSection", () => {
|
||||
describe("loading state", () => {
|
||||
it("shows loading indicator while fetching", async () => {
|
||||
let resolveGet: (v: unknown) => void;
|
||||
vi.mocked(api.get).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
|
||||
);
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPatch.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("budget-loading")).toBeTruthy();
|
||||
// ── Loading ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve after render to verify state clears
|
||||
resolveGet!(makeBudget());
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
it("shows loading state while fetching", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-loading")).toBeTruthy();
|
||||
expect(screen.getByText("Loading…")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Error ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it("shows error message when GET rejects with non-402", async () => {
|
||||
mockGet.mockRejectedValue(new Error("connection refused"));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
expect(screen.getByText(/connection refused/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows budget exceeded banner on 402 GET error", async () => {
|
||||
const err = new Error("POST https://api.example.com: 402 Payment Required");
|
||||
mockGet.mockRejectedValue(err);
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
expect(screen.getByText(/budget exceeded/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows exceeded banner AND fetch error together when 402 hides budget shape", async () => {
|
||||
// After 402, budget is null — no stats shown, but banner is up
|
||||
const err = new Error("GET https://api.example.com: 402");
|
||||
mockGet.mockRejectedValue(err);
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
expect(screen.queryByTestId("budget-stats-row")).toBeFalsy();
|
||||
});
|
||||
|
||||
// ── Budget stats ────────────────────────────────────────────────────────────
|
||||
|
||||
it("renders used and limit values", async () => {
|
||||
mockGet.mockResolvedValue(budget({ budget_used: 750, budget_limit: 1000 }));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-used-value").textContent).toBe("750");
|
||||
expect(screen.getByTestId("budget-limit-value").textContent).toBe("1,000");
|
||||
});
|
||||
|
||||
it("renders 'Unlimited' when budget_limit is null", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: null, budget_remaining: null });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-limit-value").textContent).toBe("Unlimited");
|
||||
});
|
||||
|
||||
it("renders remaining credits", async () => {
|
||||
mockGet.mockResolvedValue(budget({ budget_remaining: 999 }));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-remaining")).toBeTruthy();
|
||||
expect(screen.getByText(/999 credits remaining/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 0 credits remaining", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 100, budget_remaining: 0 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/0 credits remaining/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Progress bar ────────────────────────────────────────────────────────────
|
||||
|
||||
it("renders progress bar when limit is set", async () => {
|
||||
mockGet.mockResolvedValue(budget({ budget_limit: 200, budget_used: 100 }));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("progressbar")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides progress bar when budget_limit is null", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: null, budget_remaining: null });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.queryByRole("progressbar")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("progress bar is at 100% when budget_used equals budget_limit", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 500, budget_used: 500, budget_remaining: 0 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill).toBeTruthy();
|
||||
expect(fill.style.width).toBe("100%");
|
||||
});
|
||||
|
||||
it("progress bar is capped at 100% when budget_used exceeds budget_limit", async () => {
|
||||
// Catches over-budget; budget_remaining could be negative from platform
|
||||
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 200, budget_remaining: -100 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill.style.width).toBe("100%");
|
||||
});
|
||||
|
||||
it("progress bar width is 0% when no usage", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 0, budget_remaining: 1000 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
it("aria-valuenow reflects percentage", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 25, budget_remaining: 75 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const pb = screen.getByRole("progressbar");
|
||||
expect(pb.getAttribute("aria-valuenow")).toBe("25");
|
||||
});
|
||||
|
||||
// ── Input ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it("pre-fills input from budget_limit", async () => {
|
||||
mockGet.mockResolvedValue(budget({ budget_limit: 500 }));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("500");
|
||||
});
|
||||
|
||||
it("pre-fills input as empty string when budget_limit is null", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: null, budget_remaining: null });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("pre-fills input as '0' when budget_limit is 0", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 0, budget_used: 0, budget_remaining: null });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("0");
|
||||
});
|
||||
|
||||
it("input changes update state", async () => {
|
||||
mockGet.mockResolvedValue(budget());
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const input = screen.getByTestId("budget-limit-input");
|
||||
fireEvent.change(input, { target: { value: "2500" } });
|
||||
await flush();
|
||||
expect((input as HTMLInputElement).value).toBe("2500");
|
||||
});
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("PATCHes correct payload on Save", async () => {
|
||||
mockGet.mockResolvedValue(budget({ budget_limit: 1000 }));
|
||||
mockPatch.mockResolvedValue({ budget_limit: 2000, budget_used: 350, budget_remaining: -1650 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "2000" } });
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(mockPatch).toHaveBeenCalledWith("/workspaces/ws-1/budget", {
|
||||
budget_limit: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch error state", () => {
|
||||
it("shows error message on non-402 fetch failure", async () => {
|
||||
qGetErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
|
||||
});
|
||||
|
||||
it("shows 402 as exceeded banner, not fetch error", async () => {
|
||||
// 402 means the budget limit was hit — different UX from a network/API error.
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
|
||||
it("sends null when input is cleared (unlimited)", async () => {
|
||||
mockGet.mockResolvedValue(budget({ budget_limit: 1000 }));
|
||||
mockPatch.mockResolvedValue({ budget_limit: null, budget_used: 350, budget_remaining: null });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const input = screen.getByTestId("budget-limit-input");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(mockPatch).toHaveBeenCalledWith("/workspaces/ws-1/budget", {
|
||||
budget_limit: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget loaded — display", () => {
|
||||
it("renders used / limit stats row", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
|
||||
});
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
|
||||
});
|
||||
|
||||
it("renders 'Unlimited' when budget_limit is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders remaining credits when present", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits remaining credits when budget_remaining is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-remaining")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("caps progress bar at 100% when used > limit", async () => {
|
||||
// Over-limit: 12000 used of 10000 limit should show 100%, not 120%.
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill.getAttribute("style")).toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits progress bar when budget_limit is null (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
|
||||
});
|
||||
it("sends 0 when input is set to '0' (explicit zero, not unlimited)", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 0, budget_remaining: 1000 });
|
||||
mockPatch.mockResolvedValue({ budget_limit: 0, budget_used: 0, budget_remaining: 0 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "0" } });
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(mockPatch).toHaveBeenCalledWith("/workspaces/ws-1/budget", {
|
||||
budget_limit: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget exceeded (402)", () => {
|
||||
it("shows exceeded banner when load returns 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears exceeded banner after successful save", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input");
|
||||
fireEvent.change(input, { target: { value: "50000" } });
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
it("shows 'Saving...' during save", async () => {
|
||||
mockGet.mockResolvedValue(budget());
|
||||
mockPatch.mockImplementation(() => new Promise(() => {}));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("Saving…")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("save flow", () => {
|
||||
it("shows save error on non-402 patch failure", async () => {
|
||||
qGet(makeBudget());
|
||||
qPatchErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates input to new limit value after successful save", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: 20_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
// Wait for the input to appear (loading → loaded)
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
// Debug: check what values are rendered
|
||||
const limitValue = screen.getByTestId("budget-limit-value")?.textContent;
|
||||
expect(input.value).toBe("10000"); // initial value from API
|
||||
expect(limitValue).toBe("10,000");
|
||||
|
||||
fireEvent.change(input, { target: { value: "20000" } });
|
||||
expect(input.value).toBe("20000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends null when input is cleared (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// After save with null limit, input should show empty (unlimited)
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows saving state on button while patch is in flight", async () => {
|
||||
qGet(makeBudget());
|
||||
let resolvePatch: (v: unknown) => void;
|
||||
vi.mocked(api.patch).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
const btn = screen.getByTestId("budget-save-btn");
|
||||
expect(btn.textContent).toContain("Saving");
|
||||
|
||||
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.textContent).toContain("Save");
|
||||
});
|
||||
});
|
||||
it("disables Save button while saving", async () => {
|
||||
mockGet.mockResolvedValue(budget());
|
||||
mockPatch.mockImplementation(() => new Promise(() => {}));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const btn = screen.getByTestId("budget-save-btn");
|
||||
act(() => { btn.click(); });
|
||||
await flush();
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
describe("isApiError402 — regression coverage", () => {
|
||||
it("classifies ': 402' with space as 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget());
|
||||
it("updates display after successful save", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 0, budget_remaining: 1000 });
|
||||
mockPatch.mockResolvedValue({ budget_limit: 500, budget_used: 0, budget_remaining: 500 });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "500" } });
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-limit-value").textContent).toBe("500");
|
||||
});
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
it("shows error message when save fails", async () => {
|
||||
mockGet.mockResolvedValue(budget());
|
||||
mockPatch.mockRejectedValue(new Error("network error"));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
|
||||
expect(screen.getByText(/network error/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
it("re-shows exceeded banner when save fails with 402", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 999, budget_remaining: 1 });
|
||||
mockPatch.mockRejectedValue(new Error("https://api.example.com: 402 Payment Required"));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
act(() => { screen.getByTestId("budget-save-btn").click(); });
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("classifies non-402 error messages as regular fetch errors", async () => {
|
||||
qGetErr(503, "Service Unavailable");
|
||||
it("clears exceeded banner on successful save", async () => {
|
||||
// Start with exceeded banner showing
|
||||
mockGet.mockRejectedValue(new Error("https://api.example.com: 402 Payment Required"));
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
// Fix: re-fetch with a fresh GET, then save
|
||||
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 100, budget_remaining: 0 });
|
||||
mockPatch.mockResolvedValue({ budget_limit: 200, budget_used: 100, budget_remaining: 100 });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
await flush();
|
||||
// Banner should be gone after successful save
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeFalsy();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
it("save button is disabled when input is empty and budget_limit was null", async () => {
|
||||
mockGet.mockResolvedValue({ budget_limit: null, budget_used: 0, budget_remaining: null });
|
||||
render(<BudgetSection workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// User clears the (empty) input — this is still null, not a change
|
||||
// The button is never disabled — it always saves whatever is in the input
|
||||
expect(screen.getByTestId("budget-save-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user