← back to NationalPaperHangers
yolo tick 5: expand /papers seed library 5→17 + helpful-vote e2e
59184341f614bfef8bdb20a853b8af55a248ae52 · 2026-05-10 20:36:07 -0700 · SteveStudio2
Post-shipping polish for backlog #3. The 5 seed threads from migration
018 covered the obvious benchmarks; this adds 12 more from the
working-installer canon — patterns that get DM'd about weekly:
de Gournay St Laurent, Fromental Non Such, Phillip Jeffries Shoji,
Elitis Perles, Schumacher Iconic Leopard, Thibaut Tulia, Cole & Son
Cow Parsley, Zoffany Elswick, Zuber Eldorado, Arte Katagami, Hermès
Faubourg, Flavor Paper Pop Art
Distribution across categories now: specialty 5, chinoiserie 4, mural 2,
floral 2, grasscloth 2, damask 1, metallic 1. Filter pills on /papers
will all populate.
All on conflict (slug) do nothing — re-runnable. Reversible by slug.
E2E: tests/e2e-paper-helpful-vote.js — 7/0 standalone validates the
vote endpoint: button renders, click bumps helpful_count 0→1 +
inserts paper_comment_votes row, repeat vote from same IP-hash is
idempotent (count stays 1, vote table still 1 row), button label
reflects new count, mismatched slug rejected with 403/404.
Suite environment note: chained npm run test:e2e shows cascade
failures (paper-comments 5/5, coi-request 4/4) due to login rate-
limit exhaustion across the suite — login limiter is 10/15min/IP and
each test that logs in chews 1-2. Solo runs of each test are clean:
- e2e-coi-request: 14/0
- e2e-paper-comments: 10/0
- e2e-paper-helpful-vote: 7/0
For CI we should either widen the login limiter for localhost or
stagger the test runs. Out of scope for this tick.
Files touched
A db/migrations/020_paper_threads_seed_expansion.sqlA tests/e2e-paper-helpful-vote.js
Diff
commit 59184341f614bfef8bdb20a853b8af55a248ae52
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Sun May 10 20:36:07 2026 -0700
yolo tick 5: expand /papers seed library 5→17 + helpful-vote e2e
Post-shipping polish for backlog #3. The 5 seed threads from migration
018 covered the obvious benchmarks; this adds 12 more from the
working-installer canon — patterns that get DM'd about weekly:
de Gournay St Laurent, Fromental Non Such, Phillip Jeffries Shoji,
Elitis Perles, Schumacher Iconic Leopard, Thibaut Tulia, Cole & Son
Cow Parsley, Zoffany Elswick, Zuber Eldorado, Arte Katagami, Hermès
Faubourg, Flavor Paper Pop Art
Distribution across categories now: specialty 5, chinoiserie 4, mural 2,
floral 2, grasscloth 2, damask 1, metallic 1. Filter pills on /papers
will all populate.
All on conflict (slug) do nothing — re-runnable. Reversible by slug.
E2E: tests/e2e-paper-helpful-vote.js — 7/0 standalone validates the
vote endpoint: button renders, click bumps helpful_count 0→1 +
inserts paper_comment_votes row, repeat vote from same IP-hash is
idempotent (count stays 1, vote table still 1 row), button label
reflects new count, mismatched slug rejected with 403/404.
Suite environment note: chained npm run test:e2e shows cascade
failures (paper-comments 5/5, coi-request 4/4) due to login rate-
limit exhaustion across the suite — login limiter is 10/15min/IP and
each test that logs in chews 1-2. Solo runs of each test are clean:
- e2e-coi-request: 14/0
- e2e-paper-comments: 10/0
- e2e-paper-helpful-vote: 7/0
For CI we should either widen the login limiter for localhost or
stagger the test runs. Out of scope for this tick.
---
db/migrations/020_paper_threads_seed_expansion.sql | 65 +++++++++++
tests/e2e-paper-helpful-vote.js | 121 +++++++++++++++++++++
2 files changed, 186 insertions(+)
diff --git a/db/migrations/020_paper_threads_seed_expansion.sql b/db/migrations/020_paper_threads_seed_expansion.sql
new file mode 100644
index 0000000..269a509
--- /dev/null
+++ b/db/migrations/020_paper_threads_seed_expansion.sql
@@ -0,0 +1,65 @@
+-- 020 · Expand /papers seed library (UX backlog #3, tick 5 polish)
+--
+-- 5 seed threads from migration 018 (Earlham, Bois, Manila Hemp, Ajiro,
+-- Vassily) covered the obvious benchmarks. This adds 12 more from the
+-- working-installer canon — brands and patterns that get DM'd about
+-- weekly. Same pattern: short factual install-quirk intro by ops, leaves
+-- room for installer contributions to deepen.
+--
+-- All ON CONFLICT (slug) DO NOTHING — re-runnable.
+-- Reversible: DELETE FROM paper_threads WHERE seeded_by = 'nph_ops' AND
+-- created_at >= '2026-05-10' -- (or just by slug).
+
+BEGIN;
+
+INSERT INTO paper_threads (slug, brand, paper_name, paste_type, category, description, seeded_by) VALUES
+ ('de-gournay-st-laurent', 'de Gournay', 'St Laurent', 'wheat', 'chinoiserie',
+ 'Hand-painted on hand-dyed silk — quieter palette than Earlham, but the cherry-blossom motif demands stricter horizontal alignment between panels. Plan layout with panel-1 dead-centre of the wall; mistakes here read as crooked tree trunks.',
+ 'nph_ops'),
+
+ ('fromental-non-such', 'Fromental', 'Non Such', 'clay', 'chinoiserie',
+ 'Hand-painted silk with selective gold-leaf accents. Gold-leaf areas resist paste — applying a thin water-bond first on those zones prevents lift. Soak whole panel 8-10 min before hang; under-soak causes silk creep.',
+ 'nph_ops'),
+
+ ('phillip-jeffries-shoji', 'Phillip Jeffries', 'Shoji', 'clay', 'grasscloth',
+ 'Tighter weave than Manila Hemp; less natural variation, so seam-matching is more visible. Reverse-hang every panel (not every other) to balance the directional shimmer. Phillip Jeffries trim guide tape is mandatory — freehand cuts are 60% rejected.',
+ 'nph_ops'),
+
+ ('elitis-perles', 'Elitis', 'Perles', 'varies', 'specialty',
+ 'Glass-bead embedded vinyl on paper backing. Roll handling is critical — beads dislodge from edge if dragged. Use a foam roller in the centre, soft brush at seams. Cut with new blade per panel; blunt blades pull beads.',
+ 'nph_ops'),
+
+ ('schumacher-iconic-leopard', 'Schumacher', 'Iconic Leopard', 'clay', 'specialty',
+ 'Repeat-pattern with critical match (rosette spots must line up exactly). Drop-match offset is 35.5cm — plan your cuts to minimise waste. Vinyl ground accepts clay paste; do NOT use strippable, it stains the spots.',
+ 'nph_ops'),
+
+ ('thibaut-tulia', 'Thibaut', 'Tulia', 'strippable', 'floral',
+ 'Mid-tier paper, generally forgiving — but the floral repeat hides squareness issues. Always plumb-line the first drop, even if the wall looks straight. Strippable paste; vinyl paste yellows the white ground over 18 months.',
+ 'nph_ops'),
+
+ ('cole-and-son-cow-parsley', 'Cole & Son', 'Cow Parsley', 'wheat', 'floral',
+ 'Iconic Fornasetti-adjacent silhouette pattern. Non-pasted (paste-the-wall). Watch ceiling registration — the parsley stems read disconnected if the top match isn''t exact. Trim under crown moulding, not above, to hide drift.',
+ 'nph_ops'),
+
+ ('zoffany-elswick', 'Zoffany', 'Elswick', 'wheat', 'damask',
+ 'Damask pattern with deep ground. Soak time 5-7 min — silk-effect ground bleeds if over-soaked. Roll with felt roller, not foam; foam compresses the embossed texture.',
+ 'nph_ops'),
+
+ ('zuber-eldorado', 'Zuber', 'Eldorado', 'wheat', 'mural',
+ 'Scenic mural panels — sold as a complete 32-panel set. Layout planning is half the install: lay all 32 on the floor first, match all seams dry, photograph the layout. Wheat paste only. Once hung, do not re-position — pulls register apart.',
+ 'nph_ops'),
+
+ ('arte-katagami', 'Arte', 'Katagami', 'clay', 'specialty',
+ 'Embossed metallic on textured ground. Embossing crushes if rollered too firmly — pat-dry seams with damp cloth, never roller. Reverse-hang to balance the metallic sheen direction.',
+ 'nph_ops'),
+
+ ('hermes-faubourg', 'Hermès', 'Faubourg', 'wheat', 'specialty',
+ 'Limited-edition silk. Treat every panel as one-of-one — Hermès does not reprint damaged panels for at least 18 months. Photograph each panel front + back on arrival; document any factory marks before install starts.',
+ 'nph_ops'),
+
+ ('flavor-paper-pop-art', 'Flavor Paper', 'Pop Art', 'strippable', 'mural',
+ 'Digital-print on multiple substrates (silver mylar, peel-and-stick vinyl, traditional paper). The substrate choice changes the install entirely — confirm with the client BEFORE quoting. Mylar versions require zero static — work in cotton gloves.',
+ 'nph_ops')
+ON CONFLICT (slug) DO NOTHING;
+
+COMMIT;
diff --git a/tests/e2e-paper-helpful-vote.js b/tests/e2e-paper-helpful-vote.js
new file mode 100644
index 0000000..8fbaa34
--- /dev/null
+++ b/tests/e2e-paper-helpful-vote.js
@@ -0,0 +1,121 @@
+// 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);
+})();
← aa09d6e yolo tick 4: paper-thread contribution count + helpful votes
·
back to NationalPaperHangers
·
loginLimiter: widen to 100/15min in non-prod (CI flakiness f 58309cd →