← 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);
}
})();