← back to NationalPaperHangers

tests/e2e-paper-helpful-vote.js

122 lines

// e2e click-test: helpful-vote on paper-thread comments (UX backlog #3,
// tick 4). Validates:
//   1. POST /papers/:slug/comments/:id/helpful from unauth visitor bumps
//      helpful_count by 1 + inserts paper_comment_votes row.
//   2. Repeat POST from same IP-hash is idempotent (no-op, count stays).
//   3. Button on detail page reflects new count after vote.
//   4. Bad route (mismatched slug + comment_id) returns 404.
// Cleanup: deletes the test comment (CASCADE removes the vote).
//
// Skip cleanly against remote BASE.

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

const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = 'de-gournay-earlham';
const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);

if (!IS_LOCAL) {
  console.log(`[e2e-helpful] 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 = `Helpful-vote e2e test note ${Date.now()} — verifying vote endpoint round-trip.`;

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

  try {
    // Setup: seed a comment we can vote on. Tied to atelier-bond-nyc (which
    // has a known seed installer_id).
    const installer = await db().one(`SELECT id FROM installers WHERE slug = 'atelier-bond-nyc'`);
    const thread = await db().one(`SELECT id FROM paper_threads WHERE slug = $1`, [SLUG]);
    const inserted = await db().one(
      `INSERT INTO paper_comments (thread_id, installer_id, body, helpful_count) VALUES ($1, $2, $3, 0) RETURNING id`,
      [thread.id, installer.id, NOTE]
    );
    createdCommentId = inserted.id;
    console.log(`\n[setup] inserted test comment id=${createdCommentId}`);

    console.log(`\n[step 1] page renders the vote button (count 0 = label shows "helpful")`);
    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
    const btnSelector = `form[action="/papers/${SLUG}/comments/${createdCommentId}/helpful#comment-${createdCommentId}"] button`;
    const btn = await page.$(btnSelector);
    assert(!!btn, 'helpful-vote button present for comment');

    console.log(`\n[step 2] click vote → count 0→1`);
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
      page.click(btnSelector)
    ]);
    const after1 = await db().one(`SELECT helpful_count FROM paper_comments WHERE id = $1`, [createdCommentId]);
    assert(after1.helpful_count === 1, `helpful_count 0→1 (got ${after1.helpful_count})`);
    const votes1 = await db().one(`SELECT COUNT(*)::int AS n FROM paper_comment_votes WHERE comment_id = $1`, [createdCommentId]);
    assert(votes1.n === 1, `paper_comment_votes has 1 row (got ${votes1.n})`);
    const html1 = await page.content();
    assert(html1.includes(`↑ helpful · 1`), 'button label reflects "↑ helpful · 1"');

    console.log(`\n[step 3] second vote from same browser-context → idempotent`);
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
      page.click(btnSelector)
    ]);
    const after2 = await db().one(`SELECT helpful_count FROM paper_comments WHERE id = $1`, [createdCommentId]);
    assert(after2.helpful_count === 1, `helpful_count still 1 after duplicate vote (got ${after2.helpful_count})`);
    const votes2 = await db().one(`SELECT COUNT(*)::int AS n FROM paper_comment_votes WHERE comment_id = $1`, [createdCommentId]);
    assert(votes2.n === 1, `paper_comment_votes still 1 row (got ${votes2.n})`);

    console.log(`\n[step 4] mismatched slug returns 404`);
    const r = await new Promise((resolve, reject) => {
      const u = new URL(`${BASE}/papers/wrong-slug/comments/${createdCommentId}/helpful`);
      const req = http.request({
        method: 'POST', hostname: u.hostname, port: u.port, path: u.pathname,
        headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': 0 }
      }, res => { res.on('data', () => {}); res.on('end', () => resolve(res.statusCode)); });
      req.on('error', reject);
      req.end();
    });
    assert(r === 404 || r === 403, `mismatched slug rejected (got ${r})`);

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

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