diff --git a/canvas/src/app/__tests__/orgs-page.test.tsx b/canvas/src/app/__tests__/orgs-page.test.tsx
index 430aa8f0..db0adeb7 100644
--- a/canvas/src/app/__tests__/orgs-page.test.tsx
+++ b/canvas/src/app/__tests__/orgs-page.test.tsx
@@ -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();
- 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();
+ 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();
- 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();
+ 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();
- 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();
+ 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();
- 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();
+ 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=", 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();
- 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();
+ 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();
- 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();
+ 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();
- 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();
+ 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();
- 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();
+ 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();
- 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();
+ 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();
- // 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();
- 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();
- 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();