|
| 1 | +// Cloud-only (billing, browser): completing the Team free-trial checkout should |
| 2 | +// leave the billing page showing the new plan WITHOUT a manual reload. |
| 3 | +// |
| 4 | +// Repro of a reported bug: a user starts the free trial, completes Stripe |
| 5 | +// checkout, and is redirected back to the plans page, which STILL shows the |
| 6 | +// upgrade/"Start free trial" call to action. A manual reload then shows the |
| 7 | +// active trial. The cause is client-side: autumn-js fetches the customer once |
| 8 | +// on load (staleTime 60s, refetchOnWindowFocus off) and the redirect back from |
| 9 | +// Stripe lands before Autumn has processed Stripe's webhook, so that single |
| 10 | +// fetch sees the old plan and the page never refetches on its own. |
| 11 | +// |
| 12 | +// The emulator models this faithfully: completing the hosted checkout redirects |
| 13 | +// back immediately but does NOT activate the subscription; the activation lands |
| 14 | +// only when the webhook settles (autumn.settleCheckout). The reload control |
| 15 | +// proves the backend is consistent, isolating the failure to the stale client. |
| 16 | +import { expect } from "@effect/vitest"; |
| 17 | +import { Effect } from "effect"; |
| 18 | + |
| 19 | +import { scenario } from "../src/scenario"; |
| 20 | +import { Autumn, Billing, Browser, Target } from "../src/services"; |
| 21 | + |
| 22 | +scenario( |
| 23 | + "Billing · completing the trial checkout shows the new plan without a reload", |
| 24 | + { timeout: 120_000 }, |
| 25 | + Effect.gen(function* () { |
| 26 | + // Gates: billing enforced here AND the Autumn emulator is observable (so we |
| 27 | + // can land the checkout webhook). Yield before any work so a target missing |
| 28 | + // either capability skips cleanly rather than failing. |
| 29 | + yield* Billing; |
| 30 | + const autumn = yield* Autumn; |
| 31 | + const target = yield* Target; |
| 32 | + const browser = yield* Browser; |
| 33 | + |
| 34 | + const identity = yield* target.newIdentity(); |
| 35 | + |
| 36 | + yield* browser.session(identity, async ({ page, step }) => { |
| 37 | + const teamCard = page |
| 38 | + .getByText("Team", { exact: true }) |
| 39 | + .locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]"); |
| 40 | + const startTrial = teamCard.getByRole("button", { name: "Start free trial" }); |
| 41 | + |
| 42 | + await step("A fresh org is offered the Team free trial", async () => { |
| 43 | + // Billing requests are org-scoped via the URL slug header, so reach the |
| 44 | + // plans page through the org-scoped URL (a bare /billing/plans would fire |
| 45 | + // the first fetch before the slug resolves and 401). Land on "/" to |
| 46 | + // canonicalize, then open the slug-scoped plans page. |
| 47 | + await page.goto("/", { waitUntil: "networkidle" }); |
| 48 | + const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; |
| 49 | + await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" }); |
| 50 | + await page.getByRole("heading", { name: "Choose a plan" }).waitFor(); |
| 51 | + await startTrial.waitFor(); |
| 52 | + }); |
| 53 | + |
| 54 | + let sessionId = ""; |
| 55 | + await step("Start the trial and land on the hosted checkout", async () => { |
| 56 | + await startTrial.click(); |
| 57 | + // attach() redirects the whole page to the checkout URL. |
| 58 | + await page.waitForURL(/\/checkout\//, { timeout: 30_000 }); |
| 59 | + sessionId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? ""; |
| 60 | + expect(sessionId, "captured the checkout session id").toMatch(/^cs_/); |
| 61 | + await page.locator("button.checkout-pay-btn").waitFor(); |
| 62 | + }); |
| 63 | + |
| 64 | + await step("Complete checkout and return to the plans page", async () => { |
| 65 | + await page.locator("button.checkout-pay-btn").click(); |
| 66 | + // Checkout completes and redirects back to the success_url (the plans |
| 67 | + // page). The webhook has NOT landed yet, so the trial is still offered: |
| 68 | + // exactly the window the bug lives in. |
| 69 | + await page.waitForURL(/billing\/plans/, { timeout: 30_000 }); |
| 70 | + await startTrial.waitFor(); |
| 71 | + }); |
| 72 | + |
| 73 | + // The Stripe webhook reaches Autumn: the customer is now on the Team trial. |
| 74 | + // From here on the billing backend is authoritative-consistent. |
| 75 | + await Effect.runPromise(autumn.settleCheckout(sessionId)); |
| 76 | + |
| 77 | + // The page the user was returned to never reloads. If the UI refetched |
| 78 | + // after returning from checkout it would drop the trial CTA within a few |
| 79 | + // seconds; today it does not, so this stays visible. |
| 80 | + const reflectedWithoutReload = await startTrial |
| 81 | + .waitFor({ state: "hidden", timeout: 8_000 }) |
| 82 | + .then(() => true) |
| 83 | + .catch(() => false); |
| 84 | + |
| 85 | + // Control: a manual reload surfaces the active trial, proving the backend |
| 86 | + // was consistent all along and the only thing missing was a client refetch. |
| 87 | + await step("A manual reload shows the active trial", async () => { |
| 88 | + await page.reload({ waitUntil: "networkidle" }); |
| 89 | + await teamCard.getByText("Current plan").waitFor({ timeout: 15_000 }); |
| 90 | + }); |
| 91 | + |
| 92 | + expect( |
| 93 | + reflectedWithoutReload, |
| 94 | + "the plans page reflects the completed checkout without a manual reload", |
| 95 | + ).toBe(true); |
| 96 | + }); |
| 97 | + }), |
| 98 | +); |
0 commit comments