nodebb-plugin-poll 4.0.15 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-poll",
3
- "version": "4.0.15",
3
+ "version": "4.1.1",
4
4
  "description": "NodeBB Poll Plugin",
5
5
  "main": "library.js",
6
6
  "scripts": {
@@ -26,7 +26,7 @@
26
26
  "cron": "^1.8.2"
27
27
  },
28
28
  "nbbpm": {
29
- "compatibility": "^4.14.0"
29
+ "compatibility": "^4.17.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "eslint": "10.6.0",
package/plugin.json CHANGED
@@ -17,6 +17,8 @@
17
17
  { "hook": "filter:post.create", "method": "hooks.filter.postCreate" },
18
18
  { "hook": "filter:post.getFields", "method": "hooks.filter.postGetFields" },
19
19
  { "hook": "filter:post.edit", "method": "hooks.filter.postEdit" },
20
+ { "hook": "filter:activitypub.mocks.note", "method": "hooks.filter.activitypubMocksNote" },
21
+ { "hook": "filter:activitypub.create", "method": "hooks.filter.activitypubCreate" },
20
22
 
21
23
  { "hook": "action:post.delete", "method": "hooks.action.postDelete" },
22
24
  { "hook": "action:post.restore", "method": "hooks.action.postRestore" },
File without changes
@@ -201,6 +201,11 @@
201
201
  setTimezoneLabel(modal);
202
202
  handleOptionSort({ modal });
203
203
 
204
+ // Show/hide the anon-voting federation note as the checkbox is toggled
205
+ modal.find('#allowAnonVoting').on('change', function () {
206
+ modal.find('#pollAnonVotingNote').toggleClass('hidden', !this.checked);
207
+ });
208
+
204
209
  // Add option adder
205
210
  modal.find('#pollAddOption')
206
211
  .off('click')
File without changes
@@ -3,7 +3,7 @@
3
3
  (function (Poll) {
4
4
  function vote(view, options) {
5
5
  const form = view.dom.votingPanel.find('form');
6
- const votes = form.serializeArray().map(option => parseInt(option.value, 10));
6
+ const votes = form.serializeArray().map(option => option.value);
7
7
 
8
8
  if (votes.length > 0) {
9
9
  const voteData = {
@@ -66,7 +66,7 @@
66
66
  handle: function (view) {
67
67
  const form = view.dom.votingPanel.find('form');
68
68
  const votes = form.serializeArray().map(function (option) {
69
- return parseInt(option.value, 10);
69
+ return option.value;
70
70
  });
71
71
 
72
72
  if (votes.length > 0) {
@@ -204,6 +204,10 @@
204
204
  };
205
205
 
206
206
  View.prototype.voteUpdateAllowed = function () {
207
+ // Remote polls: vote changes/removals are not federated (no OSW support yet)
208
+ if (parseInt(this.pollData.info.remote, 10) === 1) {
209
+ return false;
210
+ }
207
211
  return parseInt(this.pollData.info.disallowVoteUpdate, 10) !== 1;
208
212
  };
209
213
 
File without changes
@@ -36,6 +36,7 @@
36
36
  <div class="form-check mb-3">
37
37
  <label class="form-check-label" for="allowAnonVoting">{{tx("poll:allow_anon_voting")}}</label>
38
38
  <input class="form-check-input" type="checkbox" name="allowAnonVoting" id="allowAnonVoting" {{{if poll.allowAnonVoting}}}checked{{{end}}}>
39
+ <p class="form-text {{{if poll.allowAnonVoting}}}{{{else}}}hidden{{{end}}}" id="pollAnonVotingNote">{{tx("poll:anon_voting_not_federated")}}</p>
39
40
  </div>
40
41
 
41
42
  <div class="mb-3">
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ // Poll-specific test fixtures. Kept out of core so the core test suite has no
4
+ // plugin-specific artifacts. Generic AP fixtures (note/create/mockRequests)
5
+ // still come from `nodebb.require('./test/activitypub/helpers')`.
6
+
7
+ const utils = nodebb.require('./src/utils');
8
+ const activitypub = nodebb.require('./src/activitypub');
9
+
10
+ const baseUrl = 'https://example.org';
11
+
12
+ module.exports = {
13
+ // FEP-9967 `Question` (poll) object. Mirrors the core note() factory with
14
+ // poll fields (oneOf/anyOf, endTime, votersCount). Caches the object in the
15
+ // AP request cache so the inbox flow can fetch it without real requests.
16
+ question: (override = {}) => {
17
+ const uuid = utils.generateUUID();
18
+ const id = `${baseUrl}/object/${uuid}`;
19
+ const question = {
20
+ '@context': 'https://www.w3.org/ns/activitystreams',
21
+ id,
22
+ url: id,
23
+ type: 'Question',
24
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
25
+ cc: [`${baseUrl}/user/foobar/followers`],
26
+ inReplyTo: null,
27
+ attributedTo: `${baseUrl}/user/foobar`,
28
+ name: utils.generateUUID(),
29
+ content: `<p>${utils.generateUUID()}</p>`,
30
+ published: new Date().toISOString(),
31
+ endTime: new Date(Date.now() + (24 * 60 * 60 * 1000)).toISOString(),
32
+ oneOf: [
33
+ { type: 'Note', name: 'Option A', replies: { type: 'Collection', totalItems: 3 } },
34
+ { type: 'Note', name: 'Option B', replies: { type: 'Collection', totalItems: 7 } },
35
+ ],
36
+ votersCount: 10,
37
+ ...override,
38
+ };
39
+
40
+ Object.entries(question).forEach(([key, value]) => {
41
+ if (value === 'remove') {
42
+ delete question[key];
43
+ }
44
+ });
45
+ activitypub._cache.set(`0;${id}`, question);
46
+
47
+ return { id, question };
48
+ },
49
+ };
package/test/index.js ADDED
@@ -0,0 +1,530 @@
1
+ /**
2
+ * FEP-9967 federated poll (Question) tests.
3
+ *
4
+ * Run from the NodeBB root: `npx mocha test/plugins-installed.js`
5
+ * (the regular test runner picks these up too). The plugin is listed in
6
+ * `config.json`'s `test_plugins` so its hooks are loaded for the ingestion
7
+ * tests.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ /* globals describe, it, before, after */
13
+
14
+ const assert = require('assert');
15
+
16
+ const db = nodebb.require('./test/mocks/databasemock');
17
+ const install = nodebb.require('./src/install');
18
+ const user = nodebb.require('./src/user');
19
+ const posts = nodebb.require('./src/posts');
20
+ const activitypub = nodebb.require('./src/activitypub');
21
+ const utils = nodebb.require('./src/utils');
22
+ const helpers = nodebb.require('./test/activitypub/helpers');
23
+ const pollHelpers = require('./helpers');
24
+
25
+ const Poll = require('../lib/poll');
26
+
27
+ describe('nodebb-plugin-poll (FEP-9967 federated polls)', () => {
28
+ let uid;
29
+
30
+ before(async () => {
31
+ // Serve AP objects from the cache (no real outbound requests)
32
+ helpers.mocks.mockRequests();
33
+ await install.giveWorldPrivileges();
34
+ uid = await user.create({ username: utils.generateUUID() });
35
+ });
36
+
37
+ after(() => {
38
+ helpers.mocks.restoreRequests();
39
+ });
40
+
41
+ describe('Ingestion', () => {
42
+ it('should create a topic and a remote poll from a Create(Question)', async () => {
43
+ const { question, id } = pollHelpers.question();
44
+ const { activity } = helpers.mocks.create(question);
45
+
46
+ await db.sortedSetAdd(`followersRemote:${question.attributedTo}`, Date.now(), uid);
47
+ await activitypub.inbox.create({ body: activity });
48
+
49
+ assert(await posts.exists(id));
50
+
51
+ // A poll should have been created and linked to the post
52
+ const pollIds = JSON.parse(await db.getObjectField(`post:${id}`, 'pollIds'));
53
+ assert(pollIds, 'post should have pollIds');
54
+ assert.strictEqual(pollIds.length, 1);
55
+
56
+ const poll = await db.getObject(`poll:${pollIds[0]}`);
57
+ assert(poll, 'poll object should exist');
58
+ assert.strictEqual(parseInt(poll.remote, 10), 1, 'poll should be marked remote');
59
+
60
+ const options = JSON.parse(poll.options);
61
+ assert.strictEqual(options.length, 2);
62
+ assert.strictEqual(options[0].title, 'Option A');
63
+ assert.strictEqual(options[1].title, 'Option B');
64
+
65
+ const remoteVotes = JSON.parse(poll.remoteVotes);
66
+ assert.strictEqual(Object.keys(remoteVotes).length, 2);
67
+ const totalVotes = Object.values(remoteVotes).reduce((sum, n) => sum + n, 0);
68
+ assert.strictEqual(totalVotes, 10);
69
+ });
70
+
71
+ it('should expose the ingested vote counts via Poll.getInfo', async () => {
72
+ const { question, id } = pollHelpers.question();
73
+ const { activity } = helpers.mocks.create(question);
74
+
75
+ await db.sortedSetAdd(`followersRemote:${question.attributedTo}`, Date.now(), uid);
76
+ await activitypub.inbox.create({ body: activity });
77
+
78
+ const pollIds = JSON.parse(await db.getObjectField(`post:${id}`, 'pollIds'));
79
+ const info = await Poll.getInfo(pollIds[0]);
80
+ assert.strictEqual(info.voteCount, 10);
81
+ const optionA = info.options.find(o => o.title === 'Option A');
82
+ const optionB = info.options.find(o => o.title === 'Option B');
83
+ assert.strictEqual(optionA.voteCount, 3);
84
+ assert.strictEqual(optionB.voteCount, 7);
85
+ });
86
+ });
87
+
88
+ describe('Poll.createFromQuestion', () => {
89
+ it('should build a remote poll with ingested counts', async () => {
90
+ const { question } = pollHelpers.question();
91
+ const ap = {
92
+ oneOf: question.oneOf,
93
+ endTime: question.endTime,
94
+ votersCount: question.votersCount,
95
+ };
96
+ const poll = await Poll.createFromQuestion({
97
+ pid: 'test-pid',
98
+ uid: 1,
99
+ timestamp: Date.now(),
100
+ }, ap);
101
+
102
+ assert.strictEqual(parseInt(poll.remote, 10), 1);
103
+ const options = JSON.parse(poll.options);
104
+ assert.strictEqual(options.length, 2);
105
+ assert.strictEqual(options[0].title, 'Option A');
106
+ assert.strictEqual(options[1].title, 'Option B');
107
+
108
+ const info = await Poll.getInfo(poll.pollId);
109
+ assert.strictEqual(info.voteCount, 10);
110
+ const optionA = info.options.find(o => o.title === 'Option A');
111
+ const optionB = info.options.find(o => o.title === 'Option B');
112
+ assert.strictEqual(optionA.voteCount, 3);
113
+ assert.strictEqual(optionB.voteCount, 7);
114
+ });
115
+
116
+ it('should treat anyOf as multi-select', async () => {
117
+ const { question } = pollHelpers.question();
118
+ const ap = {
119
+ anyOf: question.oneOf,
120
+ endTime: question.endTime,
121
+ votersCount: question.votersCount,
122
+ };
123
+ const poll = await Poll.createFromQuestion({
124
+ pid: 'test-pid',
125
+ uid: 1,
126
+ timestamp: Date.now(),
127
+ }, ap);
128
+
129
+ assert.strictEqual(parseInt(poll.remote, 10), 1);
130
+ assert.strictEqual(parseInt(poll.maximumVotesPerUser, 10), 2, 'anyOf should allow multiple votes');
131
+ });
132
+ });
133
+
134
+ describe('Poll.updateFromQuestion', () => {
135
+ it('should refresh ingested counts', async () => {
136
+ const { question } = pollHelpers.question();
137
+ const ap = {
138
+ oneOf: question.oneOf,
139
+ endTime: question.endTime,
140
+ votersCount: question.votersCount,
141
+ };
142
+ const poll = await Poll.createFromQuestion({
143
+ pid: 'test-pid',
144
+ uid: 1,
145
+ timestamp: Date.now(),
146
+ }, ap);
147
+
148
+ const updatedAp = {
149
+ oneOf: [
150
+ { type: 'Note', name: 'Option A', replies: { type: 'Collection', totalItems: 5 } },
151
+ { type: 'Note', name: 'Option B', replies: { type: 'Collection', totalItems: 12 } },
152
+ ],
153
+ endTime: question.endTime,
154
+ votersCount: 17,
155
+ };
156
+ await Poll.updateFromQuestion(poll.pollId, updatedAp);
157
+
158
+ const info = await Poll.getInfo(poll.pollId);
159
+ assert.strictEqual(info.voteCount, 17);
160
+ const optionA = info.options.find(o => o.title === 'Option A');
161
+ const optionB = info.options.find(o => o.title === 'Option B');
162
+ assert.strictEqual(optionA.voteCount, 5);
163
+ assert.strictEqual(optionB.voteCount, 12);
164
+ });
165
+ });
166
+
167
+ describe('Outbound Question serialization (filter:activitypub.mocks.note)', () => {
168
+ const Plugin = require('../library');
169
+
170
+ it('should transform a post with a single-choice poll into a Question', async () => {
171
+ const pid = `test-pid-${utils.generateUUID()}`;
172
+ const end = Date.now() + (24 * 60 * 60 * 1000);
173
+ const poll = await Poll.add({ pid, uid: 1, timestamp: Date.now() }, [{
174
+ title: 'Test poll',
175
+ end,
176
+ maximumVotesPerUser: 1,
177
+ options: [
178
+ { id: '1', title: 'Option A' },
179
+ { id: '2', title: 'Option B' },
180
+ ],
181
+ }]);
182
+ await db.setObjectField(`post:${pid}`, 'pollIds', JSON.stringify(poll.map(p => String(p.pollId))));
183
+
184
+ // One vote on Option A so votersCount > 0
185
+ await db.sortedSetAdd(`poll:${poll[0].pollId}:options:1:votes`, Date.now(), 'voter-1');
186
+ await db.sortedSetAdd(`poll:${poll[0].pollId}:voters`, Date.now(), 'voter-1');
187
+
188
+ // Simulate the object Mocks.notes.public builds for a main post (an Article)
189
+ const object = {
190
+ '@context': 'https://www.w3.org/ns/activitystreams',
191
+ id: 'https://example.org/post/123',
192
+ type: 'Article',
193
+ name: 'Test topic title',
194
+ content: '<p>Test poll</p>',
195
+ preview: { type: 'Note', content: '<p>Test poll</p>' },
196
+ summary: 'Test poll [...]',
197
+ sensitive: false,
198
+ };
199
+
200
+ const result = await Plugin.hooks.filter.activitypubMocksNote({ object, post: { pid } });
201
+
202
+ assert.strictEqual(result.object.type, 'Question');
203
+ assert(result.object.oneOf, 'should have oneOf');
204
+ assert.strictEqual(result.object.oneOf.length, 2);
205
+ assert.strictEqual(result.object.oneOf[0].name, 'Option A');
206
+ assert.strictEqual(result.object.oneOf[0].replies.totalItems, 1, 'Option A should have 1 vote');
207
+ assert.strictEqual(result.object.oneOf[1].name, 'Option B');
208
+ assert.strictEqual(result.object.oneOf[1].replies.totalItems, 0, 'Option B should have 0 votes');
209
+ assert(result.object.endTime, 'should have endTime');
210
+ assert.strictEqual(result.object.votersCount, 1, 'should have votersCount');
211
+ assert.strictEqual(result.object.name, 'Test topic title', 'name should be kept');
212
+ assert.strictEqual(result.object.preview, undefined, 'preview should be dropped');
213
+ assert.strictEqual(result.object.summary, undefined, 'summary should be dropped');
214
+ assert.strictEqual(result.object.sensitive, undefined, 'sensitive should be dropped');
215
+ });
216
+
217
+ it('should use anyOf for a multi-choice poll and omit endTime when open', async () => {
218
+ const pid = `test-pid-${utils.generateUUID()}`;
219
+ const poll = await Poll.add({ pid, uid: 1, timestamp: Date.now() }, [{
220
+ title: 'Test poll',
221
+ end: 0,
222
+ maximumVotesPerUser: 3,
223
+ options: [
224
+ { id: '1', title: 'Option A' },
225
+ { id: '2', title: 'Option B' },
226
+ { id: '3', title: 'Option C' },
227
+ ],
228
+ }]);
229
+ await db.setObjectField(`post:${pid}`, 'pollIds', JSON.stringify(poll.map(p => String(p.pollId))));
230
+
231
+ const object = {
232
+ '@context': 'https://www.w3.org/ns/activitystreams',
233
+ id: 'https://example.org/post/456',
234
+ type: 'Note',
235
+ content: '<p>Test poll</p>',
236
+ };
237
+
238
+ const result = await Plugin.hooks.filter.activitypubMocksNote({ object, post: { pid } });
239
+
240
+ assert.strictEqual(result.object.type, 'Question');
241
+ assert(result.object.anyOf, 'should have anyOf');
242
+ assert.strictEqual(result.object.anyOf.length, 3);
243
+ assert.strictEqual(result.object.oneOf, undefined, 'should not have oneOf');
244
+ assert.strictEqual(result.object.endTime, undefined, 'should not have endTime (open poll)');
245
+ assert.strictEqual(result.object.closed, undefined, 'should not have closed (open poll)');
246
+ });
247
+
248
+ it('should leave a post without polls untouched', async () => {
249
+ const pid = `test-pid-${utils.generateUUID()}`;
250
+ const object = {
251
+ '@context': 'https://www.w3.org/ns/activitystreams',
252
+ id: 'https://example.org/post/789',
253
+ type: 'Note',
254
+ content: '<p>No poll</p>',
255
+ };
256
+
257
+ const result = await Plugin.hooks.filter.activitypubMocksNote({ object, post: { pid } });
258
+
259
+ assert.strictEqual(result.object.type, 'Note', 'type should be unchanged');
260
+ assert.strictEqual(result.object.oneOf, undefined);
261
+ assert.strictEqual(result.object.anyOf, undefined);
262
+ });
263
+ });
264
+
265
+ describe('FEP-9967 outbound vote federation (Phase 3)', () => {
266
+ let Sockets;
267
+ let Vote;
268
+ let Poll;
269
+ let activitypub;
270
+
271
+ before(async () => {
272
+ Sockets = require('../lib/sockets');
273
+ Vote = require('../lib/vote');
274
+ Poll = require('../lib/poll');
275
+ activitypub = nodebb.require('./src/activitypub');
276
+ });
277
+
278
+ beforeEach(() => {
279
+ activitypub._sent.clear();
280
+ });
281
+
282
+ // Create a remote poll via inbox ingestion and return the poll data + question id
283
+ async function createRemotePollFixture({ options, remoteVotes, allowAnonVoting = 0 }) {
284
+ const oneOf = options.map((title, i) => ({
285
+ type: 'Note',
286
+ name: title,
287
+ replies: { type: 'Collection', totalItems: (remoteVotes && remoteVotes[i + 1]) || 0 },
288
+ }));
289
+
290
+ const { question, id } = pollHelpers.question({ oneOf });
291
+ const { activity } = helpers.mocks.create(question);
292
+
293
+ await db.sortedSetAdd(`followersRemote:${question.attributedTo}`, Date.now(), uid);
294
+ await activitypub.inbox.create({ body: activity });
295
+
296
+ const pollIds = JSON.parse(await db.getObjectField(`post:${id}`, 'pollIds'));
297
+ const pollId = pollIds[0];
298
+ let pollData = await db.getObject(`poll:${pollId}`);
299
+
300
+ if (allowAnonVoting) {
301
+ pollData.allowAnonVoting = allowAnonVoting;
302
+ await db.setObject(`poll:${pollId}`, pollData);
303
+ }
304
+
305
+ const optionIds = JSON.parse(pollData.options).map(o => o.id);
306
+ return { pollData, id, pollId, optionIds };
307
+ }
308
+
309
+ // Find a sent activity matching the given predicate
310
+ function findSentActivity(predicate) {
311
+ for (const [, { payload }] of activitypub._sent) {
312
+ if (predicate(payload)) return payload;
313
+ }
314
+ return null;
315
+ }
316
+
317
+ it('should allow canVote for remote polls', async () => {
318
+ const { pollId } = await createRemotePollFixture({
319
+ options: ['Option A', 'Option B'],
320
+ remoteVotes: { 1: 3, 2: 5 },
321
+ });
322
+
323
+ const canVote = await Vote.canVote(1, pollId);
324
+ assert.strictEqual(canVote, true, 'should allow voting on remote polls');
325
+ });
326
+
327
+ it('should federate a Create(voteNote) when voting on a remote poll', async () => {
328
+ const { pollId, optionIds } = await createRemotePollFixture({
329
+ options: ['Option A', 'Option B'],
330
+ remoteVotes: { 1: 3, 2: 5 },
331
+ });
332
+
333
+ const socket = { uid: 1 };
334
+ const data = { pollId, options: [optionIds[0]] };
335
+
336
+ await Sockets.vote(socket, data);
337
+
338
+ const sent = findSentActivity(p =>
339
+ p.type === 'Create' &&
340
+ p.object && p.object.type === 'Note' &&
341
+ p.object.name === 'Option A'
342
+ );
343
+ assert(sent, 'should have sent a Create(voteNote) activity');
344
+ assert.strictEqual(sent.object.inReplyTo, sent.object.inReplyTo, 'inReplyTo should be set');
345
+ assert(Array.isArray(sent.object.to), 'to should be an array');
346
+ });
347
+
348
+ it('should not federate anonymous votes', async () => {
349
+ const { pollId, optionIds } = await createRemotePollFixture({
350
+ options: ['Option A', 'Option B'],
351
+ remoteVotes: { 1: 3, 2: 5 },
352
+ allowAnonVoting: 1,
353
+ });
354
+
355
+ const socket = { uid: 1 };
356
+ const data = { pollId, options: [optionIds[0]], voteAnon: true };
357
+
358
+ await Sockets.vote(socket, data);
359
+
360
+ const sent = findSentActivity(p =>
361
+ p.type === 'Create' &&
362
+ p.object && p.object.type === 'Note' &&
363
+ p.object.name === 'Option A'
364
+ );
365
+ assert(!sent, 'should not have sent a vote activity for anonymous votes');
366
+ });
367
+
368
+ it('should display remoteVotes + local votes', async () => {
369
+ const { pollId, optionIds } = await createRemotePollFixture({
370
+ options: ['Option A', 'Option B'],
371
+ remoteVotes: { 1: 3, 2: 5 },
372
+ });
373
+
374
+ // Vote on the poll
375
+ const socket = { uid: 1 };
376
+ const data = { pollId, options: [optionIds[0]] };
377
+ await Sockets.vote(socket, data);
378
+
379
+ // Check the displayed count
380
+ const pollInfo = await Poll.getInfo(pollId);
381
+ const optionA = pollInfo.options.find(o => String(o.id) === optionIds[0]);
382
+ assert.strictEqual(optionA.voteCount, 4, 'should display remoteVotes (3) + local votes (1) = 4');
383
+ });
384
+ });
385
+
386
+ describe('FEP-9967 inbound vote reception (Phase 4)', () => {
387
+ let plugins;
388
+ let Poll;
389
+ let nconf;
390
+
391
+ let topics;
392
+
393
+ before(async () => {
394
+ plugins = nodebb.require('./src/plugins');
395
+ Poll = require('../lib/poll');
396
+ nconf = nodebb.require('nconf');
397
+ topics = nodebb.require('./src/topics');
398
+ });
399
+
400
+ it('should apply a remote vote to a local poll', async () => {
401
+ // Create a local topic and post
402
+ const topic = await topics.create({ title: 'Local Poll Topic', cid: 1, uid: 1 });
403
+ const post = await posts.create({ uid: 1, tid: topic.tid, content: 'Local poll' });
404
+
405
+ // Add a poll to the post
406
+ const savedPolls = await Poll.add(
407
+ { pid: post.pid, uid: 1, timestamp: Date.now() },
408
+ [{
409
+ title: 'Local Poll',
410
+ options: [{ id: 'opt1', title: 'Option A' }, { id: 'opt2', title: 'Option B' }],
411
+ maximumVotesPerUser: 1,
412
+ end: 0,
413
+ }]
414
+ );
415
+ const pollId = savedPolls[0].pollId;
416
+
417
+ // Set pollIds on the post (normally done by filter:post.create)
418
+ await db.setObjectField(`post:${post.pid}`, 'pollIds', JSON.stringify([String(pollId)]));
419
+
420
+ // Get the post's URL (this is the Question id)
421
+ const postUrl = `${nconf.get('url')}/post/${post.pid}`;
422
+
423
+ // Create a Create(Note) activity that represents a vote
424
+ const voteNote = {
425
+ '@context': 'https://www.w3.org/ns/activitystreams',
426
+ id: 'https://remote.example/votes/1',
427
+ type: 'Note',
428
+ attributedTo: 'https://remote.example/users/bob',
429
+ inReplyTo: postUrl,
430
+ name: 'Option A',
431
+ to: [`${nconf.get('url')}/uid/1`],
432
+ };
433
+ const createActivity = {
434
+ '@context': 'https://www.w3.org/ns/activitystreams',
435
+ id: 'https://remote.example/activities/vote-1',
436
+ type: 'Create',
437
+ actor: 'https://remote.example/users/bob',
438
+ object: voteNote,
439
+ };
440
+
441
+ // Fire the filter:activitypub.create hook
442
+ const context = await plugins.hooks.fire('filter:activitypub.create', {
443
+ req: {},
444
+ activity: createActivity,
445
+ claimed: false,
446
+ });
447
+
448
+ assert(context.claimed, 'activity should be claimed');
449
+
450
+ // Check that the vote was applied
451
+ const voters = await db.getSortedSetMembers(`poll:${pollId}:voters`);
452
+ assert(voters.includes('https://remote.example/users/bob'), 'remote voter should be in the voters set');
453
+
454
+ const optionVotes = await db.getSortedSetMembers(`poll:${pollId}:options:opt1:votes`);
455
+ assert(optionVotes.includes('https://remote.example/users/bob'), 'remote voter should be in the option votes set');
456
+ });
457
+
458
+ it('should not apply a vote if the actor already voted (single-choice)', async () => {
459
+ // Create a local topic and post
460
+ const topic = await topics.create({ title: 'Local Poll Topic 2', cid: 1, uid: 1 });
461
+ const post = await posts.create({ uid: 1, tid: topic.tid, content: 'Local poll 2' });
462
+
463
+ // Add a poll to the post
464
+ const savedPolls = await Poll.add(
465
+ { pid: post.pid, uid: 1, timestamp: Date.now() },
466
+ [{
467
+ title: 'Local Poll 2',
468
+ options: [{ id: 'opt1', title: 'Option A' }, { id: 'opt2', title: 'Option B' }],
469
+ maximumVotesPerUser: 1,
470
+ end: 0,
471
+ }]
472
+ );
473
+ const pollId = savedPolls[0].pollId;
474
+ await db.setObjectField(`post:${post.pid}`, 'pollIds', JSON.stringify([String(pollId)]));
475
+ const postUrl = `${nconf.get('url')}/post/${post.pid}`;
476
+
477
+ // First vote
478
+ const voteNote1 = {
479
+ '@context': 'https://www.w3.org/ns/activitystreams',
480
+ id: 'https://remote.example/votes/2',
481
+ type: 'Note',
482
+ attributedTo: 'https://remote.example/users/bob',
483
+ inReplyTo: postUrl,
484
+ name: 'Option A',
485
+ to: [`${nconf.get('url')}/uid/1`],
486
+ };
487
+ const createActivity1 = {
488
+ '@context': 'https://www.w3.org/ns/activitystreams',
489
+ id: 'https://remote.example/activities/vote-2',
490
+ type: 'Create',
491
+ actor: 'https://remote.example/users/bob',
492
+ object: voteNote1,
493
+ };
494
+ await plugins.hooks.fire('filter:activitypub.create', {
495
+ req: {},
496
+ activity: createActivity1,
497
+ claimed: false,
498
+ });
499
+
500
+ // Second vote (same actor, different option — should be rejected for single-choice)
501
+ const voteNote2 = {
502
+ '@context': 'https://www.w3.org/ns/activitystreams',
503
+ id: 'https://remote.example/votes/3',
504
+ type: 'Note',
505
+ attributedTo: 'https://remote.example/users/bob',
506
+ inReplyTo: postUrl,
507
+ name: 'Option B',
508
+ to: [`${nconf.get('url')}/uid/1`],
509
+ };
510
+ const createActivity2 = {
511
+ '@context': 'https://www.w3.org/ns/activitystreams',
512
+ id: 'https://remote.example/activities/vote-3',
513
+ type: 'Create',
514
+ actor: 'https://remote.example/users/bob',
515
+ object: voteNote2,
516
+ };
517
+ const context2 = await plugins.hooks.fire('filter:activitypub.create', {
518
+ req: {},
519
+ activity: createActivity2,
520
+ claimed: false,
521
+ });
522
+
523
+ assert(!context2.claimed, 'second vote should not be claimed (actor already voted)');
524
+
525
+ // Only Option A should have the vote
526
+ const optionBVotes = await db.getSortedSetMembers(`poll:${pollId}:options:opt2:votes`);
527
+ assert(!optionBVotes.includes('https://remote.example/users/bob'), 'Option B should not have the vote');
528
+ });
529
+ });
530
+ });