diff --git a/canvas/src/app/__tests__/orgs-page.test.tsx b/canvas/src/app/__tests__/orgs-page.test.tsx
index e68fb764..af08b4bb 100644
--- a/canvas/src/app/__tests__/orgs-page.test.tsx
+++ b/canvas/src/app/__tests__/orgs-page.test.tsx
@@ -249,3 +249,120 @@ describe("/orgs — fetch includes credentials + timeout signal", () => {
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();
+ }
+ });
+});