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();
+ });
+ });
+});