← back to NationalPaperHangers

tests/e2e-claim-flow.js

179 lines

// e2e: claim flow for an unclaimed (directory-listing) studio.
//
//   1. GET  /installer/:slug/claim — page renders, accepts only domain-matched info@
//   2. POST /installer/:slug/claim — generates token, stores on row, "sent" page renders
//   3. Skip the actual email round-trip; pull the token from PG directly
//      (the token is single-use and 24h-bounded; e2e is a legitimate consumer)
//   4. GET  /installer/:slug/claim/verify?token=... — token consumed, session set
//   5. GET  /installer/:slug/claim/complete — final form
//   6. POST /installer/:slug/claim/complete — set password, lands on /admin
//   7. Cleanup: revert installer row to unclaimed state
//
// Run: node tests/e2e-claim-flow.js
// Env: BASE=http://localhost:9765 (default), TARGET_SLUG=<unclaimed slug>
//
// Pre-req: a studio with claim_status='unclaimed' AND a website domain.
// Default uses the demo seed 'demo-unclaimed-marquee-finishes'
// (website https://example.com/marquee → use info@example.com).

// Pull PG creds from the project's .env so we use the same connection NPH itself uses.
require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
const { chromium } = require('playwright-core');
const { Client } = require('pg');

const BASE = process.env.BASE || 'http://localhost:9765';
// Test reads/writes the local PG to verify token state + clean up. Skip when
// BASE is remote — we can't (and shouldn't) write to prod DB from here.
if (!/localhost|127\.0\.0\.1/.test(BASE)) {
  console.log(`[e2e-claim-flow] SKIP — BASE=${BASE} is remote; this test mutates DB and is local-only`);
  process.exit(78);
}
const TARGET_SLUG = process.env.TARGET_SLUG || 'demo-unclaimed-marquee-finishes';
const PG_CONFIG = {
  host: process.env.PGHOST || '/tmp',
  port: parseInt(process.env.PGPORT || '5432', 10),
  database: process.env.PGDATABASE || 'national_paper_hangers',
  user: process.env.PGUSER || require('os').userInfo().username,
  password: process.env.PGPASSWORD || undefined
};
const NEW_EMAIL = `e2e-claim-${Date.now()}@example.com`;
const NEW_PW = 'e2e-strong-pw-1234';

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;
}

