← back to NationalPaperHangers

tests/e2e-stripe-flow.js

326 lines

// e2e click-test: full Stripe Connect + booking-deposit flow.
//
// Verifies the wiring that already exists (lib/stripe.js + routes/admin.js
// connect/billing routes + routes/webhooks.js + routes/api.js
// createBookingDepositIntent integration) without sending money to Stripe.
//
// Phases:
//
//   1. /admin/billing renders for a paid-tier installer
//      · price grid, Connect callout, and per-installer marketplace stat-grid
//        all present.
//   2. /admin/connect/onboard POST returns either a Stripe redirect URL (live
//      mode) or a mock-redirect to /admin?mock=connect (no STRIPE_SECRET_KEY).
//      DB row gets stripe_account_id populated. Cleaned up after.
//   3. POST /admin/billing/checkout returns a Stripe Checkout URL or, in mock
//      mode, flips installers.tier locally and redirects to /admin/billing?ok=1.
//      DB row tier flips signature → pro. Cleaned up after.
//   4. Booking flow — SKIP (depends on consumer signed-in session; covered
//      by e2e-buyer-wizard at the wizard layer).
//   5. Webhook fan-in: POST a forged checkout.session.completed event to
//      /webhooks/stripe with STRIPE_DEV_ACCEPT_UNSIGNED=1 and assert the
//      installer row reflects subscription_status='active'.
//   6. Idempotency: POST the same forged event a second time and assert
//      no double-update happens (subscription_events.stripe_event_id UNIQUE
//      constraint should prevent it).
//
// Run: node tests/e2e-stripe-flow.js
// Env: BASE=http://localhost:9765 (default)
//      SLUG=<paid-tier studio slug> (default rivera-installs-miami)
//      EMAIL=<installer email> (default demo+rivera@example.com)
//      PW=<installer pw>       (default demo1234)
//      STRIPE_DEV_ACCEPT_UNSIGNED=1 to enable phases 5+6 in mock mode
//      STRIPE_SECRET_KEY=sk_test_... to run live-mode assertions
//
// Phases 1-3 require local DB access for cleanup — skipped cleanly when BASE
// is remote.
//
// Exit codes: 0 = all phases passed, 1 = at least one assertion failed,
//             2 = setup failed (no browser, target not reachable),
//             78 = phase skipped due to missing env (treated as SKIP by run-e2e.js)

const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });

const http = require('http');
const https = require('https');
const { chromium } = require('playwright-core');

const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = process.env.SLUG || 'rivera-installs-miami';
const EMAIL = process.env.EMAIL || 'demo+rivera@example.com';
const PW = process.env.PW || 'demo1234';
const STRIPE_LIVE = !!process.env.STRIPE_SECRET_KEY;
const ACCEPT_UNSIGNED = process.env.STRIPE_DEV_ACCEPT_UNSIGNED === '1';
const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
// Safety gate — phases 2/3/5/6 either write to Stripe (creating real Connect
// accounts + Checkout sessions in Steve's live Stripe account) or require the
// SERVER to accept unsigned webhook payloads. Both are unsafe against a
// production-keyed pm2 process. Default to Phase-1-only; opt in with
// STRIPE_E2E_FULL=1 (requires a test/mock-mode server, not live keys).
const FULL = process.env.STRIPE_E2E_FULL === '1';
const STRIPE_KEY_LIVE = /^sk_live_/.test(process.env.STRIPE_SECRET_KEY || '');

function findChromium() {
  const paths = [
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Chromium.app/Contents/MacOS/Chromium',
    process.env.CHROME_PATH,
  ].filter(Boolean);
  for (const p of paths) { try { require('fs').accessSync(p); return p; } catch {} }
  return null;
}

// DB helper — only loaded when IS_LOCAL so remote runs don't try to connect.
let _db = null;
function db() {
  if (_db) return _db;
  _db = require('../lib/db');
  return _db;
}

