← back to NationalPaperHangers

tests/e2e-paper-comments.js

142 lines

// e2e click-test: paper-thread comment submission (UX backlog #3, tick 3).
//
// Flow:
//   1. Public /papers/de-gournay-earlham renders thread + login CTA.
//   2. Login as installer (atelier-bond-nyc).
//   3. Profile fetched — currentInstaller surfaces → comment form replaces CTA.
//   4. Submit a valid note → row inserted, trigger bumps thread.comment_count.
//   5. Page renders the new comment with the installer attribution.
//   6. Submit a too-short note → flash.error shown, no row written.
//   7. Cleanup: delete the test row, reset thread.comment_count.
//
// Skip cleanly against remote BASE.

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 = 'de-gournay-earlham';
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-paper-comments] 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 { require('fs').accessSync(p); return p; } catch {} }
  return null;
}

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

const NOTE = `E2E test note ${Date.now()} — soak time matters when seam-matching this paper; bring a backup brush.`;
const SHORT_NOTE = 'too short';

(async () => {
  const exe = findChromium();
  if (!exe) { console.error('FAIL: no Chromium'); process.exit(2); }
  console.log(`[e2e-papers] 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 createdId = null;

  try {
    console.log(`\n[step 1] public ${BASE}/papers/${SLUG} renders unauth`);
    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
    const html1 = await page.content();
    assert(html1.includes('Earlham'), 'thread name renders');
    assert(/Log in as a verified installer/i.test(html1), 'unauth CTA shown');

    console.log(`\n[step 2] login as ${EMAIL}`);
    const loginResp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
    if (loginResp && loginResp.status() === 429) {
      console.log('  ⊘ login rate-limit — skipping write phases');
      process.exit(78);
    }
    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');

    console.log(`\n[step 3] /papers/${SLUG} now shows the comment form`);
    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
    const form = await page.$('#comment-form form[action$="/comments"]');
    assert(!!form, 'comment form present for logged-in installer');

    console.log(`\n[step 4] submit a valid note`);
    const beforeCount = (await db().one(`SELECT comment_count FROM paper_threads WHERE slug=$1`, [SLUG])).comment_count;
    await page.fill('#comment-form textarea[name="body"]', NOTE);
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
      page.click('#comment-form button[type="submit"]')
    ]);

    const newRow = await db().one(
      `SELECT c.id, c.body, c.flagged, t.comment_count
         FROM paper_comments c
         JOIN paper_threads t ON t.id = c.thread_id
        WHERE t.slug = $1 AND c.body = $2
        ORDER BY c.id DESC LIMIT 1`,
      [SLUG, NOTE]
    );
    assert(!!newRow, 'paper_comments row created');
    if (newRow) createdId = newRow.id;
    assert(newRow && !newRow.flagged, 'flagged=false by default');
    assert(newRow && newRow.comment_count === beforeCount + 1, `trigger bumped comment_count ${beforeCount}→${newRow && newRow.comment_count}`);

    const html2 = await page.content();
    assert(html2.includes(NOTE.slice(0, 60)), 'new comment renders on detail page');

    console.log(`\n[step 5] submit a too-short note → rejected`);
    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
    await page.fill('#comment-form textarea[name="body"]', SHORT_NOTE);
    // Bypass HTML5 minlength by removing the attribute first — backend must still reject
    await page.evaluate(() => {
      document.querySelector('textarea[name="body"]').removeAttribute('minlength');
    });
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
      page.click('#comment-form button[type="submit"]')
    ]);
    const html3 = await page.content();
    assert(/at least 20 characters/i.test(html3), 'too-short note rejected with flash.error');
    const shortRow = await db().one(
      `SELECT id FROM paper_comments WHERE body = $1`, [SHORT_NOTE]
    );
    assert(!shortRow, 'no row written for too-short note');

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

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