mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add Muse invitation, connection readiness and scoped operator controls.
Keep connection state tied to the current company, person, agent and binding. Surface failed reads and invalidate stale cleanup observations; require current authority and exact readback. Preserve Dot/Grok setup, source-trust limits and existing design tokens.
Carry actual merged backend e6dcc6b095 through a normal forward merge. Preserve its project-write authority fix, existing migration bytes and upstream idle-serving behavior; no new UI behavior is authored by this source carry. Current-head CI and substantive review remain merge gates; final canonical and deployed-provider qualification remain separate.
Refs #16450.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
515 lines
36 KiB
TypeScript
515 lines
36 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Costs } from "./Costs";
|
|
import { CostByUserTable } from "../components/CostByUserTable";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
|
|
const scopedAgentMock = vi.hoisted(() => vi.fn());
|
|
vi.mock("../api/agents", () => ({ agentsApi: { get: scopedAgentMock } }));
|
|
|
|
const resolveIncidentMock = vi.hoisted(() => vi.fn());
|
|
const upsertPolicyMock = vi.hoisted(() => vi.fn());
|
|
const budgetOverviewMock = vi.hoisted(() => vi.fn());
|
|
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
|
|
const decisionHistoryMock = vi.hoisted(() => vi.fn());
|
|
const costsApiMocks = vi.hoisted(() => ({
|
|
summary: vi.fn(),
|
|
byAgent: vi.fn(),
|
|
byUser: vi.fn(),
|
|
byProject: vi.fn(),
|
|
byAgentModel: vi.fn(),
|
|
financeSummary: vi.fn(),
|
|
financeByBiller: vi.fn(),
|
|
financeByKind: vi.fn(),
|
|
financeEvents: vi.fn(),
|
|
byProvider: vi.fn(),
|
|
byBiller: vi.fn(),
|
|
windowSpend: vi.fn(),
|
|
quotaWindows: vi.fn(),
|
|
dataQuality: vi.fn(),
|
|
retryAccounting: vi.fn(),
|
|
}));
|
|
const subscriptionsMocks = vi.hoisted(() => ({ report: vi.fn(), refresh: vi.fn(), update: vi.fn(), link: vi.fn() }));
|
|
vi.mock("../api/subscriptions", () => ({ subscriptionsApi: subscriptionsMocks }));
|
|
|
|
vi.mock("../api/budgets", () => ({
|
|
budgetsApi: {
|
|
overview: (...args: unknown[]) => budgetOverviewMock(...args),
|
|
upsertPolicy: upsertPolicyMock,
|
|
resolveIncident: resolveIncidentMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock("../api/costs", () => ({ costsApi: costsApiMocks }));
|
|
vi.mock("../api/decision-models", () => ({ decisionModelsApi: { history: decisionHistoryMock } }));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
|
}));
|
|
|
|
vi.mock("../context/BreadcrumbContext", () => ({
|
|
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }) }));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const surfaces = [
|
|
["streamlined", { embedded: true, hideBudgetsTab: true }],
|
|
["standalone", {}],
|
|
] as const;
|
|
|
|
describe("Shared Costs surfaces", () => {
|
|
let container: HTMLDivElement;
|
|
let root: ReturnType<typeof createRoot>;
|
|
|
|
beforeEach(() => {
|
|
scopedAgentMock.mockReset();
|
|
const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costCents: "0", eventCount: 0, estimatedEventCount: 0, unpricedEventCount: 0 };
|
|
subscriptionsMocks.report.mockResolvedValue({ canRefresh: false, asOf: "2026-10-08T00:00:00Z", accounts: [], monthlyTotals: [], activeCount: 0, unknownPriceCount: 0, unidentifiedAccountCount: 0, api: usage, subscription: usage, unknown: usage, unattributedSubscription: usage });
|
|
decisionHistoryMock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
budgetOverviewMock.mockResolvedValue({
|
|
policies: [],
|
|
activeIncidents: [],
|
|
pendingApprovalCount: 0,
|
|
pausedAgentCount: 0,
|
|
pausedProjectCount: 0,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it.each(["overview", "budgets"] as const)("explains unavailable Muse accounting on its scoped %s page", async (tab) => {
|
|
scopedAgentMock.mockResolvedValue({ id: "muse-agent", name: "Researcher", adapterType: "paperclip_runner", adapterConfig: { provider: "muse" } });
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 0, budgetCents: 0, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter initialEntries={["/?agentId=muse-agent"]}><QueryClientProvider client={queryClient}><Costs embedded initialTab={tab} lockTab /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Researcher's Muse activity has no reported usage or cost")); });
|
|
expect(scopedAgentMock).toHaveBeenCalledWith("muse-agent", "company-1");
|
|
expect(container.textContent).toContain("Totals below show recorded organization charges only");
|
|
expect(container.textContent).toContain("Paperclip cannot enforce a Muse spend ceiling");
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("explains when scoped provider details cannot be loaded", async () => {
|
|
scopedAgentMock.mockRejectedValue(new Error("unavailable"));
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter initialEntries={["/?agentId=muse-agent"]}><QueryClientProvider client={queryClient}><Costs embedded initialTab="budgets" lockTab /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Could not load agent details. Totals below show recorded organization costs.")); });
|
|
expect(container.textContent).not.toContain("Muse activity has no reported");
|
|
queryClient.clear();
|
|
});
|
|
|
|
it.each([0, 125])("shows $%s of known user spend while retaining an unpriced warning", async (costCents) => {
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<CostByUserTable report={{ activeUserCount: 1, rows: [{
|
|
userId: null, userName: null, userImage: null, runCount: 1, eventCount: 1, estimatedEventCount: 0, unpricedEventCount: 1,
|
|
costCents, costCentsExact: `${costCents}.0000000`, inputTokens: 10, cachedInputTokens: 0, outputTokens: 2,
|
|
}] }} />));
|
|
expect(container.querySelector("tbody tr td:last-child")?.textContent).toContain(costCents > 0 ? "$1.25" : "—");
|
|
expect(container.textContent).toContain("1 unpriced charge");
|
|
});
|
|
|
|
it.each(surfaces)("labels other and unknown billing accurately on the %s surface", async (_name, props) => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 600, budgetCents: 0, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const report = await subscriptionsMocks.report();
|
|
subscriptionsMocks.report.mockResolvedValue({ ...report, unknown: { ...report.unknown,
|
|
inputTokens: 100, cachedInputTokens: 40, outputTokens: 10, costCents: "600", eventCount: 3,
|
|
} });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => vi.waitFor(() => expect(container.textContent).toContain(
|
|
"Other billing usage: 150 tokens · $6.00 in recorded charges, included in the inference ledger.",
|
|
)));
|
|
expect(container.textContent).not.toContain("tokens have an unknown billing type");
|
|
});
|
|
|
|
it("renders a focused Budgets section without duplicate Costs chrome or spend queries", async () => {
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter><Costs embedded initialTab="budgets" lockTab /></MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await act(async () => {
|
|
await vi.waitFor(() => {
|
|
expect(budgetOverviewMock).toHaveBeenCalledWith("company-1");
|
|
expect(container.textContent).toContain("Budget control plane");
|
|
});
|
|
});
|
|
expect(container.textContent).not.toContain("Inference spend");
|
|
expect(container.querySelector('[role="tab"]')).toBeFalsy();
|
|
expect(setBreadcrumbsMock).not.toHaveBeenCalled();
|
|
for (const mock of Object.values(costsApiMocks)) expect(mock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(surfaces)("shows unknown billing separately from complete prices only on the %s Overview", async (_name, props) => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 21, budgetCents: 0, pricingComplete: true, unknownBillingEventCount: 1, unpricedEventCount: 0, pendingRunCount: 0 });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
costsApiMocks.dataQuality.mockResolvedValue({ canRetry: true, events: [{ id: "event", runId: "run", agentId: "agent", agentName: "Codie", reason: "unknown_billing", occurredAt: "2026-10-09T12:00:00Z" }], runs: [], hasMoreEvents: false, hasMoreRuns: false });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => vi.waitFor(() => expect(container.textContent).toContain("1 billing type unknown")));
|
|
expect(container.textContent).not.toContain("Totals include known charges only.");
|
|
expect(costsApiMocks.dataQuality).not.toHaveBeenCalled();
|
|
const button = (label: string) => [...container.querySelectorAll("button")].find(row => row.textContent === label)!;
|
|
await act(async () => button("1 billing type unknown").click());
|
|
await act(async () => vi.waitFor(() => expect(container.textContent).toContain("Billing category unknown")));
|
|
const [, from, to] = costsApiMocks.dataQuality.mock.lastCall!;
|
|
expect(Number.isFinite(Date.parse(from))).toBe(true);
|
|
expect(Number.isFinite(Date.parse(to))).toBe(true);
|
|
await act(async () => button("Providers").dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })));
|
|
expect(container.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Providers");
|
|
expect(container.textContent).not.toContain("1 billing type unknown");
|
|
expect(container.textContent).not.toContain("Billing category unknown");
|
|
await act(async () => button("Overview").dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })));
|
|
expect(button("1 billing type unknown")).toBeDefined();
|
|
await act(async () => button("All Time").click());
|
|
await act(async () => vi.waitFor(() => expect(button("1 billing type unknown")).toBeDefined()));
|
|
await act(async () => button("1 billing type unknown").click());
|
|
await act(async () => vi.waitFor(() => expect(costsApiMocks.dataQuality).toHaveBeenLastCalledWith("company-1", undefined, undefined)));
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("opens linked decision history with real date bounds instead of report cache labels", async () => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 0, budgetCents: 0, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter initialEntries={["/?tab=decisions"]}><QueryClientProvider client={queryClient}><Costs /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("No decision requests in this period")); });
|
|
expect(container.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Decisions");
|
|
const [companyId, from, to] = decisionHistoryMock.mock.lastCall!;
|
|
expect(companyId).toBe("company-1");
|
|
expect(Number.isFinite(Date.parse(from))).toBe(true);
|
|
expect(Number.isFinite(Date.parse(to))).toBe(true);
|
|
queryClient.clear();
|
|
});
|
|
|
|
it.each([
|
|
["reservation", 500, "agent"], ["unknown price", 500, "agent"], ["amount", 500, "agent"],
|
|
["amount", 0, "company"], ["amount", 0, "agent"], ["amount", 0, "project"],
|
|
] as const)("preserves other settings when editing %s from %s on a %s budget", async (field, previousAmount, scopeType) => {
|
|
const summary = {
|
|
policyId: "policy", companyId: "company-1", scopeType, scopeId: "agent", scopeName: "Worker", metric: "billed_cents", windowKind: "lifetime",
|
|
amount: previousAmount, warnPercent: 65, hardStopEnabled: false, notifyEnabled: false, isActive: false, unpricedUsagePolicy: "allow", reservationCents: "2.0000000",
|
|
observedAmount: 0, remainingAmount: 500, utilizationPercent: 0, unpricedEventCount: 0, pendingRunCount: 0, status: "ok", paused: false, pauseReason: null,
|
|
};
|
|
budgetOverviewMock.mockResolvedValue({ policies: [summary], activeIncidents: [], pendingApprovalCount: 0, pausedAgentCount: 0, pausedProjectCount: 0 });
|
|
upsertPolicyMock.mockResolvedValue({});
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<QueryClientProvider client={queryClient}><MemoryRouter><Costs embedded initialTab="budgets" lockTab /></MemoryRouter></QueryClientProvider>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.querySelector('[aria-label="Reserve per run (USD)"]')).not.toBeNull()); });
|
|
const advanced = container.querySelector("details")!;
|
|
expect(advanced.open).toBe(false);
|
|
if (field !== "amount") await act(async () => advanced.querySelector("summary")!.click());
|
|
if (field === "unknown price") await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
|
|
else {
|
|
const element = field === "reservation" ? container.querySelector<HTMLInputElement>('[aria-label="Reserve per run (USD)"]')! : container.querySelector<HTMLInputElement>('input:not([type="checkbox"]):not([aria-label])')!;
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(element, "1");
|
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
const label = field === "reservation" ? "Update reservation" : previousAmount === 0 ? "Set budget" : "Update budget";
|
|
await act(async () => [...container.querySelectorAll("button")].find(b => b.textContent === label)!.click());
|
|
}
|
|
await act(async () => { await vi.waitFor(() => expect(upsertPolicyMock).toHaveBeenCalled()); });
|
|
expect(upsertPolicyMock).toHaveBeenCalledWith("company-1", {
|
|
scopeType: summary.scopeType, scopeId: summary.scopeId, metric: summary.metric, windowKind: summary.windowKind,
|
|
...(field === "amount" ? { amount: 100 } : field === "unknown price" ? { unpricedUsagePolicy: "block" } : { reservationCents: "100.0000000" }),
|
|
});
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("shows a safe policy-save failure without losing the operator's settings", async () => {
|
|
const policy = {
|
|
policyId: "policy", companyId: "company-1", scopeType: "agent", scopeId: "agent", scopeName: "Worker", metric: "billed_cents", windowKind: "lifetime",
|
|
amount: 500, warnPercent: 65, hardStopEnabled: false, notifyEnabled: false, isActive: false, unpricedUsagePolicy: "allow", reservationCents: "2.0000000",
|
|
observedAmount: 0, remainingAmount: 500, utilizationPercent: 0, unpricedEventCount: 0, pendingRunCount: 0, status: "ok", paused: false, pauseReason: null,
|
|
};
|
|
budgetOverviewMock.mockResolvedValue({ policies: [policy], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0 });
|
|
upsertPolicyMock.mockRejectedValueOnce(new Error("private SQL policy error"));
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<QueryClientProvider client={queryClient}><MemoryRouter><Costs embedded initialTab="budgets" lockTab /></MemoryRouter></QueryClientProvider>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.querySelector('input[type="checkbox"]')).not.toBeNull()); });
|
|
const reservation = container.querySelector<HTMLInputElement>('[aria-label="Reserve per run (USD)"]')!;
|
|
const originalReservation = reservation.value;
|
|
await act(async () => container.querySelector("details summary")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
|
|
await act(async () => { await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not update the budget policy")); });
|
|
expect(container.textContent).toContain("Worker");
|
|
expect(container.textContent).not.toContain("private SQL");
|
|
expect(reservation.value).toBe(originalReservation);
|
|
queryClient.clear();
|
|
});
|
|
|
|
it.each(surfaces)("shows failed budget actions on the %s Overview", async (_name, props) => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 200, budgetCents: 1000, pricingComplete: false });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
budgetOverviewMock.mockResolvedValue({ policies: [], activeIncidents: [{ id: "incident", scopeType: "agent", scopeName: "Codie", status: "open", thresholdType: "hard", amountObserved: 200, amountLimit: 1000 }], pausedAgentCount: 1, pausedProjectCount: 0 });
|
|
resolveIncidentMock.mockRejectedValue(new Error("private database error"));
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Raise budget & resume")); });
|
|
await act(async () => [...container.querySelectorAll("button")].find(b => b.textContent === "Raise budget & resume")!.click());
|
|
await act(async () => { await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not update the budget")); });
|
|
expect(resolveIncidentMock).toHaveBeenCalledWith("company-1", "incident", { incidentId: "incident", action: "raise_budget_and_resume", amount: 1200 });
|
|
expect(container.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Overview");
|
|
expect(container.textContent).toContain("Check any pending runs or unpriced usage shown on this page");
|
|
expect(container.textContent).not.toContain("private database error");
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("retries failed account discovery without discarding the last subscription report", async () => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 0, budgetCents: 0, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const report = await subscriptionsMocks.report();
|
|
subscriptionsMocks.report.mockResolvedValue({ ...report, canRefresh: true, activeCount: 1, monthlyTotals: [{ currency: "USD", amountCents: "2000", estimatedCount: 1 }] });
|
|
subscriptionsMocks.refresh.mockRejectedValueOnce(new Error("private connection error")).mockResolvedValue({});
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs /></QueryClientProvider></MemoryRouter>));
|
|
const retry = () => [...container.querySelectorAll("button")].find(button => button.textContent === "Retry account check");
|
|
await act(async () => { await vi.waitFor(() => expect(retry()).toBeDefined()); });
|
|
expect(container.textContent).toContain("$20.00/month");
|
|
expect(container.textContent).not.toContain("private connection error");
|
|
await act(async () => retry()!.click());
|
|
await act(async () => { await vi.waitFor(() => expect(retry()).toBeUndefined()); });
|
|
expect(subscriptionsMocks.refresh).toHaveBeenCalledTimes(2);
|
|
expect(subscriptionsMocks.refresh).toHaveBeenLastCalledWith("company-1");
|
|
expect(container.textContent).toContain("$20.00/month");
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("keeps monthly subscription fees through date changes and failed background reloads", async () => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 200, budgetCents: 0, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const report = await subscriptionsMocks.report();
|
|
subscriptionsMocks.report.mockResolvedValue({ ...report, activeCount: 1, monthlyTotals: [{ currency: "USD", amountCents: "2000", estimatedCount: 1 }], api: { ...report.api, costCents: "200" } });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs /></QueryClientProvider></MemoryRouter>));
|
|
const card = (label: string) => [...container.querySelectorAll("div")].find(node => node.textContent === label)?.closest('[data-slot="card"]');
|
|
await act(async () => { await vi.waitFor(() => expect(card("Subscriptions")?.textContent).toContain("$20.00/month")); });
|
|
expect(card("API spend")?.textContent).toContain("$2.00");
|
|
subscriptionsMocks.report.mockResolvedValue({ ...report, activeCount: 1, monthlyTotals: [{ currency: "USD", amountCents: "2000", estimatedCount: 1 }], api: { ...report.api, costCents: "300" } });
|
|
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "All Time")!.click());
|
|
await act(async () => { await vi.waitFor(() => expect(card("API spend")?.textContent).toContain("$3.00")); });
|
|
expect(card("Subscriptions")?.textContent).toContain("$20.00/month");
|
|
expect(subscriptionsMocks.report).toHaveBeenLastCalledWith("company-1", undefined, undefined);
|
|
subscriptionsMocks.report.mockRejectedValue(new Error("private provider failure"));
|
|
await act(async () => { await queryClient.invalidateQueries({ queryKey: ["cost-subscriptions"] }); });
|
|
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Showing the last loaded API and subscription usage")); });
|
|
expect(card("API spend")?.textContent).toContain("$3.00");
|
|
expect(card("Subscriptions")?.textContent).toContain("$20.00/month");
|
|
expect(container.textContent).not.toContain("private provider failure");
|
|
queryClient.clear();
|
|
});
|
|
|
|
it("compares monthly budgets only with month-to-date spend", async () => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 200, budgetCents: 1000, utilizationPercent: 20, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs /></QueryClientProvider></MemoryRouter>));
|
|
const budgetCard = () => [...container.querySelectorAll("div")].find(node => node.textContent === "Budget")?.closest('[data-slot="card"]');
|
|
await act(async () => { await vi.waitFor(() => expect(budgetCard()?.textContent).toContain("20%")); });
|
|
expect(budgetCard()?.textContent).toContain("$2.00 of $10.00 this month");
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 12000, budgetCents: 1000, utilizationPercent: 1200, pricingComplete: true });
|
|
const subscriptionReport = await subscriptionsMocks.report();
|
|
subscriptionsMocks.report.mockResolvedValue({ ...subscriptionReport, api: { ...subscriptionReport.api, costCents: "12000" } });
|
|
await act(async () => [...container.querySelectorAll("button")].find(b => b.textContent === "All Time")!.click());
|
|
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("$120.00")); });
|
|
expect(budgetCard()?.textContent).toContain("$10.00");
|
|
expect(budgetCard()?.textContent).toContain("Monthly limit");
|
|
expect(container.textContent).not.toContain("1200%");
|
|
expect(container.textContent).not.toContain("of monthly budget consumed");
|
|
expect(costsApiMocks.summary).toHaveBeenLastCalledWith("company-1", undefined, undefined);
|
|
queryClient.clear();
|
|
});
|
|
|
|
it.each(surfaces)("uses the displayed company cap for provider warnings on the %s page", async (_name, props) => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(new Date("2026-10-16T00:30:00Z"));
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
try {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 800, budgetCents: 1000, pricingComplete: true });
|
|
const row = { model: "test", billingType: "metered_api", inputTokens: 0, cachedInputTokens: 0, outputTokens: 0,
|
|
apiRunCount: 1, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0 };
|
|
costsApiMocks.byProvider.mockResolvedValue([
|
|
{ ...row, provider: "openai", biller: "openai", costCents: 100 },
|
|
{ ...row, provider: "google", biller: "google", costCents: 700 },
|
|
]);
|
|
root = createRoot(container);
|
|
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} initialTab="providers" /></QueryClientProvider></MemoryRouter>));
|
|
await act(async () => { await vi.waitFor(() => expect(container.querySelectorAll('[aria-label^="Period spend:"]')).toHaveLength(2)); });
|
|
const bar = (percent: number) => container.querySelector(`[aria-label="Period spend: ${percent}%"]`)!.parentElement!;
|
|
expect(bar(10).querySelector(".bg-destructive")).toBeNull();
|
|
expect(bar(70).querySelector(".bg-destructive")).not.toBeNull();
|
|
expect(container.textContent).toContain("10% of company budget");
|
|
expect(container.textContent).toContain("70% of company budget");
|
|
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "All Time")!.click());
|
|
await act(async () => { await vi.waitFor(() => expect(container.querySelector('[aria-label^="Period spend:"]')).toBeNull()); });
|
|
} finally { queryClient.clear(); vi.useRealTimers(); }
|
|
});
|
|
|
|
it.each(surfaces)("explains missing costs and pending runs neutrally on the %s page", async (_name, props) => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 2, pendingRunCount: 1 });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 1,
|
|
currencies: [{ currency: "EUR", netCents: 100 }],
|
|
});
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>);
|
|
});
|
|
await act(async () => {
|
|
await vi.waitFor(() => expect(container.textContent).toContain("3 run costs unavailable"));
|
|
});
|
|
const notice = [...container.querySelectorAll("button")].find(button => button.textContent === "3 run costs unavailable")!.parentElement!;
|
|
expect(notice.classList.contains("text-muted-foreground")).toBe(true);
|
|
expect(notice.querySelector(".text-destructive")).toBeNull();
|
|
expect(notice.classList.contains("text-destructive")).toBe(false);
|
|
expect(notice.textContent).toBe("3 run costs unavailable");
|
|
expect(costsApiMocks.dataQuality).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain("Finance headline totals are USD only");
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 10, pendingRunCount: 0 });
|
|
await act(async () => { await queryClient.invalidateQueries(); });
|
|
await vi.waitFor(() => expect(notice.textContent).toBe("10 run costs unavailable"));
|
|
expect(notice.textContent).not.toContain("awaiting cost data");
|
|
expect(notice.textContent).not.toContain("0 runs");
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 0, pendingRunCount: 1 });
|
|
await act(async () => { await queryClient.invalidateQueries(); });
|
|
await vi.waitFor(() => expect(notice.textContent).toBe("1 run cost unavailable"));
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: true, unpricedEventCount: 0, pendingRunCount: 0 });
|
|
await act(async () => { await queryClient.invalidateQueries(); });
|
|
await vi.waitFor(() => expect(container.contains(notice)).toBe(false));
|
|
});
|
|
|
|
it.each(surfaces)("labels each agent, project, and expanded model independently on the %s page", async (_name, props) => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 600, budgetCents: 0, pricingComplete: true, estimatedEventCount: 3 });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const base = { costCents: 200, inputTokens: 10, cachedInputTokens: 5, outputTokens: 2, apiRunCount: 2, subscriptionRunCount: 0, eventCount: 2 };
|
|
costsApiMocks.byProject.mockResolvedValue([
|
|
{ ...base, projectId: "estimated-project", projectName: "Estimated project", estimatedEventCount: 2 },
|
|
{ ...base, projectId: "mixed-project", projectName: "Mixed project", estimatedEventCount: 1 },
|
|
{ ...base, projectId: "reported-project", projectName: "Reported project", estimatedEventCount: 0 },
|
|
]);
|
|
costsApiMocks.byAgent.mockResolvedValue([
|
|
{ ...base, agentId: "codie", agentName: "Codie", estimatedEventCount: 2 },
|
|
{ ...base, agentId: "mixed", agentName: "Mixed", estimatedEventCount: 1 },
|
|
{ ...base, agentId: "reported", agentName: "Reported", estimatedEventCount: 0 },
|
|
{ ...base, agentId: "legacy", agentName: "Legacy", eventCount: undefined },
|
|
]);
|
|
costsApiMocks.byAgentModel.mockResolvedValue([
|
|
{ ...base, agentId: "mixed", provider: "openai", biller: "openai", billingType: "metered_api", model: "gpt-6-astra", eventCount: 1, estimatedEventCount: 1 },
|
|
{ ...base, agentId: "mixed", provider: "openai", biller: "openai", billingType: "metered_api", model: "gpt-6-sol", eventCount: 1, estimatedEventCount: 0 },
|
|
]);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>);
|
|
});
|
|
await act(async () => {
|
|
await vi.waitFor(() => expect(container.querySelector('[aria-label="Codie costs"]')).not.toBeNull());
|
|
});
|
|
const card = (name: string) => container.querySelector<HTMLElement>(`[aria-label="${name} costs"]`)!;
|
|
expect(card("Codie").textContent).toContain("Estimated");
|
|
expect(card("Codie").textContent).not.toContain("Partially estimated");
|
|
expect(card("Mixed").textContent).toContain("Partially estimated");
|
|
expect(card("Reported").textContent).not.toMatch(/Estimated|Partially estimated/);
|
|
expect(card("Legacy").textContent).not.toMatch(/Estimated|Partially estimated/);
|
|
const projectBadge = (name: string) => [...container.querySelectorAll("span")]
|
|
.find(element => element.textContent === name)!.parentElement!.querySelector('[data-slot="badge"]');
|
|
expect(projectBadge("Estimated project")?.textContent).toBe("Estimated");
|
|
expect(projectBadge("Mixed project")?.textContent).toBe("Partially estimated");
|
|
expect(projectBadge("Reported project")).toBeNull();
|
|
expect(container.textContent).not.toContain("Includes 3 estimated charges.");
|
|
expect(container.querySelector('button[aria-expanded]')).toBeNull();
|
|
expect(costsApiMocks.dataQuality).not.toHaveBeenCalled();
|
|
await act(async () => card("Mixed").querySelector<HTMLElement>(".cursor-pointer")!.click());
|
|
const breakdown = card("Mixed").querySelector(".border-l")!;
|
|
expect(breakdown.textContent).toContain("gpt-6-astra");
|
|
expect(breakdown.querySelectorAll('[data-slot="badge"]')).toHaveLength(1);
|
|
expect(breakdown.querySelector('[data-slot="badge"]')!.textContent).toBe("Estimated");
|
|
// The count is for charges, not runs; it must not use apiRunCount or the
|
|
// page-wide estimated count when determining whether a row is mixed.
|
|
expect(breakdown.querySelector('[data-slot="badge"]')!.getAttribute("title")).toContain("1 estimated charge.");
|
|
});
|
|
|
|
it.each(surfaces)("preserves the %s heading, breadcrumbs and tab navigation", async (name, props) => {
|
|
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
|
|
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
|
|
costsApiMocks.summary.mockResolvedValue({ spendCents: 0, budgetCents: 0, pricingComplete: true });
|
|
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>);
|
|
});
|
|
const streamlined = name === "streamlined";
|
|
expect(container.querySelector(streamlined ? "h2" : "h1")?.textContent).toBe("Costs");
|
|
if (streamlined) {
|
|
expect(container.querySelector("h1")).toBeNull();
|
|
expect(setBreadcrumbsMock).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Costs" }]);
|
|
}
|
|
expect([...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent)).toEqual(
|
|
streamlined ? ["Overview", "Providers", "Billers", "Finance", "Decisions", "Fast responses"] : ["Overview", "Budgets", "Providers", "Billers", "Finance", "Decisions", "Fast responses"],
|
|
);
|
|
});
|
|
|
|
});
|