| 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
|
|---|
| 3 | import { test } from 'node:test';
|
|---|
| 4 | import assert from 'node:assert/strict';
|
|---|
| 5 |
|
|---|
| 6 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 7 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 8 |
|
|---|
| 9 | const dbMod = await import('../src/config/database.js');
|
|---|
| 10 | const db = dbMod.default;
|
|---|
| 11 | dbMod.initializeDatabase();
|
|---|
| 12 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 13 |
|
|---|
| 14 | const BASE = 'https://test.example';
|
|---|
| 15 |
|
|---|
| 16 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 17 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'demo', 'Demo', 'u1', 1);
|
|---|
| 18 | const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
|
|---|
| 19 | site.primary_slug = 'demo';
|
|---|
| 20 |
|
|---|
| 21 | // A single-choice poll with three options; seed distinct ballots: A×2 (x, z), B×1 (y).
|
|---|
| 22 | const 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 | };
|
|---|
| 27 | const ins = db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?,?,?)');
|
|---|
| 28 | ins.run('p1', 'https://a.test/users/x', 'A');
|
|---|
| 29 | ins.run('p1', 'https://b.test/users/y', 'B');
|
|---|
| 30 | ins.run('p1', 'https://c.test/users/z', 'A');
|
|---|
| 31 |
|
|---|
| 32 | test('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 |
|
|---|
| 46 | test('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 |
|
|---|
| 52 | test('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 |
|
|---|
| 62 | test('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 |
|
|---|
| 80 | test('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 |
|
|---|
| 85 | test('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 | });
|
|---|