Merge branch 'fix/canvas-orgs-page-tests' into staging

This commit is contained in:
Molecule AI · cp-be 2026-04-20 23:47:02 +00:00
commit 696bd86322

View File

@ -36,6 +36,12 @@ vi.mock("@/lib/api", () => ({
PLATFORM_URL: "https://cp.test",
}));
// Mock TermsGate to a pass-through so it doesn't make network calls that
// consume the mockFetch queue. OrgsPage wraps its content in TermsGate.
vi.mock("@/components/TermsGate", () => ({
TermsGate: ({ children }: { children: React.ReactNode }) => children,
}));
const mockFetch = vi.fn();
globalThis.fetch = mockFetch as unknown as typeof fetch;
@ -80,6 +86,11 @@ function setLocation(href: string) {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock return values so each test starts fresh.
// The mock functions (vi.fn) persist across tests; only their
// per-call behavior is reset here.
mockFetchSession.mockReset();
mockFetch.mockReset();
setLocation("https://moleculesai.app/orgs");
});
@ -87,38 +98,60 @@ afterEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("/orgs — auth guard", () => {
it("redirects to login when session is null", async () => {
mockFetchSession.mockResolvedValueOnce(null);
render(<OrgsPage />);
await waitFor(() => expect(mockRedirectToLogin).toHaveBeenCalled());
// Must not attempt to fetch /cp/orgs before auth is established
expect(mockFetch).not.toHaveBeenCalledWith(
expect.stringContaining("/cp/orgs"),
expect.anything()
);
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValueOnce(null);
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
expect(mockRedirectToLogin).toHaveBeenCalled();
// Must not attempt to fetch /cp/orgs before auth is established
expect(mockFetch).not.toHaveBeenCalledWith(
expect.stringContaining("/cp/orgs"),
expect.anything()
);
} finally {
vi.useFakeTimers();
}
});
});
describe("/orgs — error state", () => {
it("shows error + Retry button when /cp/orgs fails", async () => {
mockFetchSession.mockResolvedValueOnce({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(notOk(500, "db down"));
render(<OrgsPage />);
await waitFor(() => expect(screen.getByText(/Error:/)).toBeTruthy());
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(notOk(500, "db down"));
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
expect(screen.getByText(/Error:/)).toBeTruthy();
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
} finally {
vi.useFakeTimers();
}
});
});
describe("/orgs — empty list", () => {
it("renders EmptyState with CreateOrgForm when user has zero orgs", async () => {
mockFetchSession.mockResolvedValueOnce({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await waitFor(() => expect(screen.getByText(/don't have any organizations/i)).toBeTruthy());
expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy();
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy();
} finally {
vi.useFakeTimers();
}
});
});
@ -126,127 +159,160 @@ describe("/orgs — CTAs by status", () => {
const session = { userId: "u-1" };
it("running → Open link targets {slug}.moleculesai.app", async () => {
mockFetchSession.mockResolvedValueOnce(session);
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-1",
slug: "acme",
name: "Acme",
plan: "pro",
status: "running",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
const link = (await screen.findByRole("link", { name: /open/i })) as HTMLAnchorElement;
expect(link.href).toBe("https://acme.moleculesai.app/");
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue(session);
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-1",
slug: "acme",
name: "Acme",
plan: "pro",
status: "running",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
const link = screen.getByRole("link", { name: /open/i }) as HTMLAnchorElement;
expect(link.href).toBe("https://acme.moleculesai.app/");
} finally {
vi.useFakeTimers();
}
});
it("awaiting_payment → Complete payment link to /pricing?org=<slug>", async () => {
mockFetchSession.mockResolvedValueOnce(session);
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-2",
slug: "beta-co",
name: "Beta",
plan: "",
status: "awaiting_payment",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
const link = (await screen.findByRole("link", {
name: /complete payment/i,
})) as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("/pricing?org=beta-co");
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue(session);
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-2",
slug: "beta-co",
name: "Beta",
plan: "",
status: "awaiting_payment",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
const link = screen.getByRole("link", {
name: /complete payment/i,
}) as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("/pricing?org=beta-co");
} finally {
vi.useFakeTimers();
}
});
it("failed → mailto support link", async () => {
mockFetchSession.mockResolvedValueOnce(session);
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-3",
slug: "boom",
name: "Boom",
plan: "",
status: "failed",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
const link = (await screen.findByRole("link", {
name: /contact support/i,
})) as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("mailto:support@moleculesai.app");
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue(session);
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-3",
slug: "boom",
name: "Boom",
plan: "",
status: "failed",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
const link = screen.getByRole("link", {
name: /contact support/i,
}) as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("mailto:support@moleculesai.app");
} finally {
vi.useFakeTimers();
}
});
});
describe("/orgs — post-checkout banner", () => {
it("renders CheckoutBanner when ?checkout=success and scrubs the URL", async () => {
setLocation("https://moleculesai.app/orgs?checkout=success");
const replaceState = vi.spyOn(window.history, "replaceState");
mockFetchSession.mockResolvedValueOnce({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-1",
slug: "acme",
name: "Acme",
plan: "pro",
status: "running",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
expect(await screen.findByText(/Payment confirmed/i)).toBeTruthy();
// URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner
expect(replaceState).toHaveBeenCalled();
const callArgs = replaceState.mock.calls[0];
expect(callArgs[2]).toBe("/orgs");
vi.useRealTimers();
try {
setLocation("https://moleculesai.app/orgs?checkout=success");
const replaceState = vi.spyOn(window.history, "replaceState");
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
{
id: "o-1",
slug: "acme",
name: "Acme",
plan: "pro",
status: "running",
created_at: "",
updated_at: "",
},
],
})
);
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
expect(screen.getByText(/Payment confirmed/i)).toBeTruthy();
// URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner
expect(replaceState).toHaveBeenCalled();
const callArgs = replaceState.mock.calls[0];
expect(callArgs[2]).toBe("/orgs");
} finally {
vi.useFakeTimers();
}
});
it("does NOT render CheckoutBanner without ?checkout=success", async () => {
mockFetchSession.mockResolvedValueOnce({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await waitFor(() =>
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy()
);
expect(screen.queryByText(/Payment confirmed/i)).toBeNull();
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
expect(screen.queryByText(/Payment confirmed/i)).toBeNull();
} finally {
vi.useFakeTimers();
}
});
});
describe("/orgs — fetch includes credentials + timeout signal", () => {
it("/cp/orgs fetch is called with credentials:include and an AbortSignal", async () => {
mockFetchSession.mockResolvedValueOnce({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
const callArgs = mockFetch.mock.calls.find((c) =>
String(c[0]).includes("/cp/orgs")
);
expect(callArgs).toBeDefined();
expect(callArgs![1]).toMatchObject({ credentials: "include" });
expect(callArgs![1].signal).toBeInstanceOf(AbortSignal);
vi.useRealTimers();
try {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
const callArgs = mockFetch.mock.calls.find((c) =>
String(c[0]).includes("/cp/orgs")
);
expect(callArgs).toBeDefined();
expect(callArgs![1]).toMatchObject({ credentials: "include" });
expect(callArgs![1].signal).toBeInstanceOf(AbortSignal);
} finally {
vi.useFakeTimers();
}
});
});
@ -261,6 +327,8 @@ describe("/orgs — polling of in-flight orgs", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
// First /cp/orgs returns provisioning orgs so a poll is scheduled.
// Second returns running orgs to observe the state flip stop re-scheduling.
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
@ -276,8 +344,6 @@ describe("/orgs — polling of in-flight orgs", () => {
],
})
);
// Second fetch (the poll refresh) returns a running org so we can
// observe the state flip — and to let the test stop re-scheduling.
mockFetch.mockResolvedValueOnce(
okJson({
orgs: [
@ -295,12 +361,10 @@ describe("/orgs — polling of in-flight orgs", () => {
);
render(<OrgsPage />);
// First fetch resolves
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
// Advance past the 5s scheduled refresh
// Auto-advancing time fires the 5s poll while we await
await vi.advanceTimersByTimeAsync(5_100);
// Second fetch is the poll refresh
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
// First /cp/orgs + second poll /cp/orgs
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
@ -326,9 +390,9 @@ describe("/orgs — polling of in-flight orgs", () => {
})
);
render(<OrgsPage />);
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
// Advance well past the 5s poll window — no second fetch must fire
// Auto-advancing time — no poll fires (stillMoving = false)
await vi.advanceTimersByTimeAsync(10_000);
// Only the initial /cp/orgs
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
@ -355,11 +419,15 @@ describe("/orgs — polling of in-flight orgs", () => {
})
);
const { unmount } = render(<OrgsPage />);
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
// Tear down BEFORE the 5s timer fires
// With shouldAdvanceTime, effects are deferred — flush microtasks first
// so the effect runs and schedules the 5s poll before we unmount.
await vi.advanceTimersByTimeAsync(0);
// Now the effect has run (scheduling the poll) but not the poll itself
expect(mockFetch).toHaveBeenCalledTimes(1);
// Tear down — cleanup must clear the 5s timer
unmount();
// Advance timers — the cleanup cleared the 5s timer, so no poll fires
await vi.advanceTimersByTimeAsync(10_000);
// Fetch count must stay at 1 — the cleanup cleared the timer
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();