|
| 1 | +/* commerce-first-redirect.spec.ts — mocked-contract Playwright gate for the |
| 2 | + * COMMERCE-FIRST REDIRECT (2026-06-10, |
| 3 | + * memory project_commerce_first_redirect_at_interactions). |
| 4 | + * |
| 5 | + * The product rule: a successful login is a scarce interaction, so the |
| 6 | + * post-auth landing routes by plan tier to push commerce — |
| 7 | + * free → /pricing (drive the first purchase) |
| 8 | + * paid + upgrade-eligible → /app/billing (show the next tier) |
| 9 | + * top tier (team) → /app (no upsell — NEVER a Team checkout) |
| 10 | + * — UNLESS the user carried an explicit deep-link (a saved /app/* return_to |
| 11 | + * or a /login?next=), which always wins (and prevents pricing→login→pricing |
| 12 | + * loops). |
| 13 | + * |
| 14 | + * This drives the REAL SPA route (LoginCallbackPage) through the REAL src/api |
| 15 | + * client with the network mocked at the page.route() boundary, so it runs on |
| 16 | + * every web PR (mocked playwright.config.ts, VITE_NO_PROXY=1) and reds the PR |
| 17 | + * if the tier→destination wiring breaks. It complements: |
| 18 | + * - src/lib/postAuthDestination.test.ts (the pure decision matrix, vitest) |
| 19 | + * - src/pages/LoginCallbackPage.test.tsx (component, ../api stubbed) |
| 20 | + * by exercising the browser-rendered redirect against the real api client. |
| 21 | + */ |
| 22 | + |
| 23 | +import { expect, test, type Page, type Route } from '@playwright/test' |
| 24 | + |
| 25 | +const AUTH_ME_PATH = '**/auth/me' |
| 26 | +const SESSION_TOKEN = 'sess_jwt_commerce' |
| 27 | + |
| 28 | +// Catch-all for the dependent dashboard bootstrap fetches that fire once the |
| 29 | +// SPA lands on an /app/* route (counts + billing). We don't assert on them — |
| 30 | +// we only care WHERE the user was routed — so we stub them to harmless empties |
| 31 | +// so the destination page doesn't error mid-render. |
| 32 | +const RESOURCES_PATH = /\/api\/v1\/resources(\?[^/]*)?$/ |
| 33 | +const DEPLOYMENTS_PATH = /\/api\/v1\/deployments(\?[^/]*)?$/ |
| 34 | +const VAULT_PATH = /\/api\/v1\/vault(\?[^/]*)?$/ |
| 35 | +const BILLING_PATH = '**/api/v1/billing' |
| 36 | + |
| 37 | +/** Mock GET /auth/me to report the given plan tier. The wire shape is the FLAT |
| 38 | + * agent payload ({ ok, user_id, team_id, email, tier }); fetchMe() adapts it |
| 39 | + * into { user: { tier } } which postAuthDestination reads. */ |
| 40 | +async function mockAuthMe(page: Page, tier: string) { |
| 41 | + await page.route(AUTH_ME_PATH, (route: Route) => |
| 42 | + route.fulfill({ |
| 43 | + status: 200, |
| 44 | + contentType: 'application/json', |
| 45 | + body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: 'founder@acme.dev', tier }), |
| 46 | + }), |
| 47 | + ) |
| 48 | +} |
| 49 | + |
| 50 | +/** Stub the dashboard bootstrap fetches so an /app/* destination renders. */ |
| 51 | +async function mockDashboardBootstrap(page: Page) { |
| 52 | + await page.route(RESOURCES_PATH, (route: Route) => |
| 53 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }), |
| 54 | + ) |
| 55 | + await page.route(DEPLOYMENTS_PATH, (route: Route) => |
| 56 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }), |
| 57 | + ) |
| 58 | + await page.route(VAULT_PATH, (route: Route) => |
| 59 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, entries: [] }) }), |
| 60 | + ) |
| 61 | + await page.route(BILLING_PATH, (route: Route) => |
| 62 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, billing: { tier: 'free', subscription_status: 'none' } }) }), |
| 63 | + ) |
| 64 | +} |
| 65 | + |
| 66 | +test.describe('commerce-first redirect — post-auth landing by tier', () => { |
| 67 | + test('free tier lands on /pricing (drive the first purchase)', async ({ page }) => { |
| 68 | + await mockAuthMe(page, 'free') |
| 69 | + await mockDashboardBootstrap(page) |
| 70 | + await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`) |
| 71 | + await expect(page).toHaveURL(/\/pricing$/) |
| 72 | + }) |
| 73 | + |
| 74 | + test('paid+eligible tier (pro) lands on /app/billing (show the next tier)', async ({ page }) => { |
| 75 | + await mockAuthMe(page, 'pro') |
| 76 | + await mockDashboardBootstrap(page) |
| 77 | + await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`) |
| 78 | + await expect(page).toHaveURL(/\/app\/billing$/) |
| 79 | + }) |
| 80 | + |
| 81 | + test('top tier (team) lands on /app — never a Team checkout', async ({ page }) => { |
| 82 | + await mockAuthMe(page, 'team') |
| 83 | + await mockDashboardBootstrap(page) |
| 84 | + await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`) |
| 85 | + await expect(page).toHaveURL(/\/app\/?$/) |
| 86 | + // Hard guard: a team user must NOT be pushed to a commerce surface. |
| 87 | + await expect(page).not.toHaveURL(/\/pricing$/) |
| 88 | + await expect(page).not.toHaveURL(/\/app\/billing$/) |
| 89 | + }) |
| 90 | + |
| 91 | + test('an explicit /app deep-link (saved return_to) overrides the free-tier pricing push', async ({ page }) => { |
| 92 | + await mockAuthMe(page, 'free') |
| 93 | + await mockDashboardBootstrap(page) |
| 94 | + // Seed the 401-interceptor's saved destination before the callback runs. |
| 95 | + // We use /app/resources (a stable page that just lists the empty resource |
| 96 | + // set we mocked) so the test asserts the deep-link wins without depending |
| 97 | + // on a page that itself redirects (e.g. CheckoutPage auto-fires checkout). |
| 98 | + await page.addInitScript(() => { |
| 99 | + try { localStorage.setItem('instanode.return_to', '/app/resources') } catch {} |
| 100 | + }) |
| 101 | + await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`) |
| 102 | + // Deep-link wins — the user lands on the saved destination, NOT /pricing. |
| 103 | + await expect(page).toHaveURL(/\/app\/resources$/) |
| 104 | + await expect(page).not.toHaveURL(/\/pricing$/) |
| 105 | + }) |
| 106 | +}) |
0 commit comments