diff --git a/src/hooks/__tests__/use-quotas.test.tsx b/src/hooks/__tests__/use-quotas.test.tsx index 1162b211..2764da79 100644 --- a/src/hooks/__tests__/use-quotas.test.tsx +++ b/src/hooks/__tests__/use-quotas.test.tsx @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "@/test/mocks/server"; import { type ReactNode } from "react"; +import { http, HttpResponse } from "msw"; import { useQuotas, useQuota, @@ -11,10 +12,6 @@ import { useResetUsage, } from "@/hooks/use-quotas"; -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); -afterAll(() => server.close()); -afterEach(() => server.resetHandlers()); - function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -29,6 +26,8 @@ function createWrapper() { }; } +// ─── useQuotas (list) ────────────────────────────────────────── + describe("useQuotas", () => { it("fetches quota list", async () => { const { result } = renderHook(() => useQuotas(), { @@ -40,6 +39,8 @@ describe("useQuotas", () => { }); }); +// ─── useQuota (single tenant) ────────────────────────────────── + describe("useQuota", () => { it("fetches a single tenant quota", async () => { const { result } = renderHook(() => useQuota("default"), { @@ -48,6 +49,7 @@ describe("useQuota", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveProperty("tenantId"); expect(result.current.data).toHaveProperty("maxConversationsPerDay"); + expect(result.current.data).toHaveProperty("enabled"); }); it("is disabled when tenantId is empty", () => { @@ -56,8 +58,48 @@ describe("useQuota", () => { }); expect(result.current.fetchStatus).toBe("idle"); }); + + it("returns default quota when backend returns 404", async () => { + server.use( + http.get("*/administration/quotas/:tenantId", ({ params }) => { + // Let /quotas/count fall through to the global handler — :tenantId also matches "count" + if (params.tenantId === "count") return; + return new HttpResponse(null, { status: 404 }); + }), + ); + + const { result } = renderHook(() => useQuota("fresh-tenant"), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ + tenantId: "fresh-tenant", + maxConversationsPerDay: -1, + maxAgentsPerTenant: -1, + maxApiCallsPerMinute: -1, + maxMonthlyCostUsd: -1, + enabled: false, + }); + }); + + it("propagates non-404 errors", async () => { + server.use( + http.get("*/administration/quotas/:tenantId", ({ params }) => { + // Let /quotas/count fall through to the global handler — :tenantId also matches "count" + if (params.tenantId === "count") return; + return new HttpResponse(null, { status: 500 }); + }), + ); + + const { result } = renderHook(() => useQuota("default"), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); }); +// ─── useQuotaUsage ───────────────────────────────────────────── + describe("useQuotaUsage", () => { it("fetches usage for a tenant", async () => { const { result } = renderHook(() => useQuotaUsage("default"), { @@ -66,6 +108,7 @@ describe("useQuotaUsage", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveProperty("conversationsToday"); expect(result.current.data).toHaveProperty("monthlyCostUsd"); + expect(result.current.data).toHaveProperty("tenantId"); }); it("is disabled when tenantId is empty", () => { @@ -74,8 +117,40 @@ describe("useQuotaUsage", () => { }); expect(result.current.fetchStatus).toBe("idle"); }); + + it("returns zeroed usage when backend returns 404", async () => { + server.use( + http.get("*/administration/quotas/:tenantId/usage", () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + const { result } = renderHook(() => useQuotaUsage("fresh-tenant"), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data!.tenantId).toBe("fresh-tenant"); + expect(result.current.data!.conversationsToday).toBe(0); + expect(result.current.data!.apiCallsThisMinute).toBe(0); + expect(result.current.data!.monthlyCostUsd).toBe(0); + }); + + it("propagates non-404 errors for usage", async () => { + server.use( + http.get("*/administration/quotas/:tenantId/usage", () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + const { result } = renderHook(() => useQuotaUsage("default"), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); }); +// ─── useUpdateQuota ──────────────────────────────────────────── + describe("useUpdateQuota", () => { it("performs mutation successfully", async () => { const { result } = renderHook(() => useUpdateQuota(), { @@ -95,9 +170,13 @@ describe("useUpdateQuota", () => { }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveProperty("tenantId", "default"); + expect(result.current.data).toHaveProperty("maxConversationsPerDay", 10000); }); }); +// ─── useResetUsage ───────────────────────────────────────────── + describe("useResetUsage", () => { it("performs reset mutation successfully", async () => { const { result } = renderHook(() => useResetUsage(), { diff --git a/src/lib/api/quotas.ts b/src/lib/api/quotas.ts index 5ca66398..e0376ec3 100644 --- a/src/lib/api/quotas.ts +++ b/src/lib/api/quotas.ts @@ -1,4 +1,4 @@ -import { api } from "../api-client"; +import { api, isApiError } from "../api-client"; /* ─── Types ─── */ @@ -20,6 +20,33 @@ export interface TenantUsage { dayStart: string; } +/* ─── Helpers ─── */ + +/** Sensible defaults when no quota record exists yet (all unlimited, disabled). */ +export function defaultQuota(tenantId: string): TenantQuota { + return { + tenantId, + maxConversationsPerDay: -1, + maxAgentsPerTenant: -1, + maxApiCallsPerMinute: -1, + maxMonthlyCostUsd: -1, + enabled: false, + }; +} + +/** Zeroed usage snapshot for tenants with no usage data yet. */ +export function emptyUsage(tenantId: string): TenantUsage { + const now = new Date().toISOString(); + return { + tenantId, + conversationsToday: 0, + apiCallsThisMinute: 0, + monthlyCostUsd: 0, + minuteWindowStart: now, + dayStart: now, + }; +} + /* ─── API Functions ─── */ const BASE = "/administration/quotas"; @@ -29,12 +56,22 @@ export async function listQuotas(): Promise { return api.get(BASE); } -/** Get quota for a specific tenant. */ +/** + * Get quota for a specific tenant. + * Returns local defaults when no record exists (404) so the UI can render. + */ export async function getQuota(tenantId: string): Promise { - return api.get(`${BASE}/${tenantId}`); + try { + return await api.get(`${BASE}/${tenantId}`); + } catch (err) { + if (isApiError(err) && err.status === 404) { + return defaultQuota(tenantId); + } + throw err; + } } -/** Update quota for a tenant. */ +/** Update (or create) quota for a tenant. */ export async function updateQuota( tenantId: string, quota: TenantQuota, @@ -42,12 +79,23 @@ export async function updateQuota( return api.put(`${BASE}/${tenantId}`, quota); } -/** Get current usage for a tenant. */ +/** + * Get current usage for a tenant. + * Returns zeroed snapshot when no usage data exists yet (404). + */ export async function getUsage(tenantId: string): Promise { - return api.get(`${BASE}/${tenantId}/usage`); + try { + return await api.get(`${BASE}/${tenantId}/usage`); + } catch (err) { + if (isApiError(err) && err.status === 404) { + return emptyUsage(tenantId); + } + throw err; + } } /** Reset usage counters for a tenant. */ export async function resetUsage(tenantId: string): Promise { await api.post(`${BASE}/${tenantId}/usage/reset`, undefined); } + diff --git a/src/pages/__tests__/quotas.test.tsx b/src/pages/__tests__/quotas.test.tsx index 03396466..eff74d6e 100644 --- a/src/pages/__tests__/quotas.test.tsx +++ b/src/pages/__tests__/quotas.test.tsx @@ -1,15 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/components/layout/theme-provider"; +import { http, HttpResponse } from "msw"; +import { server } from "@/test/mocks/server"; import { QuotasPage } from "@/pages/quotas"; function renderQuotas() { const queryClient = new QueryClient({ defaultOptions: { - queries: { retry: false }, + queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, }); @@ -25,7 +28,9 @@ function renderQuotas() { } describe("QuotasPage", () => { - it("renders the page header", async () => { + // ─── Rendering ──────────────────────────────────────────────── + + it("renders the page header and description", async () => { renderQuotas(); expect(screen.getByText("Tenant Quotas")).toBeInTheDocument(); expect( @@ -40,12 +45,35 @@ describe("QuotasPage", () => { expect(screen.getByTestId("quotas-page")).toBeInTheDocument(); }); - it("renders save button", () => { + it("renders save button (disabled by default)", () => { renderQuotas(); - expect(screen.getByTestId("quotas-save")).toBeInTheDocument(); + const saveBtn = screen.getByTestId("quotas-save"); + expect(saveBtn).toBeInTheDocument(); + expect(saveBtn).toBeDisabled(); }); - it("loads and displays quota configuration fields", async () => { + it("shows loading skeleton while data is fetching", () => { + // Delay mock responses to observe loading state + server.use( + http.get("*/administration/quotas/:tenantId", async () => { + await new Promise((r) => setTimeout(r, 200)); + return HttpResponse.json({ + tenantId: "default", + maxConversationsPerDay: 5000, + maxAgentsPerTenant: 100, + maxApiCallsPerMinute: 500, + maxMonthlyCostUsd: 2500, + enabled: true, + }); + }), + ); + renderQuotas(); + expect(screen.getByTestId("quotas-loading")).toBeInTheDocument(); + }); + + // ─── Configuration Card ────────────────────────────────────── + + it("loads and displays all quota configuration fields", async () => { renderQuotas(); await waitFor(() => { expect(screen.getByTestId("quota-max-conversations")).toBeInTheDocument(); @@ -55,6 +83,27 @@ describe("QuotasPage", () => { expect(screen.getByTestId("quota-max-cost")).toBeInTheDocument(); }); + it("shows the toggle enabled button", async () => { + renderQuotas(); + await waitFor(() => { + expect(screen.getByTestId("quotas-toggle-enabled")).toBeInTheDocument(); + }); + // Mock data has enabled: true + expect(screen.getByText("Enabled")).toBeInTheDocument(); + }); + + it("renders quota field values from server data", async () => { + renderQuotas(); + await waitFor(() => { + expect(screen.getByTestId("quota-max-conversations")).toHaveValue(5000); + }); + expect(screen.getByTestId("quota-max-agents")).toHaveValue(100); + expect(screen.getByTestId("quota-max-api-calls")).toHaveValue(500); + expect(screen.getByTestId("quota-max-cost")).toHaveValue(2500); + }); + + // ─── Usage Card ────────────────────────────────────────────── + it("loads and displays usage data", async () => { renderQuotas(); await waitFor(() => { @@ -65,12 +114,11 @@ describe("QuotasPage", () => { expect(screen.getByTestId("usage-tenant-id")).toBeInTheDocument(); }); - it("shows the toggle enabled button", async () => { + it("displays tenant ID in usage card", async () => { renderQuotas(); await waitFor(() => { - expect(screen.getByTestId("quotas-toggle-enabled")).toBeInTheDocument(); + expect(screen.getByTestId("usage-tenant-id")).toHaveTextContent("default"); }); - expect(screen.getByText("Enabled")).toBeInTheDocument(); }); it("shows reset usage button", async () => { @@ -81,10 +129,187 @@ describe("QuotasPage", () => { expect(screen.getByText("Reset Counters")).toBeInTheDocument(); }); - it("displays tenant ID in usage card", async () => { + // ─── Interactions ───────────────────────────────────────────── + + it("marks form as dirty when a field is changed", async () => { + const user = userEvent.setup(); renderQuotas(); + + await waitFor(() => { + expect(screen.getByTestId("quota-max-conversations")).toBeInTheDocument(); + }); + + const input = screen.getByTestId("quota-max-conversations"); + await user.clear(input); + await user.type(input, "999"); + + expect(screen.getByTestId("quotas-dirty-indicator")).toBeInTheDocument(); + expect(screen.getByTestId("quotas-save")).toBeEnabled(); + }); + + it("toggles enforcement off and shows disabled banner", async () => { + const user = userEvent.setup(); + renderQuotas(); + + await waitFor(() => { + expect(screen.getByTestId("quotas-toggle-enabled")).toBeInTheDocument(); + }); + + // Mock has enabled: true, toggling should show "Disabled" + await user.click(screen.getByTestId("quotas-toggle-enabled")); + + expect(screen.getByText("Disabled")).toBeInTheDocument(); + // The disabled banner should now appear + expect(screen.getByTestId("quotas-disabled-banner")).toBeInTheDocument(); + }); + + it("saves quota changes via PUT", async () => { + const user = userEvent.setup(); + renderQuotas(); + + await waitFor(() => { + expect(screen.getByTestId("quota-max-conversations")).toBeInTheDocument(); + }); + + // Change a field to make dirty + const input = screen.getByTestId("quota-max-conversations"); + await user.clear(input); + await user.type(input, "1000"); + + // Click save + await user.click(screen.getByTestId("quotas-save")); + + // After successful save, dirty indicator should be gone + await waitFor(() => { + expect(screen.queryByTestId("quotas-dirty-indicator")).not.toBeInTheDocument(); + }); + }); + + it("calls reset usage endpoint on reset button click", async () => { + const user = userEvent.setup(); + const resetFn = vi.fn(); + server.use( + http.post("*/administration/quotas/:tenantId/usage/reset", () => { + resetFn(); + return new HttpResponse(null, { status: 200 }); + }), + ); + + renderQuotas(); + + await waitFor(() => { + expect(screen.getByTestId("quotas-reset-usage")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("quotas-reset-usage")); + + await waitFor(() => { + expect(resetFn).toHaveBeenCalledTimes(1); + }); + }); + + // ─── 404 Fallback (no quota record) ────────────────────────── + + it("renders with default values when backend returns 404 for quota", async () => { + server.use( + http.get("*/administration/quotas/:tenantId/usage", ({ params }) => { + // Let /quotas/count fall through to the global handler — :tenantId also matches "count" + if (params.tenantId === "count") return; + return HttpResponse.json({ + tenantId: "default", + conversationsToday: 0, + apiCallsThisMinute: 0, + monthlyCostUsd: 0, + minuteWindowStart: new Date().toISOString(), + dayStart: new Date().toISOString(), + }); + }), + http.get("*/administration/quotas/:tenantId", () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + renderQuotas(); + + // Should still render fields with default values (-1 = unlimited) + await waitFor(() => { + expect(screen.getByTestId("quota-max-conversations")).toHaveValue(-1); + }); + expect(screen.getByTestId("quota-max-agents")).toHaveValue(-1); + expect(screen.getByTestId("quota-max-api-calls")).toHaveValue(-1); + expect(screen.getByTestId("quota-max-cost")).toHaveValue(-1); + + // Should show disabled state + expect(screen.getByText("Disabled")).toBeInTheDocument(); + // Should show the enforcement-off banner + expect(screen.getByTestId("quotas-disabled-banner")).toBeInTheDocument(); + }); + + it("renders with zeroed usage when backend returns 404 for usage", async () => { + server.use( + http.get("*/administration/quotas/:tenantId/usage", () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + renderQuotas(); + await waitFor(() => { expect(screen.getByTestId("usage-tenant-id")).toHaveTextContent("default"); }); }); + + // ─── Enforcement disabled banner ───────────────────────────── + + it("shows enforcement disabled banner when quota is disabled", async () => { + server.use( + http.get("*/administration/quotas/:tenantId", ({ params }) => { + // Let /quotas/count fall through to the global handler — :tenantId also matches "count" + if (params.tenantId === "count") return; + return HttpResponse.json({ + tenantId: "default", + maxConversationsPerDay: -1, + maxAgentsPerTenant: -1, + maxApiCallsPerMinute: -1, + maxMonthlyCostUsd: -1, + enabled: false, + }); + }), + ); + + renderQuotas(); + + await waitFor(() => { + expect(screen.getByTestId("quotas-disabled-banner")).toBeInTheDocument(); + }); + expect(screen.getByText("Enforcement is off.")).toBeInTheDocument(); + }); + + it("does not show enforcement banner when quota is enabled", async () => { + renderQuotas(); + + await waitFor(() => { + expect(screen.getByTestId("quotas-toggle-enabled")).toBeInTheDocument(); + }); + // Mock data has enabled: true + expect(screen.queryByTestId("quotas-disabled-banner")).not.toBeInTheDocument(); + }); + + // ─── Error handling ────────────────────────────────────────── + + it("does not crash on server error (non-404)", async () => { + server.use( + http.get("*/administration/quotas/:tenantId", () => { + return new HttpResponse(null, { status: 500 }); + }), + http.get("*/administration/quotas/:tenantId/usage", () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + renderQuotas(); + + // Should still render the page structure + expect(screen.getByTestId("quotas-page")).toBeInTheDocument(); + }); }); diff --git a/src/pages/quotas.tsx b/src/pages/quotas.tsx index e313af25..509c3835 100644 --- a/src/pages/quotas.tsx +++ b/src/pages/quotas.tsx @@ -8,6 +8,11 @@ import { Save, ToggleLeft, ToggleRight, + MessageSquare, + Bot, + Zap, + DollarSign, + Info, } from "lucide-react"; import { useQuota, useQuotaUsage, useUpdateQuota, useResetUsage } from "@/hooks/use-quotas"; import type { TenantQuota } from "@/lib/api/quotas"; @@ -81,7 +86,9 @@ export function QuotasPage() {
{dirty && ( - {t("editor.dirty", "Unsaved changes")} + + {t("editor.dirty", "Unsaved changes")} + )} + )} +
+ {form && ( +
+ } + label={t("quotas.maxConversationsPerDay", "Max Conversations / Day")} + value={form.maxConversationsPerDay} + onChange={(v) => handleChange("maxConversationsPerDay", v)} + hint={t("quotas.limitHint", "-1 = unlimited")} + testId="quota-max-conversations" + dimmed={!form.enabled} + /> + } + label={t("quotas.maxAgentsPerTenant", "Max Agents / Tenant")} + value={form.maxAgentsPerTenant} + onChange={(v) => handleChange("maxAgentsPerTenant", v)} + hint={t("quotas.limitHint", "-1 = unlimited")} + testId="quota-max-agents" + dimmed={!form.enabled} + /> + } + label={t("quotas.maxApiCallsPerMinute", "Max API Calls / Minute")} + value={form.maxApiCallsPerMinute} + onChange={(v) => handleChange("maxApiCallsPerMinute", v)} + hint={t("quotas.limitHint", "-1 = unlimited")} + testId="quota-max-api-calls" + dimmed={!form.enabled} + /> + } + label={t("quotas.maxMonthlyCostUsd", "Max Monthly Cost (USD)")} + value={form.maxMonthlyCostUsd} + onChange={(v) => handleChange("maxMonthlyCostUsd", v)} + hint={t("quotas.limitHint", "-1 = unlimited")} + testId="quota-max-cost" + step={0.01} + dimmed={!form.enabled} + /> +
+ )} + + + {/* Usage Card */} +
+
+

