nodebb-plugin-poll 4.0.14 → 4.1.0
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/.editorconfig +7 -7
- package/LICENSE +20 -20
- package/eslint.config.mjs +7 -0
- package/languages/cs/poll.json +47 -47
- package/languages/de/poll.json +1 -1
- package/languages/en_GB/poll.json +1 -0
- package/languages/en_US/poll.json +52 -52
- package/languages/es/poll.json +39 -39
- package/languages/fr/poll.json +1 -1
- package/languages/gl/poll.json +38 -38
- package/languages/he/poll.json +1 -1
- package/languages/it/poll.json +1 -1
- package/languages/pl/poll.json +1 -1
- package/languages/pt-PT/poll.json +41 -41
- package/languages/ro/poll.json +39 -39
- package/languages/ru/poll.json +1 -1
- package/languages/tr/poll.json +47 -47
- package/lib/config.js +12 -1
- package/lib/hooks.js +231 -14
- package/lib/nodebb.js +0 -0
- package/lib/poll.js +147 -0
- package/lib/sockets.js +117 -12
- package/lib/vote.js +4 -0
- package/library.js +1 -1
- package/package-lock.json +1658 -0
- package/package.json +2 -2
- package/plugin.json +2 -0
- package/public/js/admin.js +0 -0
- package/public/js/poll/creator.js +26 -4
- package/public/js/poll/main.js +19 -8
- package/public/js/poll/view.js +31 -18
- package/templates/admin/plugins/poll.tpl +10 -11
- package/templates/poll/creator.tpl +14 -12
- package/templates/poll/manage.tpl +1 -1
- package/templates/poll/view/messages.tpl +2 -2
- package/templates/poll/view/results.tpl +9 -3
- package/templates/poll/view/voting.tpl +5 -5
- package/templates/poll/view.tpl +8 -8
- package/test/helpers.js +49 -0
- package/test/index.js +530 -0
- package/upgrades/fix_incorrect_poll_privilege.js +15 -15
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
|
+
});
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const db = nodebb.require('./src/database');
|
|
4
|
-
const privileges = nodebb.require('./src/privileges');
|
|
5
|
-
|
|
6
|
-
module.exports = {
|
|
7
|
-
name: 'Fix incorrectly given poll privilege',
|
|
8
|
-
timestamp: Date.UTC(2020, 9, 29),
|
|
9
|
-
method: async () => {
|
|
10
|
-
const cids = await db.getSortedSetRange('categories:cid', 0, -1);
|
|
11
|
-
await Promise.all(cids.map(async (cid) => {
|
|
12
|
-
await privileges.categories.rescind(['poll:create'], cid, ['registered-users']);
|
|
13
|
-
}));
|
|
14
|
-
},
|
|
15
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = nodebb.require('./src/database');
|
|
4
|
+
const privileges = nodebb.require('./src/privileges');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
name: 'Fix incorrectly given poll privilege',
|
|
8
|
+
timestamp: Date.UTC(2020, 9, 29),
|
|
9
|
+
method: async () => {
|
|
10
|
+
const cids = await db.getSortedSetRange('categories:cid', 0, -1);
|
|
11
|
+
await Promise.all(cids.map(async (cid) => {
|
|
12
|
+
await privileges.categories.rescind(['poll:create'], cid, ['registered-users']);
|
|
13
|
+
}));
|
|
14
|
+
},
|
|
15
|
+
};
|