diff --git a/canvas/src/app/__tests__/orgs-page.test.tsx b/canvas/src/app/__tests__/orgs-page.test.tsx new file mode 100644 index 00000000..430aa8f0 --- /dev/null +++ b/canvas/src/app/__tests__/orgs-page.test.tsx @@ -0,0 +1,368 @@ +// @vitest-environment jsdom +/** + * Tests for /orgs — the post-signup landing page (PR #992 feat/canvas-orgs-landing + * plus #994 feat/canvas-post-checkout-redirect). + * + * The page is the only route the control-plane Callback hands a new session to, + * so bugs here strand new users. Covers: + * - Signed-out → redirectToLogin + * - Failed /cp/orgs → error state + retry button + * - Empty org list → EmptyState w/ CreateOrgForm + * - `running` org → Open button links to `{slug}.{appDomain}` + * - `awaiting_payment` org → "Complete payment" → /pricing?org= + * - `failed` org → mailto support link + * - `?checkout=success` param → CheckoutBanner renders + URL is scrubbed + * - Polling: provisioning orgs schedule a 5s refresh (fake timers) + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; + +// ── Hoisted mocks ──────────────────────────────────────────────────────────── +// vi.mock factories are hoisted above imports; any captured references must +// come from vi.hoisted() or the factory would see "undefined before init". + +const { mockFetchSession, mockRedirectToLogin } = vi.hoisted(() => ({ + mockFetchSession: vi.fn(), + mockRedirectToLogin: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + fetchSession: mockFetchSession, + redirectToLogin: mockRedirectToLogin, +})); + +// api module provides PLATFORM_URL; page imports it as a constant +vi.mock("@/lib/api", () => ({ + PLATFORM_URL: "https://cp.test", +})); + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch as unknown as typeof fetch; + +// Import page AFTER mocks are declared +import OrgsPage from "../../app/orgs/page"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function okJson(body: unknown, status = 200) { + return { + ok: true, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as unknown as Response; +} + +function notOk(status: number, text = "boom") { + return { + ok: false, + status, + json: () => Promise.reject(new Error("no json")), + text: () => Promise.resolve(text), + } as unknown as Response; +} + +function setLocation(href: string) { + // jsdom allows window.location replacement via pushState rather than assign; + // the component only reads `search` + `hostname` + `pathname`. + const url = new URL(href); + window.history.pushState({}, "", url.pathname + url.search); + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...window.location, + hostname: url.hostname, + search: url.search, + pathname: url.pathname, + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + setLocation("https://moleculesai.app/orgs"); +}); + +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() + ); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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/"); + }); + + 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"); + }); + + 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"); + }); +}); + +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"); + }); + + 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(); + }); +}); + +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); + }); +}); + +// ── Polling ────────────────────────────────────────────────────────────────── +// page.tsx line 83-88: if any org is `provisioning` OR `awaiting_payment`, +// schedule a 5s refresh so the user sees the state flip live after Stripe +// Checkout returns. Cleanup must clear the timer on unmount; otherwise a +// fast-nav-away leaves the interval firing against the CP indefinitely. + +describe("/orgs — polling of in-flight orgs", () => { + it("schedules a 5s refetch when at least one org is provisioning", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + mockFetchSession.mockResolvedValue({ userId: "u-1" }); + mockFetch.mockResolvedValueOnce( + okJson({ + orgs: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "provisioning", + created_at: "", + updated_at: "", + }, + ], + }) + ); + // 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: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "running", + created_at: "", + updated_at: "", + }, + ], + }) + ); + + render(); + // First fetch resolves + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + // Advance past the 5s scheduled refresh + await vi.advanceTimersByTimeAsync(5_100); + // Second fetch is the poll refresh + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + } finally { + vi.useRealTimers(); + } + }); + + it("does NOT schedule a refetch when all orgs are running", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + 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 vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + // Advance well past the 5s poll window — no second fetch must fire + await vi.advanceTimersByTimeAsync(10_000); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the poll timer on unmount — no fetch after unmount", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + mockFetchSession.mockResolvedValue({ userId: "u-1" }); + mockFetch.mockResolvedValueOnce( + okJson({ + orgs: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "awaiting_payment", + created_at: "", + updated_at: "", + }, + ], + }) + ); + const { unmount } = render(); + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + // Tear down BEFORE the 5s timer fires + unmount(); + await vi.advanceTimersByTimeAsync(10_000); + // Fetch count must stay at 1 — the cleanup cleared the timer + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/canvas/src/lib/__tests__/api.test.ts b/canvas/src/lib/__tests__/api.test.ts index 01cb02cb..09eb0eff 100644 --- a/canvas/src/lib/__tests__/api.test.ts +++ b/canvas/src/lib/__tests__/api.test.ts @@ -309,3 +309,74 @@ describe("api – PLATFORM_URL default", () => { expect(url).toContain("localhost:8080"); }); }); + +// --------------------------------------------------------------------------- +// 15s timeout via AbortSignal.timeout (regression for #982 / +// fix/canvas-api-fetch-timeout). The signal prevents a hung backend from +// leaving the UI spinning forever. These assertions pin the behaviour so +// a future edit can't drop the signal without breaking tests. +// --------------------------------------------------------------------------- + +describe("api – request timeout signal", () => { + it("GET passes an AbortSignal to fetch", async () => { + mockSuccess({}); + await api.get("/workspaces"); + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeDefined(); + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("POST passes an AbortSignal to fetch", async () => { + mockSuccess({}); + await api.post("/workspaces", { name: "x" }); + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeDefined(); + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("PATCH passes an AbortSignal to fetch", async () => { + mockSuccess({}); + await api.patch("/workspaces/ws-1", { x: 0 }); + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("PUT passes an AbortSignal to fetch", async () => { + mockSuccess({}); + await api.put("/canvas/viewport", { x: 0, y: 0, zoom: 1 }); + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("DELETE passes an AbortSignal to fetch", async () => { + mockSuccess({}); + await api.del("/workspaces/ws-1"); + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("AbortError from timeout is propagated to the caller", async () => { + // Simulate the browser firing a TimeoutError when AbortSignal.timeout + // expires — the fetch promise rejects with a DOMException (name=TimeoutError). + const abortErr = + typeof DOMException === "function" + ? new DOMException("signal timed out", "TimeoutError") + : Object.assign(new Error("signal timed out"), { name: "TimeoutError" }); + mockFetch.mockRejectedValueOnce(abortErr); + + await expect(api.get("/slow")).rejects.toMatchObject({ name: "TimeoutError" }); + }); + + it("each request installs its own signal (not a shared module-level controller)", async () => { + mockSuccess({}); + mockSuccess({}); + await api.get("/a"); + await api.get("/b"); + const sigA = mockFetch.mock.calls[0][1].signal; + const sigB = mockFetch.mock.calls[1][1].signal; + // AbortSignal.timeout() returns a fresh signal per call — they must + // not be the same reference, otherwise one slow request could cancel + // a subsequent fast request. + expect(sigA).not.toBe(sigB); + }); +});