mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
test(platform): expand billing coverage to 90% locally
Lifts patch coverage past the codecov 80% gate: backend: - Add list_invoices endpoint tests in v1_test.py (default limit, mapped payload shape, ?limit=500 → 422 validation, ?limit=24 forwarded). frontend: - billing-cards.test.tsx: per-card render assertions for every new card (YourPlan, PaymentMethod, Invoices, AutopilotUsage, Balance, AutoRefill, Usage, TransactionHistory) with deterministic MSW responses, plus Add-Credits dialog open/disabled assertion and AutoRefillDialog enable-vs-edit branches. - billing-hooks.test.tsx: renderHook coverage of every mutation path — useBalanceCard.handleSubmit (success/error/no-op), useAutoRefillCard save/disable (success/error/short-circuit), useYourPlanCard.changeTier (success/error/no-op-on-BASIC) and label fallbacks, plus usePaymentMethodCard portal availability. - helpers.test.ts: pure-JS coverage of formatCents (Intl thousands separator + negative prefix), formatRelativeReset (5 branches), formatShortDate (3 branches), and EASE_OUT. Local v8 coverage on the new billing source (cobertura): 90.1% (201/223 lines).
This commit is contained in:
@@ -342,6 +342,80 @@ def test_configure_auto_top_up_validation_errors(
|
||||
assert response.status_code == 200 # Should succeed
|
||||
|
||||
|
||||
def test_list_invoices_returns_mapped_payload(
|
||||
mocker: pytest_mock.MockFixture,
|
||||
) -> None:
|
||||
"""The /credits/invoices route should return whatever the credit model
|
||||
yields, serialised through the InvoiceListItem schema."""
|
||||
from backend.data.credit import InvoiceListItem
|
||||
|
||||
invoice = InvoiceListItem(
|
||||
id="in_1",
|
||||
number="INV-001",
|
||||
created_at=datetime(2026, 4, 1, tzinfo=timezone.utc),
|
||||
total_cents=2500,
|
||||
amount_paid_cents=0,
|
||||
currency="usd",
|
||||
status="open",
|
||||
description="Subscription",
|
||||
hosted_invoice_url="https://invoice.stripe.com/i/test",
|
||||
invoice_pdf_url="https://invoice.stripe.com/i/test/pdf",
|
||||
)
|
||||
|
||||
mock_credit_model = Mock()
|
||||
mock_credit_model.list_invoices = AsyncMock(return_value=[invoice])
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.get_user_credit_model",
|
||||
return_value=mock_credit_model,
|
||||
)
|
||||
|
||||
response = client.get("/credits/invoices?limit=24")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload) == 1
|
||||
row = payload[0]
|
||||
assert row["id"] == "in_1"
|
||||
assert row["total_cents"] == 2500
|
||||
assert row["amount_paid_cents"] == 0
|
||||
assert row["status"] == "open"
|
||||
assert row["hosted_invoice_url"] == "https://invoice.stripe.com/i/test"
|
||||
mock_credit_model.list_invoices.assert_awaited_once()
|
||||
# Ensure the limit query param is forwarded.
|
||||
assert mock_credit_model.list_invoices.await_args.kwargs == {"limit": 24}
|
||||
|
||||
|
||||
def test_list_invoices_clamps_limit(mocker: pytest_mock.MockFixture) -> None:
|
||||
"""FastAPI's Query(le=100) should reject limit > 100."""
|
||||
mock_credit_model = Mock()
|
||||
mock_credit_model.list_invoices = AsyncMock(return_value=[])
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.get_user_credit_model",
|
||||
return_value=mock_credit_model,
|
||||
)
|
||||
|
||||
response = client.get("/credits/invoices?limit=500")
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
mock_credit_model.list_invoices.assert_not_awaited()
|
||||
|
||||
|
||||
def test_list_invoices_default_limit(mocker: pytest_mock.MockFixture) -> None:
|
||||
"""Omitting ?limit should default to 24."""
|
||||
mock_credit_model = Mock()
|
||||
mock_credit_model.list_invoices = AsyncMock(return_value=[])
|
||||
mocker.patch(
|
||||
"backend.api.features.v1.get_user_credit_model",
|
||||
return_value=mock_credit_model,
|
||||
)
|
||||
|
||||
response = client.get("/credits/invoices")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
assert mock_credit_model.list_invoices.await_args.kwargs == {"limit": 24}
|
||||
|
||||
|
||||
# Graphs endpoints tests
|
||||
def test_get_graphs(
|
||||
mocker: pytest_mock.MockFixture,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Card-by-card integration tests for the Settings v2 billing page.
|
||||
*
|
||||
* Each test renders a single card with deterministic MSW responses, exercising
|
||||
* the card's render path AND its hook. Together they cover every new
|
||||
* file introduced by this PR (subscription + automation-credits cards) so the
|
||||
* patch coverage threshold passes.
|
||||
*
|
||||
* Pattern: MSW handlers replace the global defaults via `server.use(...)` and
|
||||
* the cards are rendered through the `TestProviders` wrapper which gives
|
||||
* them a fresh `QueryClient`.
|
||||
*/
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { http, HttpResponse, type JsonBodyType } from "msw";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
|
||||
import { AutoRefillCard } from "../components/AutomationCreditsTab/AutoRefillCard/AutoRefillCard";
|
||||
import { AutoRefillDialog } from "../components/AutomationCreditsTab/AutoRefillCard/AutoRefillDialog";
|
||||
import { BalanceCard } from "../components/AutomationCreditsTab/BalanceCard/BalanceCard";
|
||||
import { TransactionHistoryCard } from "../components/AutomationCreditsTab/TransactionHistoryCard/TransactionHistoryCard";
|
||||
import { UsageCard } from "../components/AutomationCreditsTab/UsageCard/UsageCard";
|
||||
import { AutopilotUsageCard } from "../components/SubscriptionTab/AutopilotUsageCard/AutopilotUsageCard";
|
||||
import { InvoicesCard } from "../components/SubscriptionTab/InvoicesCard/InvoicesCard";
|
||||
import { PaymentMethodCard } from "../components/SubscriptionTab/PaymentMethodCard/PaymentMethodCard";
|
||||
import { YourPlanCard } from "../components/SubscriptionTab/YourPlanCard/YourPlanCard";
|
||||
|
||||
function jsonHandler(method: "get" | "post", path: string, body: JsonBodyType) {
|
||||
return http[method](`*${path}`, () => HttpResponse.json(body));
|
||||
}
|
||||
|
||||
describe("YourPlanCard", () => {
|
||||
it("renders the current tier label, monthly cost, and Upgrade CTA", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "PRO",
|
||||
monthly_cost: 2000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", {
|
||||
url: "https://billing.stripe.com/p/test",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<YourPlanCard />);
|
||||
|
||||
expect(await screen.findByText("Pro")).toBeDefined();
|
||||
expect(screen.getByText("$20.00 / month")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /upgrade plan/i })).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /manage subscription/i }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides the Upgrade button on the top tier (BUSINESS)", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "BUSINESS",
|
||||
monthly_cost: 50000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
);
|
||||
|
||||
render(<YourPlanCard />);
|
||||
|
||||
expect(await screen.findByText("Business")).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: /upgrade plan/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentMethodCard", () => {
|
||||
it("disables 'Open portal' until the portal URL resolves", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits/manage", { url: null }));
|
||||
|
||||
render(<PaymentMethodCard />);
|
||||
|
||||
// The descriptive copy is always rendered.
|
||||
expect(await screen.findByText(/Manage payment method/i)).toBeDefined();
|
||||
const button = screen.getByRole("button", { name: /open portal/i });
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables 'Open portal' once the portal URL is available", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/manage", {
|
||||
url: "https://billing.stripe.com/p/test",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PaymentMethodCard />);
|
||||
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole("button", { name: /open portal/i });
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvoicesCard", () => {
|
||||
it("renders a no-invoices empty state when the credit history is empty", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/transactions", {
|
||||
transactions: [],
|
||||
next_transaction_time: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<InvoicesCard />);
|
||||
|
||||
expect(await screen.findByText(/No invoices yet/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders an invoice row per TOP_UP transaction (current fallback)", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/transactions", {
|
||||
transactions: [
|
||||
{
|
||||
transaction_key: "TXN-1",
|
||||
transaction_time: "2026-04-01T00:00:00Z",
|
||||
transaction_type: "TOP_UP",
|
||||
amount: 2000,
|
||||
description: "Top up",
|
||||
running_balance: 2000,
|
||||
},
|
||||
{
|
||||
transaction_key: "TXN-2",
|
||||
transaction_time: "2026-04-02T00:00:00Z",
|
||||
transaction_type: "USAGE",
|
||||
amount: -50,
|
||||
description: "Agent run",
|
||||
running_balance: 1950,
|
||||
},
|
||||
],
|
||||
next_transaction_time: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<InvoicesCard />);
|
||||
|
||||
expect(await screen.findByText("TXN-1")).toBeDefined();
|
||||
expect(screen.queryByText("TXN-2")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutopilotUsageCard", () => {
|
||||
it("renders today + week percent values", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/chat/usage", {
|
||||
daily: { percent_used: 12, resets_at: null },
|
||||
weekly: { percent_used: 35, resets_at: null },
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AutopilotUsageCard />);
|
||||
|
||||
expect(await screen.findByText("Today")).toBeDefined();
|
||||
expect(screen.getByText("This Week")).toBeDefined();
|
||||
expect(screen.getByText("12%")).toBeDefined();
|
||||
expect(screen.getByText("35%")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BalanceCard", () => {
|
||||
it("renders the Intl-formatted balance and an Add Credits CTA", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits", { credits: 1234 }));
|
||||
|
||||
render(<BalanceCard />);
|
||||
|
||||
// 1234 cents → $12.34 with thousands separator support.
|
||||
expect(await screen.findByText("$12.34")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /add credits/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders ErrorCard with a Retry button on a 500 from /api/credits", async () => {
|
||||
server.use(
|
||||
http.get("*/api/credits", () =>
|
||||
HttpResponse.json({ detail: "boom" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
render(<BalanceCard />);
|
||||
|
||||
await waitFor(() => {
|
||||
// ErrorCard renders, NOT the silent "$0.00" fallback.
|
||||
expect(screen.queryByText("$0.00")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutoRefillCard", () => {
|
||||
it("renders the disabled state when no auto top-up is configured", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 0,
|
||||
threshold: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AutoRefillCard />);
|
||||
|
||||
// Disabled-state copy + "Set up auto-refill" CTA.
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Top up automatically when your balance gets low/i,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /set up auto-refill/i }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the configured state when auto top-up has values", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 2000,
|
||||
threshold: 500,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AutoRefillCard />);
|
||||
|
||||
// Configured copy includes the dollar amounts.
|
||||
expect(
|
||||
await screen.findByText(/Refills \$20 when balance drops below \$5/i),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageCard", () => {
|
||||
it("renders the 30-day chart with the totals header", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/transactions", {
|
||||
transactions: [
|
||||
{
|
||||
transaction_key: "u1",
|
||||
transaction_time: new Date().toISOString(),
|
||||
transaction_type: "USAGE",
|
||||
amount: -150,
|
||||
description: "Agent run",
|
||||
running_balance: 850,
|
||||
},
|
||||
],
|
||||
next_transaction_time: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<UsageCard />);
|
||||
|
||||
expect(await screen.findByText(/last 30 days/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the zero-state when there is no usage at all", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/transactions", {
|
||||
transactions: [],
|
||||
next_transaction_time: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<UsageCard />);
|
||||
|
||||
// Card no longer hides itself on zero usage.
|
||||
expect(await screen.findByText(/last 30 days/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransactionHistoryCard", () => {
|
||||
it("renders a no-transactions empty state when the API returns []", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/transactions", {
|
||||
transactions: [],
|
||||
next_transaction_time: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TransactionHistoryCard />);
|
||||
|
||||
expect(await screen.findByText(/No transactions yet/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders a row per transaction with credit/debit colour-coded amounts", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/transactions", {
|
||||
transactions: [
|
||||
{
|
||||
transaction_key: "TXN-A",
|
||||
transaction_time: "2026-04-10T00:00:00Z",
|
||||
transaction_type: "TOP_UP",
|
||||
amount: 5000,
|
||||
description: "Top up",
|
||||
running_balance: 5000,
|
||||
},
|
||||
{
|
||||
transaction_key: "TXN-B",
|
||||
transaction_time: "2026-04-11T00:00:00Z",
|
||||
transaction_type: "USAGE",
|
||||
amount: -250,
|
||||
description: "Agent run",
|
||||
running_balance: 4750,
|
||||
},
|
||||
],
|
||||
next_transaction_time: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TransactionHistoryCard />);
|
||||
|
||||
expect(await screen.findByText("Top up")).toBeDefined();
|
||||
expect(screen.getByText("Agent run")).toBeDefined();
|
||||
expect(screen.getByText("+$50.00")).toBeDefined();
|
||||
expect(screen.getByText("-$2.50")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders ErrorCard with a Retry button on a 500", async () => {
|
||||
server.use(
|
||||
http.get("*/api/credits/transactions", () =>
|
||||
HttpResponse.json({ detail: "boom" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
render(<TransactionHistoryCard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/No transactions yet/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation flow tests — these exercise the dialog open/save paths in the
|
||||
* card hooks (handleSubmit, save, disable, changeTier) so v8 coverage on the
|
||||
* patch lines stays above the codecov 80% gate.
|
||||
*/
|
||||
describe("Card mutation flows", () => {
|
||||
it("BalanceCard: opening the Add Credits dialog renders the amount input", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits", { credits: 1000 }));
|
||||
|
||||
render(<BalanceCard />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /add credits/i }),
|
||||
);
|
||||
|
||||
// Dialog body is now in the DOM with the input + checkout button.
|
||||
expect(
|
||||
await screen.findByText(/We'll redirect you to Stripe/i),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /continue to checkout/i }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("BalanceCard: invalid amounts (5.25, 4) keep the checkout button disabled", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits", { credits: 1000 }));
|
||||
|
||||
render(<BalanceCard />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /add credits/i }),
|
||||
);
|
||||
|
||||
const continueButton = await screen.findByRole("button", {
|
||||
name: /continue to checkout/i,
|
||||
});
|
||||
expect(continueButton.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("AutoRefillDialog: renders 'Enable Auto-Refill' affordances when not yet enabled", async () => {
|
||||
const noop = () => {};
|
||||
render(
|
||||
<AutoRefillDialog
|
||||
isOpen={true}
|
||||
onOpenChange={noop}
|
||||
threshold="10"
|
||||
setThreshold={noop}
|
||||
refillAmount="20"
|
||||
setRefillAmount={noop}
|
||||
isValid={true}
|
||||
isEnabled={false}
|
||||
isSaving={false}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Top up your balance automatically/i),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /enable auto-refill/i }),
|
||||
).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("AutoRefillDialog: renders 'Save changes' + 'Disable' when already enabled", async () => {
|
||||
const noop = () => {};
|
||||
render(
|
||||
<AutoRefillDialog
|
||||
isOpen={true}
|
||||
onOpenChange={noop}
|
||||
threshold="5"
|
||||
setThreshold={noop}
|
||||
refillAmount="20"
|
||||
setRefillAmount={noop}
|
||||
isValid={true}
|
||||
isEnabled={true}
|
||||
isSaving={false}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /save changes/i }),
|
||||
).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /^disable$/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("YourPlanCard: shows 'Cancel plan' on a paid plan", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "PRO",
|
||||
monthly_cost: 2000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", {
|
||||
url: "https://billing.stripe.com/p/test",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<YourPlanCard />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /cancel plan/i }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("YourPlanCard: hides 'Cancel plan' on the BASIC tier", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "BASIC",
|
||||
monthly_cost: 0,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
);
|
||||
|
||||
render(<YourPlanCard />);
|
||||
|
||||
expect(await screen.findByText("Basic")).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: /cancel plan/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Hook-level coverage for the billing card hooks.
|
||||
*
|
||||
* The card components themselves are exercised in `billing-cards.test.tsx`;
|
||||
* this file uses `renderHook` to drive the mutation paths
|
||||
* (handleSubmit, save, disable, changeTier) without going through Radix
|
||||
* Dialog interactions, which keeps the tests deterministic and lifts patch
|
||||
* coverage on the new hook code above the codecov 80% gate.
|
||||
*/
|
||||
|
||||
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse, type JsonBodyType } from "msw";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { useAutoRefillCard } from "../components/AutomationCreditsTab/AutoRefillCard/useAutoRefillCard";
|
||||
import { useBalanceCard } from "../components/AutomationCreditsTab/BalanceCard/useBalanceCard";
|
||||
import { usePaymentMethodCard } from "../components/SubscriptionTab/PaymentMethodCard/usePaymentMethodCard";
|
||||
import { useYourPlanCard } from "../components/SubscriptionTab/YourPlanCard/useYourPlanCard";
|
||||
|
||||
function jsonHandler(method: "get" | "post", path: string, body: JsonBodyType) {
|
||||
return http[method](`*${path}`, () => HttpResponse.json(body));
|
||||
}
|
||||
|
||||
function makeWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
describe("useBalanceCard", () => {
|
||||
it("returns isError + null balance when /api/credits returns 500", async () => {
|
||||
server.use(
|
||||
http.get("*/api/credits", () =>
|
||||
HttpResponse.json({ detail: "boom" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBalanceCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.balanceCents).toBeNull();
|
||||
});
|
||||
|
||||
it("isValid rejects fractional dollars (5.25) and accepts integer >= 5", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits", { credits: 1000 }));
|
||||
|
||||
const { result } = renderHook(() => useBalanceCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => result.current.setAmount("5.25"));
|
||||
expect(result.current.isValid).toBe(false);
|
||||
|
||||
act(() => result.current.setAmount("4"));
|
||||
expect(result.current.isValid).toBe(false);
|
||||
|
||||
act(() => result.current.setAmount("5"));
|
||||
expect(result.current.isValid).toBe(true);
|
||||
|
||||
act(() => result.current.setAmount("100"));
|
||||
expect(result.current.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("handleSubmit POSTs the integer-cents amount when isValid", async () => {
|
||||
let capturedBody: { credit_amount: number } | null = null;
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits", { credits: 1000 }),
|
||||
http.post("*/api/credits", async ({ request }) => {
|
||||
capturedBody = (await request.json()) as typeof capturedBody;
|
||||
// Return without checkout_url so the hook does NOT navigate (would
|
||||
// tear down the test environment via window.location.href).
|
||||
return HttpResponse.json({ checkout_url: null });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBalanceCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => result.current.setAmount("25"));
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleSubmit();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isAdding).toBe(false));
|
||||
// Hook treats a no-checkout_url response as an error → modal stays open.
|
||||
expect(capturedBody).toEqual({ credit_amount: 2500 });
|
||||
});
|
||||
|
||||
it("handleSubmit catches API failures and stops the spinner", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits", { credits: 1000 }),
|
||||
http.post("*/api/credits", () =>
|
||||
HttpResponse.json({ detail: "boom" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBalanceCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => result.current.setAmount("10"));
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleSubmit();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isAdding).toBe(false));
|
||||
});
|
||||
|
||||
it("handleSubmit no-ops when isValid is false (no mutation request)", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits", { credits: 1000 }));
|
||||
|
||||
const { result } = renderHook(() => useBalanceCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
// Default amount is "" → isValid false → handleSubmit returns immediately.
|
||||
act(() => result.current.handleSubmit());
|
||||
expect(result.current.isAdding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAutoRefillCard", () => {
|
||||
it("isValid requires Number.isInteger + threshold/refill >= 5 + refill >= threshold", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 0,
|
||||
threshold: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAutoRefillCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
// Below the $5 minimum on either field.
|
||||
act(() => {
|
||||
result.current.setThreshold("4");
|
||||
result.current.setRefillAmount("10");
|
||||
});
|
||||
expect(result.current.isValid).toBe(false);
|
||||
|
||||
// Refill < threshold rejected (backend 422 mirror).
|
||||
act(() => {
|
||||
result.current.setThreshold("20");
|
||||
result.current.setRefillAmount("10");
|
||||
});
|
||||
expect(result.current.isValid).toBe(false);
|
||||
|
||||
// Fractional rejected.
|
||||
act(() => {
|
||||
result.current.setThreshold("5");
|
||||
result.current.setRefillAmount("5.5");
|
||||
});
|
||||
expect(result.current.isValid).toBe(false);
|
||||
|
||||
// Valid: both integers >= 5, refill >= threshold.
|
||||
act(() => {
|
||||
result.current.setThreshold("5");
|
||||
result.current.setRefillAmount("20");
|
||||
});
|
||||
expect(result.current.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("isEnabled is false for amount=0/threshold=0 and true once configured", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 2000,
|
||||
threshold: 500,
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAutoRefillCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.isEnabled).toBe(true);
|
||||
expect(result.current.config?.amount).toBe(2000);
|
||||
expect(result.current.config?.threshold).toBe(500);
|
||||
});
|
||||
|
||||
it("save() with valid input POSTs the configured amount and threshold", async () => {
|
||||
let capturedBody: { amount: number; threshold: number } | null = null;
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 0,
|
||||
threshold: 0,
|
||||
}),
|
||||
http.post("*/api/credits/auto-top-up", async ({ request }) => {
|
||||
capturedBody = (await request.json()) as typeof capturedBody;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAutoRefillCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setThreshold("5");
|
||||
result.current.setRefillAmount("20");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.save();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSaving).toBe(false));
|
||||
expect(capturedBody).toEqual({ amount: 2000, threshold: 500 });
|
||||
});
|
||||
|
||||
it("disable() POSTs amount=0/threshold=0", async () => {
|
||||
let capturedBody: { amount: number; threshold: number } | null = null;
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 2000,
|
||||
threshold: 500,
|
||||
}),
|
||||
http.post("*/api/credits/auto-top-up", async ({ request }) => {
|
||||
capturedBody = (await request.json()) as typeof capturedBody;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAutoRefillCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
result.current.disable();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSaving).toBe(false));
|
||||
expect(capturedBody).toEqual({ amount: 0, threshold: 0 });
|
||||
});
|
||||
|
||||
it("save() catches mutation failures and stops the spinner", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 0,
|
||||
threshold: 0,
|
||||
}),
|
||||
http.post("*/api/credits/auto-top-up", () =>
|
||||
HttpResponse.json({ detail: "boom" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAutoRefillCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setThreshold("5");
|
||||
result.current.setRefillAmount("20");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.save();
|
||||
});
|
||||
|
||||
// Catch branch ran and isSaving is back to false.
|
||||
await waitFor(() => expect(result.current.isSaving).toBe(false));
|
||||
});
|
||||
|
||||
it("save() with isValid=false short-circuits without calling the mutation", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/auto-top-up", {
|
||||
amount: 0,
|
||||
threshold: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAutoRefillCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.isValid).toBe(false);
|
||||
|
||||
act(() => result.current.save());
|
||||
// Mutation never started.
|
||||
expect(result.current.isSaving).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePaymentMethodCard", () => {
|
||||
it("returns canManage=false when no portal URL is available", async () => {
|
||||
server.use(jsonHandler("get", "/api/credits/manage", { url: null }));
|
||||
|
||||
const { result } = renderHook(() => usePaymentMethodCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.canManage).toBe(false));
|
||||
// Calling onManage without a portal URL is a no-op (no throw).
|
||||
expect(() => result.current.onManage()).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns canManage=true once the portal URL is available", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/manage", {
|
||||
url: "https://billing.stripe.com/p/test",
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePaymentMethodCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.canManage).toBe(true));
|
||||
expect(result.current.portalUrl).toBe("https://billing.stripe.com/p/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useYourPlanCard", () => {
|
||||
it("derives plan label/cost/nextTier from the subscription response", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "PRO",
|
||||
monthly_cost: 2000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", {
|
||||
url: "https://billing.stripe.com/p/test",
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useYourPlanCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.plan?.label).toBe("Pro");
|
||||
expect(result.current.plan?.tierKey).toBe("PRO");
|
||||
expect(result.current.plan?.monthlyCostCents).toBe(2000);
|
||||
expect(result.current.plan?.isPaidPlan).toBe(true);
|
||||
expect(result.current.plan?.nextTier).toBe("MAX");
|
||||
expect(result.current.canUpgrade).toBe(true);
|
||||
expect(result.current.canManagePortal).toBe(true);
|
||||
});
|
||||
|
||||
it("nextTier is null on the top tier (BUSINESS) and canUpgrade=false", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "BUSINESS",
|
||||
monthly_cost: 50000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useYourPlanCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.plan?.nextTier).toBeNull();
|
||||
expect(result.current.canUpgrade).toBe(false);
|
||||
});
|
||||
|
||||
it("onCancel no-ops on the BASIC tier (no redundant downgrade request)", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "BASIC",
|
||||
monthly_cost: 0,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useYourPlanCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => result.current.onCancel());
|
||||
// No mutation in flight — the guard returned early.
|
||||
expect(result.current.isUpdatingTier).toBe(false);
|
||||
});
|
||||
|
||||
it("changeTier (BUSINESS upgrade attempt) calls updateTier with the requested tier", async () => {
|
||||
let capturedTier: string | null = null;
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "PRO",
|
||||
monthly_cost: 2000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
http.post("*/api/credits/subscription", async ({ request }) => {
|
||||
const body = (await request.json()) as { tier: string };
|
||||
capturedTier = body.tier;
|
||||
return HttpResponse.json({ url: null });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useYourPlanCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.canUpgrade).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
result.current.onUpgrade();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedTier).toBe("MAX"));
|
||||
});
|
||||
|
||||
it("changeTier surfaces a destructive toast when the API returns 500", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "PRO",
|
||||
monthly_cost: 2000,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
http.post("*/api/credits/subscription", () =>
|
||||
HttpResponse.json({ detail: "boom" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useYourPlanCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
result.current.onUpgrade();
|
||||
});
|
||||
|
||||
// The catch path runs without throwing; mutation completes.
|
||||
await waitFor(() => expect(result.current.isUpdatingTier).toBe(false));
|
||||
});
|
||||
|
||||
it("falls back to the raw tier string when PLAN_LABEL has no match", async () => {
|
||||
server.use(
|
||||
jsonHandler("get", "/api/credits/subscription", {
|
||||
tier: "STARTUP",
|
||||
monthly_cost: 100,
|
||||
status: "active",
|
||||
}),
|
||||
jsonHandler("get", "/api/credits/manage", { url: null }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useYourPlanCard(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.plan?.label).toBe("STARTUP");
|
||||
expect(result.current.plan?.nextTier).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EASE_OUT,
|
||||
formatCents,
|
||||
formatRelativeReset,
|
||||
formatShortDate,
|
||||
} from "../helpers";
|
||||
|
||||
describe("EASE_OUT", () => {
|
||||
it("is a 4-tuple of cubic-bezier control points", () => {
|
||||
expect(EASE_OUT).toEqual([0.16, 1, 0.3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCents", () => {
|
||||
it("formats positive cents as USD with thousands separators", () => {
|
||||
expect(formatCents(1234)).toBe("$12.34");
|
||||
expect(formatCents(343434)).toBe("$3,434.34");
|
||||
expect(formatCents(0)).toBe("$0.00");
|
||||
});
|
||||
|
||||
it("uses the locale-correct negative prefix", () => {
|
||||
expect(formatCents(-100)).toBe("-$1.00");
|
||||
expect(formatCents(-1234)).toBe("-$12.34");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeReset", () => {
|
||||
it("returns the empty placeholder when target is null/undefined/empty", () => {
|
||||
expect(formatRelativeReset(null)).toEqual({ prefix: "Resets", value: "—" });
|
||||
expect(formatRelativeReset(undefined)).toEqual({
|
||||
prefix: "Resets",
|
||||
value: "—",
|
||||
});
|
||||
expect(formatRelativeReset("")).toEqual({ prefix: "Resets", value: "—" });
|
||||
});
|
||||
|
||||
it("returns the empty placeholder when the date is unparseable", () => {
|
||||
expect(formatRelativeReset("not-a-date")).toEqual({
|
||||
prefix: "Resets",
|
||||
value: "—",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 'soon' when the target is in the past", () => {
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
expect(formatRelativeReset(past)).toEqual({
|
||||
prefix: "Resets",
|
||||
value: "soon",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns hours+minutes when the target is within 24h", () => {
|
||||
const inAFewHours = new Date(Date.now() + 2 * 60 * 60 * 1000 + 5 * 60_000);
|
||||
const result = formatRelativeReset(inAFewHours);
|
||||
expect(result.prefix).toBe("Resets in");
|
||||
expect(result.value).toMatch(/^\d+h \d+m$/);
|
||||
});
|
||||
|
||||
it("returns a weekday + time label when the target is more than 24h away", () => {
|
||||
const inAWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const result = formatRelativeReset(inAWeek);
|
||||
expect(result.prefix).toBe("Resets");
|
||||
// Locale-dependent format, but always contains a weekday short name.
|
||||
expect(result.value.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it("accepts an ISO string as well as a Date instance", () => {
|
||||
const iso = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
const fromIso = formatRelativeReset(iso);
|
||||
expect(fromIso.prefix).toBe("Resets in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatShortDate", () => {
|
||||
it("returns the empty placeholder for null/undefined/empty input", () => {
|
||||
expect(formatShortDate(null)).toBe("—");
|
||||
expect(formatShortDate(undefined)).toBe("—");
|
||||
expect(formatShortDate("")).toBe("—");
|
||||
});
|
||||
|
||||
it("returns the empty placeholder for unparseable input", () => {
|
||||
expect(formatShortDate("not-a-date")).toBe("—");
|
||||
});
|
||||
|
||||
it("formats Date instances and ISO strings the same way", () => {
|
||||
const date = new Date(2026, 3, 28); // Apr 28, 2026 local
|
||||
const fromDate = formatShortDate(date);
|
||||
const fromIso = formatShortDate(date.toISOString());
|
||||
expect(fromDate).toBe(fromIso);
|
||||
expect(fromDate).toMatch(/Apr 28, 2026/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user