← back to NationalPaperHangers

tests/e2e-coi-request.js

179 lines

// e2e click-test: Live COI request flow (UX backlog #5).
//
// Drives the end-to-end designer flow + admin inbox:
//   1. Public /installer/:slug — "Request COI →" button visible (gated on
//      insurance_on_file=true).
//   2. Reveal form, fill required fields, submit → /coi-request-result page
//      renders ok.
//   3. DB: row exists in coi_requests for the installer, status='pending'.
//   4. Login as the installer → /admin/coi-requests shows the request.
//   5. Mark fulfilled → DB row status='fulfilled', fulfilled_at set.
//   6. Cleanup — delete the test request row.
//
// Run: node tests/e2e-coi-request.js
// Env: BASE=http://localhost:9765 (default)
//      SLUG=atelier-bond-nyc (default — must be a claimed installer with
//                              insurance_on_file=true)
//      EMAIL=demo+bond@example.com (matching SLUG)
//      PW=demo1234

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

const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = process.env.SLUG || 'atelier-bond-nyc';
const EMAIL = process.env.EMAIL || 'demo+bond@example.com';
const PW = process.env.PW || 'demo1234';
const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);

if (!IS_LOCAL) {
  console.log(`[e2e-coi-request] SKIP — BASE=${BASE} is remote; this test writes to DB and verifies via SQL`);
  process.exit(78);
}

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

let _db = null;
function db() {
  if (_db) return _db;
  _db = require('../lib/db');
  return _db;
}

const DESIGNER_NAME = `E2E Test Designer ${Date.now()}`;
const DESIGNER_EMAIL = `e2e-test-${Date.now()}@example.com`;
const ADDITIONAL_INSURED = 'ACME Design LLC, its officers and directors';

(async () => {
  const exe = findChromium();
  if (!exe) { console.error('FAIL: no Chromium binary'); process.exit(2); }
  console.log(`[e2e-coi] using browser: ${exe}`);

  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 = (cond, msg) => { if (cond) { console.log(`  ✓ ${msg}`); pass++; } else { console.log(`  ✗ ${msg}`); fail++; } };

  let createdRequestId = null;

  try {
    // ─── Step 1 — Public profile renders the COI section ────────────────
    console.log(`\n[step 1] public /installer/${SLUG} renders COI section`);
    await page.goto(`${BASE}/installer/${SLUG}`, { waitUntil: 'domcontentloaded' });
    const coiSection = await page.$('#coi-section');
    assert(!!coiSection, 'COI section #coi-section present');
    const btn = await page.$('[data-coi-toggle]');
    assert(!!btn, '"Request COI →" button present');

    // ─── Step 2 — Reveal form, fill, submit ─────────────────────────────
    console.log(`\n[step 2] reveal form + submit request`);
    await page.click('[data-coi-toggle]');
    await page.waitForSelector('input[name="designer_name"]', { timeout: 3000 });
    await page.fill('input[name="designer_name"]', DESIGNER_NAME);
    await page.fill('input[name="designer_email"]', DESIGNER_EMAIL);
    await page.fill('input[name="designer_company"]', 'E2E Test Firm');
    await page.fill('input[name="project_name"]', 'E2E Test Suite');
    await page.fill('input[name="project_address"]', '123 Test Ave, Beverly Hills CA 90210');
    await page.fill('input[name="additional_insured_name"]', ADDITIONAL_INSURED);
    await page.fill('input[name="additional_insured_address"]', '456 Design Blvd, LA CA 90069');

    await Promise.all([
      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
      page.click('#coi-request-form button[type="submit"]')
    ]);
    const url = page.url();
    assert(/\/installer\/.*\/coi-request/.test(url) || /coi-request-result/.test(url) || url.includes(`/installer/${SLUG}/coi-request`),
      `landed on COI result page (got ${url})`);

    const body = await page.content();
    assert(/COI request sent|in flight|received/i.test(body), 'result page shows confirmation copy');

    // ─── Step 3 — DB row landed ─────────────────────────────────────────
    console.log(`\n[step 3] DB row created`);
    const rows = await db().many(
      `SELECT cr.id, cr.status, cr.designer_name, cr.designer_email, cr.additional_insured_name
         FROM coi_requests cr
         JOIN installers i ON i.id = cr.installer_id
        WHERE i.slug = $1 AND cr.designer_email = $2
        ORDER BY cr.created_at DESC LIMIT 1`,
      [SLUG, DESIGNER_EMAIL]
    );
    assert(rows.length === 1, `exactly 1 coi_requests row for this designer (got ${rows.length})`);
    if (rows.length) {
      createdRequestId = rows[0].id;
      assert(rows[0].status === 'pending', `status=pending (got "${rows[0].status}")`);
      assert(rows[0].designer_name === DESIGNER_NAME, `designer_name matches`);
      assert(rows[0].additional_insured_name === ADDITIONAL_INSURED, `additional_insured_name matches`);
    }

    // ─── Step 4 — Login as installer and verify /admin/coi-requests ─────
    console.log(`\n[step 4] login as ${EMAIL} → /admin/coi-requests shows request`);
    const loginResp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
    if (loginResp && loginResp.status() === 429) {
      console.log('  ⊘ login rate-limit (429) — skipping admin verification phases');
    } else {
      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"]')
      ]);
      assert(page.url().includes('/admin'), 'logged in');

      await page.goto(`${BASE}/admin/coi-requests`, { waitUntil: 'domcontentloaded' });
      const inboxHtml = await page.content();
      assert(inboxHtml.includes(DESIGNER_NAME), 'admin inbox shows new request');
      assert(/pending/i.test(inboxHtml), 'request shows as pending');

      // ─── Step 5 — Mark fulfilled ──────────────────────────────────────
      console.log(`\n[step 5] mark request fulfilled`);
      if (createdRequestId) {
        // Find the form for this specific request and submit "Mark fulfilled"
        const formSel = `form[action="/admin/coi-requests/${createdRequestId}/status"] button[value="fulfilled"]`;
        const fulfillBtn = await page.$(formSel);
        assert(!!fulfillBtn, '"Mark fulfilled" button present');
        if (fulfillBtn) {
          await Promise.all([
            page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 }),
            page.click(formSel)
          ]);
          const after = await db().one(
            `SELECT status, fulfilled_at FROM coi_requests WHERE id=$1`,
            [createdRequestId]
          );
          assert(after && after.status === 'fulfilled', `DB status flipped to fulfilled (got "${after && after.status}")`);
          assert(after && after.fulfilled_at, 'fulfilled_at timestamp set');
        }
      }
    }

  } catch (err) {
    console.error(`\nFAIL — uncaught: ${err.message}`);
    fail++;
  } finally {
    // ─── Cleanup ─────────────────────────────────────────────────────────
    if (createdRequestId) {
      try {
        await db().query(`DELETE FROM coi_requests WHERE id = $1`, [createdRequestId]);
        console.log(`\n[cleanup] deleted coi_requests id=${createdRequestId}`);
      } catch (e) {
        console.error(`[cleanup] failed: ${e.message}`);
      }
    }
    await browser.close();
  }

  console.log(`\n[result] ${pass} pass · ${fail} fail`);
  process.exit(fail > 0 ? 1 : 0);
})();