async function loginAsInstaller(page) {
  const resp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
  if (resp && resp.status() === 429) return { rateLimited: true };
  await page.fill('input[name="email"]', EMAIL);
  await page.fill('input[name="password"]', PW);
  await Promise.all([
    page.waitForURL(/\/admin/, { timeout: 8000 }),
    page.click('form[action="/login"] button[type="submit"]'),
  ]);
  return { rateLimited: false };
}

function postJson(url, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url);
    const lib = u.protocol === 'https:' ? https : http;
    const data = typeof body === 'string' ? body : JSON.stringify(body);
    const req = lib.request({
      method: 'POST',
      hostname: u.hostname, port: u.port, path: u.pathname + u.search,
      headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data), ...headers }
    }, res => {
      let buf = '';
      res.on('data', c => buf += c);
      res.on('end', () => resolve({ status: res.statusCode, body: buf, headers: res.headers }));
    });
    req.on('error', reject);
    req.write(data);
    req.end();
  });
}

(async () => {
  const exe = findChromium();
  if (!exe) { console.error('FAIL: no Chromium binary'); process.exit(2); }
  console.log(`[e2e-stripe] using browser: ${exe}`);
  console.log(`[e2e-stripe] mode: ${STRIPE_LIVE ? 'LIVE' : 'MOCK'} (STRIPE_SECRET_KEY ${STRIPE_LIVE ? 'set' : 'unset'})`);

  const browser = await chromium.launch({ executablePath: exe, headless: true });
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  const page = await ctx.newPage();
  let pass = 0, fail = 0, skip = 0;
  const assert = (cond, msg) => { if (cond) { console.log(`  ✓ ${msg}`); pass++; } else { console.log(`  ✗ ${msg}`); fail++; } };
  const skipPhase = (msg) => { console.log(`  ⤷ SKIP — ${msg}`); skip++; };

  try {
    // ─── Phase 1 — login + /admin/billing renders for paid-tier installer ─
    console.log(`\n[phase 1] login as ${EMAIL} → /admin/billing renders for ${SLUG}`);
    const { rateLimited } = await loginAsInstaller(page);
    if (rateLimited) {
      console.log('  ⊘ /login returned 429 — skipping all installer-session phases');
      skipPhase('login rate-limit (429) tripped from prior runs; wait ~15min');
      // jump straight to phase 5 by closing browser early
    } else {
      assert(/\/admin/.test(page.url()), `landed on /admin after login (got ${page.url()})`);

      await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });
      const t = await page.title();
      assert(/Billing/i.test(t), `page title contains "Billing" (got "${t}")`);

      const priceCards = await page.$$eval('.price-card', els => els.length);
      assert(priceCards >= 1, `at least 1 price card (got ${priceCards})`);

      const connectCallout = await page.$('.callout-warn, .callout-ok');
      assert(!!connectCallout, 'Connect onboarding callout present (warn or ok)');

      const statGrid = await page.$('.stat-grid');
      if (statGrid) {
        const stats = await page.$$eval('.stat-grid .stat-label', els => els.map(e => e.textContent.trim()));
        assert(stats.some(s => /Deposits/i.test(s)), 'installer marketplace shows "Deposits" stat');
        assert(stats.some(s => /Platform fee|fee/i.test(s)), 'installer marketplace shows fee stat');
      } else {
        console.log('  ⤷ no installer-market stat-grid (installer has no paid bookings yet)');
      }

      // Helper — programmatic POST from inside the page so cookies + same-origin
      // headers ride along but we never follow Stripe's redirect (which would
      // load connect.stripe.com / checkout.stripe.com over the network and
      // flake/timeout the test). manual-redirect lets us assert on the 302
      // Location header directly.
      async function postFormWithCsrf(action, extraFields = {}) {
        return await page.evaluate(async ({ action, extraFields }) => {
          // Pull csrf token from any form on the current page that has it
          const tokenInput = document.querySelector('input[name="_csrf"]');
          const token = tokenInput ? tokenInput.value : '';
          const body = new URLSearchParams();
          body.set('_csrf', token);
          for (const [k, v] of Object.entries(extraFields)) body.set(k, v);
          const r = await fetch(action, {
            method: 'POST',
            redirect: 'manual',
            headers: { 'content-type': 'application/x-www-form-urlencoded' },
            body: body.toString()
          });
          // With redirect: 'manual', cross-origin 302 surfaces type='opaqueredirect'
          // and same-origin redirect surfaces as 0 + redirected=true. Either way,
          // the Location header is not exposed to JS — so we capture status only
          // and rely on the DB write as the assertion surface for the side-effect.
          return { status: r.status, type: r.type, redirected: r.redirected };
        }, { action, extraFields });
      }

      // ─── Phase 2 — /admin/connect/onboard returns redirect ──────────────
      console.log(`\n[phase 2] POST /admin/connect/onboard → redirect`);
      if (!IS_LOCAL) {
        skipPhase('phase 2 requires local DB cleanup — BASE is remote');
      } else if (STRIPE_KEY_LIVE && !FULL) {
        skipPhase('STRIPE_SECRET_KEY is sk_live_ — would create REAL Connect accounts. Set STRIPE_E2E_FULL=1 only against a sk_test_ server');
      } else {
        await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });

        const before = await db().query(
          `SELECT id, stripe_account_id FROM installers WHERE slug=$1`, [SLUG]
        );
        const installerId = before.rows[0].id;
        const priorAcct = before.rows[0].stripe_account_id;

        const r = await postFormWithCsrf('/admin/connect/onboard');
        assert(r.type === 'opaqueredirect' || r.status === 302 || r.status === 0,
          `connect/onboard responded with redirect (status=${r.status} type=${r.type})`);

        const after = await db().query(
          `SELECT stripe_account_id FROM installers WHERE id=$1`, [installerId]
        );
        const newAcct = after.rows[0].stripe_account_id;
        assert(!!newAcct, `stripe_account_id populated after onboard (got "${newAcct}")`);
        if (!STRIPE_LIVE) {
          assert(/^acct_mock_/.test(newAcct), `mock account_id has acct_mock_ prefix (got "${newAcct}")`);
        } else {
          assert(/^acct_/.test(newAcct), `live Stripe account_id has acct_ prefix (got "${newAcct}")`);
        }

        // Cleanup — restore prior account_id (likely NULL) so the test is rerunnable
        await db().query(
          `UPDATE installers SET stripe_account_id=$2,
                  stripe_account_charges_enabled=false,
                  stripe_account_payouts_enabled=false
            WHERE id=$1`,
          [installerId, priorAcct]
        );
      }

      // ─── Phase 3 — /admin/billing/checkout returns Checkout URL ─────────
      console.log(`\n[phase 3] POST /admin/billing/checkout → checkout URL`);
      if (!IS_LOCAL) {
        skipPhase('phase 3 requires local DB cleanup — BASE is remote');
      } else if (STRIPE_KEY_LIVE && !FULL) {
        skipPhase('STRIPE_SECRET_KEY is sk_live_ — would create REAL Checkout sessions. Set STRIPE_E2E_FULL=1 only against a sk_test_ server');
      } else {
        const before = await db().query(
          `SELECT id, tier FROM installers WHERE slug=$1`, [SLUG]
        );
        const installerId = before.rows[0].id;
        const priorTier = before.rows[0].tier;
        // Pick a different tier so the mock-mode flip is observable.
        // Rivera seeds at 'pro' → flip to 'signature'.
        const targetTier = priorTier === 'signature' ? 'pro' : 'signature';

        // Need a fresh /admin/billing page so the csrf token is current
        await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });

        const r = await postFormWithCsrf('/admin/billing/checkout', {
          tier: targetTier,
          cadence: 'monthly'
        });
        assert(r.type === 'opaqueredirect' || r.status === 302 || r.status === 0,
          `billing/checkout responded with redirect (status=${r.status} type=${r.type})`);

        if (!STRIPE_LIVE) {
          // mock mode flips the tier locally
          const after = await db().query(
            `SELECT tier, subscription_status FROM installers WHERE id=$1`, [installerId]
          );
          assert(after.rows[0].tier === targetTier,
            `tier flipped to ${targetTier} (got "${after.rows[0].tier}")`);
          assert(after.rows[0].subscription_status === 'active',
            `subscription_status='active' (got "${after.rows[0].subscription_status}")`);
        } else {
          // live mode — checkout session created, but tier not flipped until webhook
          console.log('  ⤷ LIVE mode: tier flip waits for checkout.session.completed webhook');
        }

        // Cleanup — restore prior tier
        await db().query(
          `UPDATE installers SET tier=$2 WHERE id=$1`,
          [installerId, priorTier]
        );
      }
    }

    // ─── Phase 4 — Booking flow: createBookingDepositIntent fires ────────
    console.log(`\n[phase 4] POST /api/installers/${SLUG}/book → deposit intent`);
    // Skipped: requires consumer signed-in session + 5-step wizard payload.
    // Existing e2e-buyer-wizard.js covers the wizard UI; this would extend it
    // to assert deposit.client_secret in the response when wired live.
    skipPhase('depends on consumer-session fixture; e2e-buyer-wizard already covers wizard UI');

    // ─── Phase 5 — Webhook fan-in: forged checkout.session.completed ──────
    console.log(`\n[phase 5] POST /webhooks/stripe (forged event)`);
    if (!ACCEPT_UNSIGNED) {
      skipPhase('STRIPE_DEV_ACCEPT_UNSIGNED!=1 in test env — webhook fail-closed in non-dev (correct)');
    } else {
      // Probe — if SERVER process doesn't have STRIPE_DEV_ACCEPT_UNSIGNED set,
      // this will 400 with bad signature. Skip cleanly instead of failing.
      const probe = await postJson(`${BASE}/webhooks/stripe`, { id: 'evt_probe', type: 'ping' });
      if (probe.status === 400) {
        skipPhase('SERVER process lacks STRIPE_DEV_ACCEPT_UNSIGNED=1 (signature enforced) — phases 5+6 require dev-mode server');
      } else {
        const eventId = `evt_test_${Date.now()}`;
        const event = {
          id: eventId,
          type: 'checkout.session.completed',
          data: { object: {
            customer: 'cus_test_e2e',
            subscription: 'sub_test_e2e',
            metadata: { installer_id: '0', tier: 'pro' }  // installer_id=0 → no real row touched
          } }
        };
        const r = await postJson(`${BASE}/webhooks/stripe`, event);
        assert(r.status === 200, `webhook returns 200 (got ${r.status})`);
        const j = JSON.parse(r.body || '{}');
        assert(j.received === true, 'webhook responds {received:true}');

        // ─── Phase 6 — Idempotency: replay same event ─────────────────────
        console.log(`\n[phase 6] POST same event again → idempotent short-circuit`);
        const r2 = await postJson(`${BASE}/webhooks/stripe`, event);
        assert(r2.status === 200, `replay returns 200 (got ${r2.status})`);
        const j2 = JSON.parse(r2.body || '{}');
        assert(j2.idempotent === true, 'replay responds {idempotent:true}');
      }
    }

  } catch (err) {
    console.error(`\nFAIL — uncaught: ${err.message}`);
    fail++;
  } finally {
    await browser.close();
  }

  console.log(`\n[e2e-stripe] ${pass} passed · ${fail} failed · ${skip} skipped`);
  // Format the runner parses: "[result] N pass · N fail"
  console.log(`[result] ${pass} pass · ${fail} fail`);
  process.exit(fail > 0 ? 1 : (pass === 0 && skip > 0 ? 78 : 0));
})();