157 lines
9.2 KiB
TypeScript
157 lines
9.2 KiB
TypeScript
import React from "react";
|
|
import "@testing-library/jest-dom";
|
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
import AiUsageCard from "./components/AiUsageCard";
|
|
import { api } from "./api";
|
|
|
|
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn() } }));
|
|
let mockLanguage = "en";
|
|
let mockSession = "one";
|
|
jest.mock("./sessionBoundary", () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSession, isSessionInvalidated: () => false }));
|
|
jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ language: mockLanguage, t: (key: string, params?: Record<string, unknown>) => {
|
|
const messages: Record<string, string> = {
|
|
settingsUsageTitle: "Account usage",
|
|
settingsUsageUnavailable: "Account usage is temporarily unavailable.",
|
|
settingsUsagePlan: "{plan} plan",
|
|
settingsUsageGenerations: "{used} of {limit} generations this month",
|
|
settingsUsageTokens: "{used} of {limit} estimated tokens",
|
|
settingsUsageStorage: "{used} of {limit} attachment storage",
|
|
settingsUsageReset: "AI limits reset at the start of each calendar month.",
|
|
settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.",
|
|
settingsBillingUpgrade: "Upgrade to Pro",
|
|
settingsBillingUnavailable: "Billing is temporarily unavailable.",
|
|
settingsUsageStorageProgress: "Attachment storage used",
|
|
loading: "Loading",
|
|
retry: "Retry",
|
|
settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.",
|
|
};
|
|
return Object.entries(params ?? {}).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), messages[key] ?? key);
|
|
} }) }));
|
|
|
|
beforeEach(() => { jest.clearAllMocks(); mockLanguage = "en"; mockSession = "one"; });
|
|
|
|
const usage = { currentMonth: { calls: 1234, estimatedTokens: 12000 }, plan: "pro", monthlyCallLimit: 2000, monthlyTokenLimit: 40000, storageUsedBytes: 1250000, storageLimitBytes: 250000000 };
|
|
const billing = { enabled: true, canCheckout: true, canManage: false };
|
|
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
|
|
|
test("keeps its heading and named loading state, then retries only a failed usage GET", async () => {
|
|
const held = deferred<unknown>();
|
|
(api.get as jest.Mock).mockImplementation((url: string) => url === "/billing/status" ? Promise.resolve({ data: billing }) : held.promise);
|
|
render(<AiUsageCard />);
|
|
expect(screen.getByRole("heading", { name: "Account usage" })).toBeInTheDocument();
|
|
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
|
|
await act(async () => { held.resolve(Promise.reject(new Error("offline"))); });
|
|
expect(await screen.findByText("Account usage is temporarily unavailable.")).toBeInTheDocument();
|
|
(api.get as jest.Mock).mockResolvedValue({ data: usage });
|
|
const retry = screen.getByRole("button", { name: "Retry" });
|
|
act(() => retry.focus());
|
|
fireEvent.click(retry);
|
|
expect(screen.getByRole("group", { name: "Account usage" })).toHaveFocus();
|
|
expect(await screen.findByText("1.3 MB of 250.0 MB attachment storage")).toBeInTheDocument();
|
|
expect((api.get as jest.Mock).mock.calls.map(([url]) => url)).toEqual(["/ai/usage", "/billing/status", "/ai/usage"]);
|
|
expect(api.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("billing failure remains visible beside usage and its Retry only reads billing", async () => {
|
|
(api.get as jest.Mock).mockImplementation((url: string) => url === "/billing/status" ? Promise.reject(new Error("offline")) : Promise.resolve({ data: usage }));
|
|
render(<AiUsageCard />);
|
|
expect(await screen.findByText("Billing is temporarily unavailable.")).toBeInTheDocument();
|
|
expect(screen.getByText("1.3 MB of 250.0 MB attachment storage")).toBeInTheDocument();
|
|
expect(screen.queryByText(/not available in this deployment/)).not.toBeInTheDocument();
|
|
(api.get as jest.Mock).mockResolvedValue({ data: billing });
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
expect(await screen.findByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument();
|
|
expect((api.get as jest.Mock).mock.calls.map(([url]) => url)).toEqual(["/ai/usage", "/billing/status", "/billing/status"]);
|
|
expect(api.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("locale changes format numbers and byte decimals without reloading; zero storage limit stays finite", async () => {
|
|
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ data: url === "/billing/status" ? billing : { ...usage, storageLimitBytes: 0 } }));
|
|
const view = render(<AiUsageCard />);
|
|
expect(await screen.findByText("1,234 of 2,000 generations this month")).toBeInTheDocument();
|
|
mockLanguage = "nb";
|
|
view.rerender(<AiUsageCard />);
|
|
expect(screen.getByText((_, element) => element?.tagName === "P" && element.textContent === `${(1234).toLocaleString("nb-NO")} of ${(2000).toLocaleString("nb-NO")} generations this month`)).toBeInTheDocument();
|
|
expect(screen.getByText("1,3 MB of 0 KB attachment storage")).toBeInTheDocument();
|
|
expect(screen.getByRole("progressbar", { name: "Attachment storage used" })).toHaveAttribute("aria-valuenow", "0");
|
|
expect(api.get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test.each(["unmount", "session"])("one billing POST ignores a late failure after %s", async boundary => {
|
|
const held = deferred<unknown>();
|
|
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ data: url === "/billing/status" ? billing : usage }));
|
|
(api.post as jest.Mock).mockReturnValue(held.promise);
|
|
const view = render(<AiUsageCard />);
|
|
const button = await screen.findByRole("button", { name: "Upgrade to Pro" });
|
|
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
|
expect(api.post).toHaveBeenCalledTimes(1);
|
|
if (boundary === "unmount") view.unmount(); else mockSession = "two";
|
|
await act(async () => { held.resolve(Promise.reject(new Error("offline"))); });
|
|
expect(screen.queryByText("Billing is temporarily unavailable.")).not.toBeInTheDocument();
|
|
});
|
|
|
|
test.each(["unmount", "session"])("late billing success after %s never consumes the redirect URL", async boundary => {
|
|
const held = deferred<unknown>();
|
|
const url = jest.fn(() => "https://billing.example/checkout");
|
|
(api.get as jest.Mock).mockImplementation((path: string) => Promise.resolve({ data: path === "/billing/status" ? billing : usage }));
|
|
(api.post as jest.Mock).mockReturnValue(held.promise);
|
|
const view = render(<AiUsageCard />);
|
|
fireEvent.click(await screen.findByRole("button", { name: "Upgrade to Pro" }));
|
|
if (boundary === "unmount") view.unmount(); else mockSession = "two";
|
|
await act(async () => { held.resolve({ data: { get url() { return url(); } } }); });
|
|
expect(url).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a failed billing POST offers the existing explicit action without automatic replay", async () => {
|
|
(api.get as jest.Mock).mockImplementation((path: string) => Promise.resolve({ data: path === "/billing/status" ? { ...billing, canCheckout: false, canManage: true } : usage }));
|
|
(api.post as jest.Mock).mockRejectedValue(new Error("offline"));
|
|
render(<AiUsageCard />);
|
|
fireEvent.click(await screen.findByRole("button", { name: "settingsBillingManage" }));
|
|
expect(await screen.findByText("Billing is temporarily unavailable.")).toBeInTheDocument();
|
|
expect(api.post).toHaveBeenCalledTimes(1);
|
|
expect(api.post).toHaveBeenCalledWith("/billing/portal");
|
|
expect(api.get).toHaveBeenCalledTimes(2);
|
|
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "settingsBillingManage" }));
|
|
await screen.findByText("Billing is temporarily unavailable.");
|
|
expect(api.post).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("shows monthly AI call and token limits", async () => {
|
|
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({
|
|
data: url === "/billing/status" ? { enabled: true, canCheckout: true, canManage: false } : {
|
|
currentMonth: { calls: 4, estimatedTokens: 12000 },
|
|
plan: "free",
|
|
monthlyCallLimit: 0,
|
|
monthlyTokenLimit: 0,
|
|
storageUsedBytes: 50000000,
|
|
storageLimitBytes: 250000000,
|
|
},
|
|
}));
|
|
|
|
render(<AiUsageCard />);
|
|
|
|
expect(await screen.findByText(/Free plan includes core job tracking/)).toBeInTheDocument();
|
|
expect(screen.getByText("50.0 MB of 250.0 MB attachment storage")).toBeInTheDocument();
|
|
expect(screen.queryByLabelText("Monthly AI generations used")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument();
|
|
});
|
|
|
|
test("does not offer a false upgrade action when billing is not configured", async () => {
|
|
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({
|
|
data: url === "/billing/status" ? { enabled: false, canCheckout: false, canManage: false } : {
|
|
currentMonth: { calls: 0, estimatedTokens: 0 },
|
|
plan: "free",
|
|
monthlyCallLimit: 0,
|
|
monthlyTokenLimit: 0,
|
|
storageUsedBytes: 0,
|
|
storageLimitBytes: 250000000,
|
|
},
|
|
}));
|
|
|
|
render(<AiUsageCard />);
|
|
|
|
expect(await screen.findByText(/not available in this deployment yet/i)).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Upgrade to Pro" })).not.toBeInTheDocument();
|
|
});
|