Skip to content

Commit 10d6db6

Browse files
committed
test(ui-react): add billing feature tests
Covers the billing workstream with Vitest + React Testing Library: - useBilling.test.ts: mutations (create customer/subscription, attach/detach/default PM), query enable/disable behavior, useOpenBillingPortal URL open + missing-URL error - billing.test.ts (types): toCustomer and toSubscription transforms, readNamespaceBilling safe narrowing, getSubscriptionStatus sentinel - stripeErrors.test.ts: known-code mapping + unknown-code fallback - BillingDialog.test.tsx: all 4 wizard steps, Next/Back nav, error paths (402, generic, incomplete status), pending state, live region announcements, close buttons - BillingSection.test.tsx: Subscribe/portal button visibility per status, non-owner view, warning banners, dialog open/close - BillingWarning.test.tsx: renders, confirm navigates, cancel closes - Update CreateNamespaceDialog + InvitationsMenu + Login mock getConfig to include new stripePublishableKey field in ClientConfig
1 parent 77e0b04 commit 10d6db6

9 files changed

Lines changed: 1413 additions & 3 deletions

File tree

ui-react/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx

Lines changed: 459 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { render, screen, cleanup, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { MemoryRouter } from "react-router-dom";
5+
import React from "react";
6+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
7+
8+
const mockLocation = { hash: "", pathname: "/settings", search: "" };
9+
10+
vi.mock("react-router-dom", async () => {
11+
const actual =
12+
await vi.importActual<typeof import("react-router-dom")>(
13+
"react-router-dom",
14+
);
15+
return { ...actual, useLocation: () => mockLocation };
16+
});
17+
18+
const mockOpenPortalMutateAsync = vi.fn();
19+
const mockSubscriptionData = {
20+
value: null as {
21+
status: string;
22+
end_at?: number;
23+
invoices?: unknown[];
24+
} | null,
25+
};
26+
const mockOpenPortalIsPending = { value: false };
27+
28+
// Also includes useCreateSubscription because the real BillingDialog is
29+
// lazy-loaded inside BillingSection — vi.mock() on the BillingDialog module
30+
// does not intercept React.lazy dynamic imports, so the real component
31+
// renders and needs all its hook dependencies satisfied.
32+
vi.mock("@/hooks/useBilling", () => ({
33+
useSubscription: () => ({
34+
subscription: mockSubscriptionData.value,
35+
isLoading: false,
36+
refetch: vi.fn().mockResolvedValue({ data: { status: "active" } }),
37+
}),
38+
useOpenBillingPortal: () => ({
39+
mutateAsync: (...args: unknown[]): Promise<unknown> =>
40+
mockOpenPortalMutateAsync(...args) as Promise<unknown>,
41+
isPending: mockOpenPortalIsPending.value,
42+
}),
43+
useCreateSubscription: () => ({
44+
mutateAsync: vi.fn().mockResolvedValue(undefined),
45+
isPending: false,
46+
}),
47+
}));
48+
49+
const mockNamespaceData = {
50+
value: null as { billing?: Record<string, unknown> } | null,
51+
};
52+
53+
vi.mock("@/hooks/useNamespaces", () => ({
54+
useNamespace: () => ({
55+
namespace: mockNamespaceData.value,
56+
isLoading: false,
57+
}),
58+
}));
59+
60+
vi.mock("@/stores/authStore", () => ({
61+
useAuthStore: () => ({ tenant: "tenant-id-123" }),
62+
}));
63+
64+
const mockCanSubscribe = { value: true };
65+
66+
vi.mock("@/hooks/useHasPermission", () => ({
67+
useHasPermission: () => mockCanSubscribe.value,
68+
}));
69+
70+
const mockInvalidate = vi.fn();
71+
72+
vi.mock("@/hooks/useInvalidateQueries", () => ({
73+
useInvalidateByIds: () => mockInvalidate,
74+
}));
75+
76+
// Deep deps of the real BillingDialog (which gets rendered via React.lazy):
77+
vi.mock("@/hooks/useFocusTrap", () => ({ useFocusTrap: vi.fn() }));
78+
79+
vi.mock("@/api/errors", () => ({
80+
isSdkError: (err: unknown): boolean =>
81+
typeof err === "object" && err !== null && "status" in err,
82+
}));
83+
84+
// BaseDialog uses native <dialog> + focus trap — replace with a plain wrapper
85+
vi.mock("@/components/common/BaseDialog", () => ({
86+
default: ({
87+
open,
88+
children,
89+
"aria-label": ariaLabel,
90+
}: {
91+
open: boolean;
92+
onClose: () => void;
93+
canClose?: () => boolean;
94+
children: React.ReactNode;
95+
"aria-label"?: string;
96+
}) => {
97+
if (!open) return null;
98+
return React.createElement(
99+
"div",
100+
{ role: "dialog", "aria-label": ariaLabel },
101+
children,
102+
);
103+
},
104+
}));
105+
106+
// Stub the wizard steps — we only care that the dialog opens/closes here.
107+
vi.mock("@/components/billing/BillingLetter", () => ({
108+
default: () =>
109+
React.createElement("div", { "data-testid": "billing-letter" }),
110+
}));
111+
vi.mock("@/components/billing/BillingPayment", () => ({ default: () => null }));
112+
vi.mock("@/components/billing/BillingCheckout", () => ({
113+
default: () => null,
114+
}));
115+
vi.mock("@/components/billing/BillingSuccessful", () => ({
116+
default: () => null,
117+
}));
118+
119+
import BillingSection from "../BillingSection";
120+
121+
function createWrapper() {
122+
const queryClient = new QueryClient({
123+
defaultOptions: { queries: { retry: false } },
124+
});
125+
return ({ children }: { children: React.ReactNode }) => (
126+
<MemoryRouter>
127+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
128+
</MemoryRouter>
129+
);
130+
}
131+
132+
function renderSection() {
133+
return render(
134+
<React.Suspense fallback={null}>
135+
<BillingSection sectionId="billing" />
136+
</React.Suspense>,
137+
{ wrapper: createWrapper() },
138+
);
139+
}
140+
141+
function setStatus(
142+
status: string,
143+
extra: { end_at?: number; invoices?: unknown[] } = {},
144+
) {
145+
mockSubscriptionData.value = { status, ...extra };
146+
mockNamespaceData.value = { billing: { customer_id: "cus_123" } };
147+
}
148+
149+
function setInactive() {
150+
mockSubscriptionData.value = null;
151+
mockNamespaceData.value = { billing: {} };
152+
}
153+
154+
beforeEach(() => {
155+
vi.clearAllMocks();
156+
mockCanSubscribe.value = true;
157+
mockOpenPortalIsPending.value = false;
158+
setInactive();
159+
});
160+
161+
afterEach(cleanup);
162+
163+
describe("BillingSection — Subscribe button visibility", () => {
164+
it("shows Subscribe button when status is 'inactive'", () => {
165+
setInactive();
166+
renderSection();
167+
expect(
168+
screen.getByRole("button", { name: /subscribe/i }),
169+
).toBeInTheDocument();
170+
});
171+
172+
it("does not show portal button when status is 'inactive'", () => {
173+
setInactive();
174+
renderSection();
175+
expect(
176+
screen.queryByRole("button", { name: /open portal/i }),
177+
).not.toBeInTheDocument();
178+
});
179+
180+
it("shows Subscribe button when status is 'canceled'", () => {
181+
setStatus("canceled");
182+
renderSection();
183+
expect(
184+
screen.getByRole("button", { name: /subscribe/i }),
185+
).toBeInTheDocument();
186+
});
187+
188+
it("shows Subscribe button when status is 'incomplete_expired'", () => {
189+
setStatus("incomplete_expired");
190+
renderSection();
191+
expect(
192+
screen.getByRole("button", { name: /subscribe/i }),
193+
).toBeInTheDocument();
194+
});
195+
196+
it("does not show Subscribe button when status is 'active'", () => {
197+
setStatus("active");
198+
renderSection();
199+
expect(
200+
screen.queryByRole("button", { name: /subscribe/i }),
201+
).not.toBeInTheDocument();
202+
});
203+
204+
it("shows portal button when status is 'active'", () => {
205+
setStatus("active");
206+
renderSection();
207+
expect(
208+
screen.getByRole("button", { name: /open portal/i }),
209+
).toBeInTheDocument();
210+
});
211+
212+
it("does not show Subscribe button when status is 'incomplete'", () => {
213+
setStatus("incomplete");
214+
renderSection();
215+
expect(
216+
screen.queryByRole("button", { name: /subscribe/i }),
217+
).not.toBeInTheDocument();
218+
});
219+
220+
it("shows portal button when status is 'incomplete'", () => {
221+
setStatus("incomplete");
222+
renderSection();
223+
expect(
224+
screen.getByRole("button", { name: /open portal/i }),
225+
).toBeInTheDocument();
226+
});
227+
228+
it("does not show Subscribe button when status is 'unpaid'; shows portal instead", () => {
229+
setStatus("unpaid");
230+
renderSection();
231+
expect(
232+
screen.queryByRole("button", { name: /subscribe/i }),
233+
).not.toBeInTheDocument();
234+
expect(
235+
screen.getByRole("button", { name: /open portal/i }),
236+
).toBeInTheDocument();
237+
});
238+
239+
it("does not show Subscribe button when status is 'paused'", () => {
240+
setStatus("paused");
241+
renderSection();
242+
expect(
243+
screen.queryByRole("button", { name: /subscribe/i }),
244+
).not.toBeInTheDocument();
245+
});
246+
247+
it("shows portal button when status is 'paused'", () => {
248+
setStatus("paused");
249+
renderSection();
250+
expect(
251+
screen.getByRole("button", { name: /open portal/i }),
252+
).toBeInTheDocument();
253+
});
254+
255+
it("does not show Subscribe button when status is 'past_due'", () => {
256+
setStatus("past_due");
257+
renderSection();
258+
expect(
259+
screen.queryByRole("button", { name: /subscribe/i }),
260+
).not.toBeInTheDocument();
261+
});
262+
263+
it("shows portal button when status is 'past_due'", () => {
264+
setStatus("past_due");
265+
renderSection();
266+
expect(
267+
screen.getByRole("button", { name: /open portal/i }),
268+
).toBeInTheDocument();
269+
});
270+
});
271+
272+
describe("BillingSection — non-owner", () => {
273+
beforeEach(() => {
274+
mockCanSubscribe.value = false;
275+
});
276+
277+
it("shows the 'Owner-only' row", () => {
278+
renderSection();
279+
expect(screen.getByText("Owner-only")).toBeInTheDocument();
280+
});
281+
282+
it("does not show the Subscribe button", () => {
283+
renderSection();
284+
expect(
285+
screen.queryByRole("button", { name: /subscribe/i }),
286+
).not.toBeInTheDocument();
287+
});
288+
289+
it("does not show the portal button", () => {
290+
setStatus("active");
291+
renderSection();
292+
expect(
293+
screen.queryByRole("button", { name: /open portal/i }),
294+
).not.toBeInTheDocument();
295+
});
296+
297+
it("does not show banners", () => {
298+
setStatus("past_due");
299+
renderSection();
300+
expect(screen.queryByText(/payment overdue/i)).not.toBeInTheDocument();
301+
});
302+
});
303+
304+
describe("BillingSection — banners", () => {
305+
it("shows 'Payment overdue' banner for 'past_due' status", () => {
306+
setStatus("past_due");
307+
renderSection();
308+
expect(screen.getByText("Payment overdue")).toBeInTheDocument();
309+
});
310+
311+
it("shows 'Subscription incomplete' banner with billing-portal wording for 'incomplete'", () => {
312+
setStatus("incomplete");
313+
renderSection();
314+
expect(screen.getByText("Subscription incomplete")).toBeInTheDocument();
315+
expect(screen.getByText(/open the billing portal/i)).toBeInTheDocument();
316+
});
317+
318+
it("shows 'Subscription expired' banner with 'Subscribe again' wording for 'incomplete_expired'", () => {
319+
setStatus("incomplete_expired");
320+
renderSection();
321+
expect(screen.getByText("Subscription expired")).toBeInTheDocument();
322+
expect(screen.getByText(/subscribe again/i)).toBeInTheDocument();
323+
});
324+
325+
it("shows no banner for 'active' status", () => {
326+
setStatus("active");
327+
renderSection();
328+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
329+
});
330+
});
331+
332+
describe("BillingSection — Subscribe button interaction", () => {
333+
it("clicking Subscribe opens BillingDialog", async () => {
334+
const user = userEvent.setup();
335+
setInactive();
336+
renderSection();
337+
await user.click(screen.getByRole("button", { name: /subscribe/i }));
338+
// Real BillingDialog is rendered via React.lazy; on step 1 it shows BillingLetter.
339+
expect(await screen.findByTestId("billing-letter")).toBeInTheDocument();
340+
expect(
341+
screen.getByRole("dialog", { name: /subscribe to shellhub cloud/i }),
342+
).toBeInTheDocument();
343+
});
344+
345+
it("closing BillingDialog via the X button hides it", async () => {
346+
const user = userEvent.setup();
347+
setInactive();
348+
renderSection();
349+
await user.click(screen.getByRole("button", { name: /subscribe/i }));
350+
await screen.findByTestId("billing-letter");
351+
await user.click(screen.getByRole("button", { name: /close wizard/i }));
352+
await waitFor(() =>
353+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
354+
);
355+
});
356+
});
357+
358+
describe("BillingSection — status badge", () => {
359+
it("shows 'Inactive' badge when there is no subscription", () => {
360+
setInactive();
361+
renderSection();
362+
expect(screen.getByText("Inactive")).toBeInTheDocument();
363+
});
364+
365+
it("shows 'Active' badge when status is active", () => {
366+
setStatus("active");
367+
renderSection();
368+
expect(screen.getByText("Active")).toBeInTheDocument();
369+
});
370+
371+
it("shows 'Past due' badge when status is past_due", () => {
372+
setStatus("past_due");
373+
renderSection();
374+
expect(screen.getByText("Past due")).toBeInTheDocument();
375+
});
376+
});

0 commit comments

Comments
 (0)