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