← back to NationalPaperHangers

tests/e2e-room-capture.js

227 lines

// e2e: room capture & measure quote flow (UX #6).
//
//   Phase 1 — capture UI: upload a wall photo, the measure stage appears,
//             wall width is derived from the box aspect, a taller ceiling
//             widens it, the wallpaper preview tiles at scale, and the whole
//             thing serializes into the hidden room_captures field.
//   Phase 2 — the public upload endpoint rejects non-image/video files.
//   Phase 3 — a sanitised room_captures array round-trips through the
//             bookings JSONB column, and an off-site photo URL is dropped.
//
// Skips cleanly (exit 78) against a remote BASE, without Chromium, or
// without the fixture image.

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

const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = process.env.SLUG || 'rivera-installs-miami';  // Pro tier in seed
const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
const FIXTURE = path.resolve(__dirname, 'fixtures', 'test-wall.png');

if (!IS_LOCAL) {
  console.log(`[e2e-room-capture] SKIP — BASE=${BASE} is remote`);
  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 { fs.accessSync(p); return p; } catch (e) {} }
  return null;
}

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

(async () => {
  const exe = findChromium();
  if (!exe) { console.log('[e2e-room-capture] SKIP — no Chromium binary'); process.exit(78); }
  if (!fs.existsSync(FIXTURE)) { console.log('[e2e-room-capture] SKIP — fixture missing'); process.exit(78); }
  console.log(`[e2e-room-capture] using browser: ${exe}`);

  const browser = await chromium.launch({ executablePath: exe, headless: true });
  const page = await (await browser.newContext({ viewport: { width: 1280, height: 1100 } })).newPage();
  let pass = 0, fail = 0;
  const ok = (c, m) => { c ? (pass++, console.log('  ✓ ' + m)) : (fail++, console.log('  ✗ ' + m)); };
  const jsErrors = [];
  page.on('pageerror', e => jsErrors.push(e.message));

  try {
    // ── Phase 1 — capture & measure UI ──────────────────────────────
    console.log('\n[phase 1] capture & measure UI');
    await page.goto(`${BASE}/installer/${SLUG}/book`, { waitUntil: 'domcontentloaded' });
    ok(await page.locator('[data-room-capture]').count() === 1, 'room-capture section present');
    ok(await page.locator('.rc-room').count() === 1, 'one room card auto-opened');

    await page.locator('.rc-photo-input').setInputFiles(FIXTURE);
    await page.waitForSelector('.rc-stage:not([hidden])', { timeout: 8000 });
    ok(true, 'photo uploads → measure stage shown');

    await page.waitForFunction(
      () => { const w = document.querySelector('.rc-width'); return w && parseFloat(w.value) > 0; },
      { timeout: 5000 }
    );
    const w1 = parseFloat(await page.locator('.rc-width').inputValue());
    ok(w1 > 0, `wall width auto-derived (${w1} ft)`);

    await page.locator('.rc-height').fill('14');
    await page.locator('.rc-height').dispatchEvent('input');
    await page.waitForTimeout(150);
    const w2 = parseFloat(await page.locator('.rc-width').inputValue());
    ok(w2 > w1, `taller ceiling widens derived width (${w1} → ${w2})`);

    // Drag the bottom-right box corner inward — the wall re-measures.
    await page.locator('.rc-photo-wrap').scrollIntoViewIfNeeded();
    await page.waitForTimeout(120);
    const wPreDrag = parseFloat(await page.locator('.rc-width').inputValue());
    const hb = await page.locator('.rc-handle[data-corner="br"]').boundingBox();
    const wb = await page.locator('.rc-photo-wrap').boundingBox();
    await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
    await page.mouse.down();
    await page.mouse.move(wb.x + wb.width * 0.45, wb.y + wb.height * 0.5, { steps: 8 });
    await page.mouse.up();
    await page.waitForTimeout(200);
    const wPostDrag = parseFloat(await page.locator('.rc-width').inputValue());
    ok(wPostDrag !== wPreDrag && wPostDrag > 0,
       `dragging a box corner re-measures the wall (${wPreDrag} → ${wPostDrag})`);

    await page.locator('.rc-room-label').fill('Dining room');

    await page.locator('.rc-wallpaper summary').click();
    await page.locator('.rc-wp-input').setInputFiles(FIXTURE);
    await page.waitForTimeout(600);
    await page.locator('.rc-wp-width').fill('27');
    await page.locator('.rc-wp-width').dispatchEvent('input');
    await page.waitForTimeout(150);
    ok(await page.locator('.rc-wp-note.is-error').count() === 1, 'wallpaper missing its repeat flags an error');

    await page.locator('.rc-wp-repeat').fill('18');
    await page.locator('.rc-wp-repeat').dispatchEvent('input');
    await page.waitForTimeout(200);
    const bg = await page.locator('.rc-wallpaper-layer').evaluate(el => el.style.backgroundImage);
    ok(/url/.test(bg), 'wallpaper tiles onto the wall at scale');

    await page.locator('.rc-wp-match').selectOption('half_drop');
    await page.waitForTimeout(400);
    const bgHalf = await page.locator('.rc-wallpaper-layer').evaluate(el => el.style.backgroundImage);
    ok(/^url\("data:image/.test(bgHalf), 'half-drop match composites a super-tile');
    await page.locator('.rc-wp-match').selectOption('straight');
    await page.waitForTimeout(150);

    const captures = JSON.parse(await page.locator('[data-rc-field]').inputValue());
    ok(captures.length === 1 && captures[0].room === 'Dining room'
       && captures[0].photo_url && captures[0].wall_width_ft > 0,
       'room_captures serialized into the hidden field');
    ok(captures[0].wallpaper && captures[0].wallpaper.width_in === 27
       && captures[0].wallpaper.repeat_in === 18,
       'wallpaper width + repeat serialized');

    // A square-feet figure the customer types by hand must survive a later
    // room re-measure — the measured-walls total no longer clobbers it.
    await page.locator('input[name="square_feet"]').fill('999');
    await page.locator('input[name="square_feet"]').dispatchEvent('input');
    await page.locator('.rc-room-label').fill('Dining room (updated)');
    await page.locator('.rc-room-label').dispatchEvent('input');
    await page.waitForTimeout(150);
    ok(await page.locator('input[name="square_feet"]').inputValue() === '999',
       'customer-entered square feet is not overwritten by measured total');

    await page.locator('[data-rc-add]').click();
    ok(await page.locator('.rc-room').count() === 2, 'add-room adds a second card');

    // ── Phase 2 — upload endpoint type-guard ────────────────────────
    console.log('\n[phase 2] upload endpoint type-guard');
    const postFile = async (bytes, type, name) => page.evaluate(
      async ({ bytes, type, name }) => {
        const meta = document.querySelector('meta[name="csrf-token"]');
        const fd = new FormData();
        fd.append('file', new Blob([new Uint8Array(bytes)], { type }), name);
        const r = await fetch('/api/booking-media', {
          method: 'POST', headers: { 'X-CSRF-Token': meta.content }, body: fd
        });
        return r.json();
      }, { bytes, type, name });

    const textBytes = Array.from('not an image').map(c => c.charCodeAt(0));
    const rejected = await postFile(textBytes, 'text/plain', 'x.txt');
    ok(rejected && rejected.ok === false, 'non-image/video upload rejected');

    // Content-sniff gate: text bytes mislabelled image/png clear multer's
    // fileFilter but must fail the magic-byte check.
    const spoofed = await postFile(textBytes, 'image/png', 'fake.png');
    ok(spoofed && spoofed.ok === false,
       'image/png mimetype with non-image bytes rejected by content sniff');

    // ── Phase 3 — room_captures DB round-trip + sanitiser ───────────
    console.log('\n[phase 3] room_captures DB round-trip + sanitiser');
    const { sanitize } = require('../lib/room-captures');
    const payload = JSON.stringify([
      { room: 'Dining', wall_width_ft: 12, wall_height_ft: 9,
        photo_url: '/uploads/bookings/' + 'a'.repeat(24) + '.jpg',
        wallpaper: { image_url: '/uploads/bookings/' + 'b'.repeat(24) + '.jpg',
                     width_in: 27, repeat_in: 18, match_type: 'straight' } },
      { room: 'Evil', photo_url: 'https://evil.example/x.jpg', wall_width_ft: 9, wall_height_ft: 9 }
    ]);
    const clean = sanitize(payload);
    const inst = await db().one('SELECT id FROM installers LIMIT 1');
    const s = new Date(Date.now() + 7 * 864e5), e = new Date(s.getTime() + 36e5);
    const row = await db().one(
      `INSERT INTO bookings
         (installer_id, customer_name, customer_email, scheduled_start, scheduled_end,
          room_captures, status, source)
       VALUES ($1,$2,$3,$4,$5,$6::jsonb,'pending','e2e')
       RETURNING id, room_captures`,
      [inst.id, 'E2E RoomCapture', 'e2e-roomcapture@example.com',
       s.toISOString(), e.toISOString(), JSON.stringify(clean)]
    );
    try {
      ok(Array.isArray(row.room_captures), 'room_captures reads back as a JSON array');
      ok(row.room_captures.length === 1, 'off-site-URL capture dropped by sanitiser (2 → 1)');
      ok(row.room_captures[0].wallpaper && row.room_captures[0].wallpaper.width_in === 27,
         'wallpaper width + repeat persisted on the booking row');
    } finally {
      await db().query('DELETE FROM bookings WHERE id = $1', [row.id]);
    }

    // ── Phase 4 — mobile viewport (the capture flow's primary surface) ──
    console.log('\n[phase 4] mobile viewport — 390px, touch');
    const mctx = await browser.newContext({
      viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true
    });
    const mpage = await mctx.newPage();
    try {
      await mpage.goto(`${BASE}/installer/${SLUG}/book`, { waitUntil: 'domcontentloaded' });
      const noOverflow = await mpage.evaluate(
        () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1
      );
      ok(noOverflow, 'no horizontal overflow at 390px');
      await mpage.locator('.rc-photo-input').setInputFiles(FIXTURE);
      await mpage.waitForSelector('.rc-stage:not([hidden])', { timeout: 8000 });
      const hSize = await mpage.locator('.rc-handle[data-corner="br"]')
        .evaluate(el => el.getBoundingClientRect().width);
      ok(hSize >= 30, `drag handles finger-sized on touch (${Math.round(hSize)}px)`);
    } finally {
      await mctx.close();
    }
  } catch (err) {
    console.error('\nFATAL:', err.message);
    fail++;
  }

  if (jsErrors.length) {
    console.log('\nUncaught JS errors on page:');
    jsErrors.forEach(e => console.log('  ' + e));
    fail += jsErrors.length;
  }
  await browser.close();
  if (_db && _db.pool) await _db.pool.end();
  console.log(`\n[result] ${pass} pass · ${fail} fail`);
  process.exit(fail > 0 ? 1 : 0);
})();