+ + {t("quotas.liveUsage", "Live Usage")} +

+
+ + {usage && ( +
+ + + +
+ + {t("quotas.tenantId", "Tenant ID")} + + + {usage.tenantId} + +
+
)}
- - {form && ( -
- handleChange("maxConversationsPerDay", v)} - hint={t("quotas.limitHint", "-1 = unlimited")} - testId="quota-max-conversations" - /> - handleChange("maxAgentsPerTenant", v)} - hint={t("quotas.limitHint", "-1 = unlimited")} - testId="quota-max-agents" - /> - handleChange("maxApiCallsPerMinute", v)} - hint={t("quotas.limitHint", "-1 = unlimited")} - testId="quota-max-api-calls" - /> - handleChange("maxMonthlyCostUsd", v)} - hint={t("quotas.limitHint", "-1 = unlimited")} - testId="quota-max-cost" - step={0.01} - /> -
- )} + + )} + + ); +} - {/* Usage Card */} -
-
-

- - {t("quotas.liveUsage", "Live Usage")} -

- -
+/* ─── Sub-components ─── */ - {usage && ( -
- - - -
- - {t("quotas.tenantId", "Tenant ID")} - - - {usage.tenantId} - -
+function LoadingSkeleton() { + return ( +
+ {[0, 1].map((i) => ( +
+
+
+ {[0, 1, 2, 3].map((j) => ( +
+
+
- )} + ))}
- )} + ))}
); } -/* ─── Sub-components ─── */ - function QuotaField({ + icon, label, value, onChange, hint, testId, step = 1, + dimmed = false, }: { + icon: React.ReactNode; label: string; value: number; onChange: (v: number) => void; hint: string; testId: string; step?: number; + dimmed?: boolean; }) { return ( -
- +
+