source: Klonkt/test/polls.test.js

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

fix(ap): a poll keeps its cover art when federated

The real reason "Swimming Pools" (a music poll) showed a blank tile
after every earlier cover fix: applyPollToNote did delete note.image,
stripping the cover off the AS2 Question entirely — so it was never in
the federated poll, and no amount of consumer-side healing could bring
back what the origin never sent.

The comment justified it as "media + poll are mutually exclusive on
Mastodon", but that's true only of attachment (media). image is the
cover, which Mastodon ignores on any Note/Question anyway and Klonkt
reads to show the cover in feeds/the Cirkel. Keep attachment stripped,
keep image.

Verified with a new test: an embed/music poll (images suppressed → cover
in note.image) stays a Question, keeps note.image with its alt text, and
still has no attachment. Suite 46/46.

  • src/services/ActivityPubService.js — stop deleting note.image in applyPollToNote
  • test/polls.test.js — poll-keeps-cover test
  • CHANGELOG(.nl/.de).md — Unreleased entry (3 languages)

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

  • Property mode set to 100644
File size: 5.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('a poll KEEPS its cover in note.image (feeds show it; Mastodon ignores it)', () => {
47 // An embed/music poll suppresses image attachments (so Mastodon shows the
48 // player/embed card) and carries the cover in note.image instead — exactly
49 // the case that lost its cover when applyPollToNote deleted image.
50 const withCover = {
51 ...singlePoll, id: 'p1', slug: 'fav',
52 content: '<p>Pick one</p> [[embed:https://www.youtube.com/watch?v=abcdefghijk]]',
53 cover_image_url: '/media/post-images/cover.webp', cover_alt: 'Album art',
54 };
55 const note = AP.buildNote(BASE, site, withCover);
56 assert.equal(note.type, 'Question', 'still a Question');
57 assert.ok(note.image && note.image.url, 'the cover survives as note.image');
58 assert.ok(note.image.url.endsWith('/media/post-images/cover.webp'), 'absolute cover url');
59 assert.equal(note.image.name, 'Album art', 'alt text kept');
60 assert.ok(!('attachment' in note), 'still no media attachment on a poll');
61});
62
63test('votersCount is a declared JSON-LD term (valid AS2)', () => {
64 const ctx = new Set();
65 for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctx.add(k);
66 assert.ok(ctx.has('votersCount'), 'AP_CONTEXT must declare votersCount');
67});
68
69test('ownPollView computes single-choice percentages against total votes', () => {
70 const view = AP.ownPollView(singlePoll);
71 assert.equal(view.total, 3);
72 assert.equal(view.voters, 3);
73 assert.equal(view.multiple, false);
74 const a = view.options.find((o) => o.name === 'A');
75 assert.equal(a.count, 2);
76 assert.equal(a.pct, 67); // round(2/3*100)
77});
78
79test('multiple-choice poll → anyOf and percentages against voters', () => {
80 const multiPoll = {
81 id: 'p2', slug: 'langs', title: 'Which?', content: '<p>Pick any</p>', tags: '[]',
82 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
83 poll_json: JSON.stringify({ multiple: true, options: [{ name: 'X' }, { name: 'Y' }], endTime: '2030-01-01T00:00:00Z', closed: false }),
84 };
85 // One voter picks both options → two ballots, one voter. Each option = 100% of voters.
86 ins.run('p2', 'https://a.test/users/x', 'X');
87 ins.run('p2', 'https://a.test/users/x', 'Y');
88 const note = AP.buildNote(BASE, site, multiPoll);
89 assert.ok(Array.isArray(note.anyOf) && !note.oneOf, 'multiple choice → anyOf, no oneOf');
90 assert.equal(note.votersCount, 1);
91 const view = AP.ownPollView(multiPoll);
92 assert.equal(view.voters, 1);
93 assert.equal(view.options.find((o) => o.name === 'X').pct, 100);
94 assert.equal(view.options.find((o) => o.name === 'Y').pct, 100);
95});
96
97test('a poll past its endTime reads as closed', () => {
98 const ended = { id: 'p3', poll_json: JSON.stringify({ multiple: false, options: [{ name: 'A' }, { name: 'B' }], endTime: '2000-01-01T00:00:00Z', closed: false }) };
99 assert.equal(AP.parseOwnPoll(ended.poll_json).closed, true);
100});
101
102test('a post without a poll stays a Note', () => {
103 const plain = { id: 'p9', slug: 'x', title: 'x', content: '<p>hi</p>', tags: '[]', created_at: '2026-01-01T00:00:00Z' };
104 assert.equal(AP.buildNote(BASE, site, plain).type, 'Note');
105 assert.equal(AP.ownPollView(plain), null);
106});
Note: See TracBrowser for help on using the repository browser.