[object Object]

← back to NationalPaperHangers

tests: e2e claim flow — landing form → email-domain check → token mint → single-use verify → password set → /admin landing. 10/10 pass with auto-cleanup

eb62adf6edbe4a624de6bccdcbccd59a9dd65b8d · 2026-05-06 19:20:10 -0700 · Steve

Files touched

Diff

commit eb62adf6edbe4a624de6bccdcbccd59a9dd65b8d
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 6 19:20:10 2026 -0700

    tests: e2e claim flow — landing form → email-domain check → token mint → single-use verify → password set → /admin landing. 10/10 pass with auto-cleanup
---
 tests/e2e-claim-flow.js | 165 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 165 insertions(+)

diff --git a/tests/e2e-claim-flow.js b/tests/e2e-claim-flow.js
new file mode 100644
index 0000000..68b63f8
--- /dev/null
+++ b/tests/e2e-claim-flow.js
@@ -0,0 +1,165 @@
+// 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';
+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`);
+    await page.goto(`${BASE}/installer/${TARGET_SLUG}/claim`, { waitUntil: 'domcontentloaded' });
+    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);
+  }
+})();

← 0420926 tests: e2e for /find sort + density controls — 7/7, locks in  ·  back to NationalPaperHangers  ·  tests: e2e suite runner — npm run test:e2e runs all e2e-*.js 698afa0 →