← 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
A tests/e2e-claim-flow.js
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 →