test(canvas): pin AbortSignal timeout regression + cover /orgs landing page

Two independent test additions that harden the surface freshly landed on
staging via PRs #982 (canvas fetch timeout), #992 (/orgs landing), #994
(post-checkout redirect to /orgs).

canvas/src/lib/__tests__/api.test.ts (+74 lines, 7 new tests)
  - GET/POST/PATCH/PUT/DELETE each pass an AbortSignal to fetch
  - TimeoutError (DOMException name=TimeoutError) propagates to the caller
  - Each request installs its own signal — no shared module-level controller
    that would allow one slow request to cancel an unrelated fast one
  This is the hardening nit I flagged in my APPROVE-w/-nit review of
  fix/canvas-api-fetch-timeout. Landing as a follow-up now that #982 is in
  staging.

canvas/src/app/__tests__/orgs-page.test.tsx (+251 lines, new file, 10 tests)
  - Auth guard: signed-out → redirectToLogin and no /cp/orgs fetch
  - Error state: failed /cp/orgs → Error message + Retry button
  - Empty list: CreateOrgForm renders
  - CTA by status:
      running          → "Open" link targets {slug}.moleculesai.app
      awaiting_payment → "Complete payment" → /pricing?org=<slug>
      failed           → "Contact support" mailto
  - Post-checkout: ?checkout=success renders CheckoutBanner AND
    history.replaceState scrubs the query param
  - Fetch contract: /cp/orgs called with credentials:include + AbortSignal

Local baseline on origin/staging tip ede6597:
  canvas vitest: 50 files / 778 tests, all green
  canvas build:  clean, /orgs route present (2.83 kB / 105 kB first-load)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
qa-agent 2026-04-19 19:14:54 +00:00
parent ede6597cc0
commit e70cd1c2aa
2 changed files with 322 additions and 0 deletions

View File

@ -0,0 +1,251 @@
// @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=<slug>
* - `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, fireEvent } 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(<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()
);
});
});
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();
});
});
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();
});
});
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/");
});
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");
});
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");
});
});
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");
});
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();
});
});
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);
});
});

View File

@ -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);
});
});