(async () => {
  const exe = findChromium();
  if (!exe) { console.error('FAIL: no Chromium'); process.exit(2); }
  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;
  const assert = (c, m) => { if (c) { console.log(`  ✓ ${m}`); pass++; } else { console.log(`  ✗ ${m}`); fail++; } };

  const pg = new Client(PG_CONFIG);
  await pg.connect();

  // Snapshot original state for cleanup
  let originalRow = null;
  try {
    const r = await pg.query(
      `SELECT email, password_hash, claim_status, claimed_at FROM installers WHERE slug=$1`,
      [TARGET_SLUG]
    );
    if (r.rows.length === 0) {
      console.error(`FAIL: target slug "${TARGET_SLUG}" not found in DB`);
      await browser.close(); await pg.end();
      process.exit(2);
    }
    originalRow = r.rows[0];
    if (originalRow.claim_status === 'claimed') {
      console.error(`FAIL: target slug already claimed — pick a different one`);
      await browser.close(); await pg.end();
      process.exit(2);
    }
  } catch (e) {
    console.error('PG snapshot FAIL:', e.message);
    await browser.close(); await pg.end();
    process.exit(2);
  }

  try {
    // ─── STEP 1 · GET /claim ──────────────────────────────────
    console.log(`\n[step 1] GET /installer/${TARGET_SLUG}/claim`);
    const r1 = await page.goto(`${BASE}/installer/${TARGET_SLUG}/claim`, { waitUntil: 'domcontentloaded' });
    // The claim route is rate-limited (5/hr/IP). Back-to-back e2e runs trip it;
    // exit with code 78 (skipped) rather than fail the suite.
    if (r1 && r1.status() === 429) {
      console.log('  ⊘ SKIP — claim rate-limit (429) tripped from prior runs; wait ~60min and retry');
      await pg.end(); await browser.close();
      process.exit(78);
    }
    const emailInput = await page.$('input[type="email"], input[name="email"]');
    assert(!!emailInput, 'claim form has email input');

    // ─── STEP 2 · POST /claim with valid info@ on the website domain ─────
    console.log('\n[step 2] POST /claim');
    // Pull the website domain from DB to compose info@ correctly
    const { rows: [{ website }] } = await pg.query(
      `SELECT website FROM installers WHERE slug=$1`, [TARGET_SLUG]
    );
    const domain = new URL(website).hostname.replace(/^www\./, '');
    const claimEmail = `info@${domain}`;
    console.log(`  composing claim email: ${claimEmail}`);

    await page.fill('input[name="email"]', claimEmail);
    await page.click('form[action*="/claim"] button[type="submit"]');
    await page.waitForLoadState('domcontentloaded');
    const sentMarker = await page.content();
    assert(/check.*email|sent|verify/i.test(sentMarker), 'POST /claim → "verification email sent" page');

    // ─── STEP 3 · pull token from DB ────────────────────────────
    console.log('\n[step 3] read claim token from DB');
    const { rows: [{ claim_token: token }] } = await pg.query(
      `SELECT claim_token FROM installers WHERE slug=$1`, [TARGET_SLUG]
    );
    assert(typeof token === 'string' && token.length > 30, `token written (${token ? token.length : 0} chars)`);

    // ─── STEP 4 · GET /claim/verify?token=... ────────────────────
    console.log('\n[step 4] GET /claim/verify');
    await page.goto(`${BASE}/installer/${TARGET_SLUG}/claim/verify?token=${token}`, { waitUntil: 'domcontentloaded' });
    assert(/\/claim\/complete$/.test(page.url()), `redirected to /claim/complete (got ${page.url()})`);

    // Token should be cleared after single use
    const { rows: [{ claim_token: tokenAfter }] } = await pg.query(
      `SELECT claim_token FROM installers WHERE slug=$1`, [TARGET_SLUG]
    );
    assert(tokenAfter === null, 'token cleared from DB (single-use)');

    // ─── STEP 5 + 6 · set password ──────────────────────────────
    console.log('\n[step 5] /claim/complete form');
    const pwInput = await page.$('input[type="password"]');
    assert(!!pwInput, 'password input exists');

    console.log('\n[step 6] POST /claim/complete');
    await page.fill('input[name="email"]', NEW_EMAIL);
    await page.fill('input[name="password"]', NEW_PW);
    await Promise.all([
      page.waitForURL(/\/admin/, { timeout: 8000 }).catch(() => {}),
      page.click('form button[type="submit"]')
    ]);
    assert(page.url().includes('/admin'), `landed on /admin (got ${page.url()})`);

    // DB should now show claimed
    const { rows: [r6] } = await pg.query(
      `SELECT email, claim_status, claimed_at FROM installers WHERE slug=$1`, [TARGET_SLUG]
    );
    assert(r6.claim_status === 'claimed', `claim_status='claimed' (got "${r6.claim_status}")`);
    assert(r6.email === NEW_EMAIL.toLowerCase(), `email updated to "${NEW_EMAIL}"`);
    assert(!!r6.claimed_at, 'claimed_at timestamp set');

  } catch (err) {
    console.error('\nFATAL:', err.message);
    fail++;
  } finally {
    // ─── CLEANUP · revert row to original state ──────────────────
    console.log('\n[cleanup] revert installer row');
    try {
      await pg.query(
        `UPDATE installers SET email=$1, password_hash=$2, claim_status=$3, claimed_at=$4, claim_token=NULL WHERE slug=$5`,
        [originalRow.email, originalRow.password_hash, originalRow.claim_status, originalRow.claimed_at, TARGET_SLUG]
      );
      console.log('  ✓ DB row reverted');
    } catch (e) {
      console.error('  ✗ cleanup failed:', e.message);
    }
    await pg.end();
    await browser.close();
    console.log(`\n[result] ${pass} pass · ${fail} fail`);
    process.exit(fail ? 1 : 0);
  }
})();