Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 84 additions & 5 deletions src/hooks/__tests__/use-quotas.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: {
Expand All @@ -29,6 +26,8 @@ function createWrapper() {
};
}
Comment thread
ginccc marked this conversation as resolved.

// ─── useQuotas (list) ──────────────────────────────────────────

describe("useQuotas", () => {
it("fetches quota list", async () => {
const { result } = renderHook(() => useQuotas(), {
Expand All @@ -40,6 +39,8 @@ describe("useQuotas", () => {
});
});

// ─── useQuota (single tenant) ──────────────────────────────────

describe("useQuota", () => {
it("fetches a single tenant quota", async () => {
const { result } = renderHook(() => useQuota("default"), {
Expand All @@ -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", () => {
Expand All @@ -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"), {
Expand All @@ -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", () => {
Expand All @@ -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(), {
Expand All @@ -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(), {
Expand Down
60 changes: 54 additions & 6 deletions src/lib/api/quotas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { api } from "../api-client";
import { api, isApiError } from "../api-client";

/* ─── Types ─── */

Expand All @@ -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";
Expand All @@ -29,25 +56,46 @@ export async function listQuotas(): Promise<TenantQuota[]> {
return api.get<TenantQuota[]>(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<TenantQuota> {
return api.get<TenantQuota>(`${BASE}/${tenantId}`);
try {
return await api.get<TenantQuota>(`${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,
): Promise<TenantQuota> {
return api.put<TenantQuota>(`${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<TenantUsage> {
return api.get<TenantUsage>(`${BASE}/${tenantId}/usage`);
try {
return await api.get<TenantUsage>(`${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<void> {
await api.post(`${BASE}/${tenantId}/usage/reset`, undefined);
}

Loading
Loading