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:
parent
ede6597cc0
commit
e70cd1c2aa
251
canvas/src/app/__tests__/orgs-page.test.tsx
Normal file
251
canvas/src/app/__tests__/orgs-page.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user