source: Klonkt/test/polls.test.js@ 0403187

main
Last change on this file since 0403187 was 0403187, checked in by roboburr <roboburr@…>, 2 months ago

feat(fediverse): host your own polls (federate as AS2 Question)

A post can carry a poll that federates as an AS2 Question so remote (Mastodon)
followers vote from their own app; votes are tallied server-side and the fresh
counts are pushed back as Update(Question). Voting is fediverse-only; the site
shows live, read-only results. Complements the existing inbound poll support.

  • src/config/database.js — posts.poll_json (our poll definition) + poll_votes table (post_id, actor_uri, choice; UNIQUE) backing the tally + per-actor dedupe.
  • src/services/ActivityPubService.js — parseOwnPoll/pollTally/ownPollView helpers; buildNote emits a Question (oneOf/anyOf + replies.totalItems + endTime/closed + votersCount) for a poll post; handleInbox records a ballot (Note with name + inReplyTo our poll) before the reply path, deduped per actor; a debounced Update(Question) pushes fresh counts to followers; votersCount added to AP_CONTEXT.
  • src/services/Scheduler.js — closeExpiredPolls() marks a poll closed once its endTime passes and pushes the final tally; runs on the existing 60s tick.
  • src/routes/posts.js — parsePollForm() turns the editor fields into poll_json on create/save (a poll with votes is frozen), passes poll_json to the federation hooks, and hands the post page a render-ready ownPollView.
  • src/views/pages/post-edit.ejs — poll section (options, multiple-choice, duration); disabled once the poll has votes.
  • src/views/pages/post.ejs — display-only poll with result bars + voter/close meta.
  • src/services/i18n.js — poll.* + pedit.poll_* strings (nl/en/de).
  • test/polls.test.js — Question shape, tally, percentages, closed state, AS2 term.
  • CHANGELOG(.nl/.de).md — "Create your own polls" under Unreleased.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 4.4 KB
Line 
1// Outbound polls — a hosted poll federates as an AS2 Question and its tally is derived
2// from the poll_votes ballots. No extra deps; in-memory SQLite. Run: npm test
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5
6process.env.DATABASE_PATH = ':memory:';
7process.env.PUBLIC_BASE_URL = 'https://test.example';
8
9const dbMod = await import('../src/config/database.js');
10const db = dbMod.default;
11dbMod.initializeDatabase();
12const AP = (await import('../src/services/ActivityPubService.js')).default;
13
14const BASE = 'https://test.example';
15
16db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
17db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'demo', 'Demo', 'u1', 1);
18const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
19site.primary_slug = 'demo';
20
21// A single-choice poll with three options; seed distinct ballots: A×2 (x, z), B×1 (y).
22const singlePoll = {
23 id: 'p1', slug: 'fav', title: 'Favourite?', content: '<p>Pick one</p>', tags: '[]',
24 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
25 poll_json: JSON.stringify({ multiple: false, options: [{ name: 'A' }, { name: 'B' }, { name: 'C' }], endTime: '2030-01-01T00:00:00Z', closed: false }),
26};
27const ins = db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?,?,?)');
28ins.run('p1', 'https://a.test/users/x', 'A');
29ins.run('p1', 'https://b.test/users/y', 'B');
30ins.run('p1', 'https://c.test/users/z', 'A');
31
32test('a hosted poll federates as an AS2 Question with oneOf + tally', () => {
33 const note = AP.buildNote(BASE, site, singlePoll);
34 assert.equal(note.type, 'Question');
35 assert.ok(Array.isArray(note.oneOf) && !note.anyOf, 'single choice → oneOf, no anyOf');
36 assert.equal(note.oneOf.length, 3);
37 const byName = Object.fromEntries(note.oneOf.map((o) => [o.name, o.replies.totalItems]));
38 assert.equal(byName.A, 2);
39 assert.equal(byName.B, 1);
40 assert.equal(byName.C, 0);
41 assert.equal(note.votersCount, 3, 'three distinct voters');
42 assert.ok(note.endTime, 'has an endTime');
43 assert.ok(!('attachment' in note), 'no media on a poll (mutually exclusive on Mastodon)');
44});
45
46test('votersCount is a declared JSON-LD term (valid AS2)', () => {
47 const ctx = new Set();
48 for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctx.add(k);
49 assert.ok(ctx.has('votersCount'), 'AP_CONTEXT must declare votersCount');
50});
51
52test('ownPollView computes single-choice percentages against total votes', () => {
53 const view = AP.ownPollView(singlePoll);
54 assert.equal(view.total, 3);
55 assert.equal(view.voters, 3);
56 assert.equal(view.multiple, false);
57 const a = view.options.find((o) => o.name === 'A');
58 assert.equal(a.count, 2);
59 assert.equal(a.pct, 67); // round(2/3*100)
60});
61
62test('multiple-choice poll → anyOf and percentages against voters', () => {
63 const multiPoll = {
64 id: 'p2', slug: 'langs', title: 'Which?', content: '<p>Pick any</p>', tags: '[]',
65 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
66 poll_json: JSON.stringify({ multiple: true, options: [{ name: 'X' }, { name: 'Y' }], endTime: '2030-01-01T00:00:00Z', closed: false }),
67 };
68 // One voter picks both options → two ballots, one voter. Each option = 100% of voters.
69 ins.run('p2', 'https://a.test/users/x', 'X');
70 ins.run('p2', 'https://a.test/users/x', 'Y');
71 const note = AP.buildNote(BASE, site, multiPoll);
72 assert.ok(Array.isArray(note.anyOf) && !note.oneOf, 'multiple choice → anyOf, no oneOf');
73 assert.equal(note.votersCount, 1);
74 const view = AP.ownPollView(multiPoll);
75 assert.equal(view.voters, 1);
76 assert.equal(view.options.find((o) => o.name === 'X').pct, 100);
77 assert.equal(view.options.find((o) => o.name === 'Y').pct, 100);
78});
79
80test('a poll past its endTime reads as closed', () => {
81 const ended = { id: 'p3', poll_json: JSON.stringify({ multiple: false, options: [{ name: 'A' }, { name: 'B' }], endTime: '2000-01-01T00:00:00Z', closed: false }) };
82 assert.equal(AP.parseOwnPoll(ended.poll_json).closed, true);
83});
84
85test('a post without a poll stays a Note', () => {
86 const plain = { id: 'p9', slug: 'x', title: 'x', content: '<p>hi</p>', tags: '[]', created_at: '2026-01-01T00:00:00Z' };
87 assert.equal(AP.buildNote(BASE, site, plain).type, 'Note');
88 assert.equal(AP.ownPollView(plain), null);
89});
Note: See TracBrowser for help on using the repository browser.