From 6bf7df1f3f54ef3f8496aceba86702e0c7cf39fd Mon Sep 17 00:00:00 2001 From: Molecule AI App-FE Date: Mon, 11 May 2026 23:49:15 +0000 Subject: [PATCH] test(tabs): add BudgetSection coverage (17 cases) Covers all render states: loading, fetch error, 402 exceeded banner, budget loaded (with/without limit, over-limit cap), progress bar visibility, save success, save error, saving-in-flight button state, and the isApiError402 helper's regex branches. Co-Authored-By: Claude Opus 4.7 --- .../tabs/__tests__/BudgetSection.test.tsx | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 canvas/src/components/tabs/__tests__/BudgetSection.test.tsx diff --git a/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx new file mode 100644 index 00000000..7372ca0d --- /dev/null +++ b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx @@ -0,0 +1,330 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +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[] = []; + +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; + }), + }, +})); + +afterEach(cleanup); + +beforeEach(() => { + apiQueue.length = 0; + vi.clearAllMocks(); +}); + +const WS_ID = "budget-test-ws"; + +function qGet(body: unknown) { + apiQueue.push({ body }); +} + +function qGetErr(status: number, msg: string) { + apiQueue.push({ err: new Error(`${msg}: ${status}`) }); +} + +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, + }; +} + +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; }), + ); + + render(); + + expect(screen.getByTestId("budget-loading")).toBeTruthy(); + + // Resolve after render to verify state clears + resolveGet!(makeBudget()); + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-loading")).toBeNull(); + }); + }); + }); + + describe("fetch error state", () => { + it("shows error message on non-402 fetch failure", async () => { + qGetErr(500, "Internal Server Error"); + + render(); + + 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(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + expect(screen.queryByTestId("budget-fetch-error")).toBeNull(); + }); + }); + + describe("budget loaded — display", () => { + it("renders used / limit stats row", async () => { + qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 })); + + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await vi.waitFor(() => { + expect(screen.queryByTestId("budget-progress-fill")).toBeNull(); + }); + }); + }); + + describe("budget exceeded (402)", () => { + it("shows exceeded banner when load returns 402", async () => { + qGetErr(402, "Payment Required"); + + render(); + + 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(); + + 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(); + }); + }); + }); + + describe("save flow", () => { + it("shows save error on non-402 patch failure", async () => { + qGet(makeBudget()); + qPatchErr(500, "Internal Server Error"); + + render(); + + 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(); + + // 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(); + + 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(); + + 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"); + }); + }); + }); + + describe("isApiError402 — regression coverage", () => { + it("classifies ': 402' with space as 402", async () => { + qGetErr(402, "Payment Required"); + qPatch(makeBudget()); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + }); + + it("classifies non-402 error messages as regular fetch errors", async () => { + qGetErr(503, "Service Unavailable"); + + render(); + + await vi.waitFor(() => { + expect(screen.getByTestId("budget-fetch-error")).toBeTruthy(); + }); + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull(); + }); + }); +});