Skip to content

Commit 941eca0

Browse files
authored
Merge pull request #48 from CMU-17313Q/feat/polls-vote-api
Feat/polls vote api issue 4
2 parents 4ca8d6a + da74e4b commit 941eca0

File tree

9 files changed

+310
-2
lines changed

9 files changed

+310
-2
lines changed

public/openapi/write.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ paths:
184184
$ref: 'write/posts/pid/move.yaml'
185185
/posts/{pid}/vote:
186186
$ref: 'write/posts/pid/vote.yaml'
187+
/posts/{pid}/poll/vote:
188+
$ref: 'write/posts/pid/poll/vote.yaml'
187189
/posts/{pid}/voters:
188190
$ref: 'write/posts/pid/voters.yaml'
189191
/posts/{pid}/upvoters:
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
post:
2+
tags:
3+
- posts
4+
- polls
5+
summary: Submit a vote to a poll
6+
description: This operation casts a vote on a poll attached to a post. Users can only vote once per poll.
7+
parameters:
8+
- in: path
9+
name: pid
10+
schema:
11+
type: string
12+
required: true
13+
description: a valid post id that has an attached poll
14+
example: 2
15+
requestBody:
16+
required: true
17+
content:
18+
application/json:
19+
schema:
20+
type: object
21+
properties:
22+
optionIndex:
23+
type: number
24+
description: Zero-based index of the poll option to vote for
25+
example: 0
26+
required:
27+
- optionIndex
28+
responses:
29+
'200':
30+
description: Vote successfully cast
31+
content:
32+
application/json:
33+
schema:
34+
type: object
35+
properties:
36+
status:
37+
$ref: ../../../../components/schemas/Status.yaml#/Status
38+
response:
39+
type: object
40+
properties:
41+
pollId:
42+
type: string
43+
description: Unique identifier of the poll
44+
optionIndex:
45+
type: number
46+
description: Index of the voted option
47+
counts:
48+
type: object
49+
description: Updated vote counts for all poll options
50+
additionalProperties:
51+
type: number
52+
voted:
53+
type: boolean
54+
description: Confirmation that the vote was recorded
55+
'400':
56+
$ref: ../../../../components/responses/400.yaml#/400
57+
'401':
58+
$ref: ../../../../components/responses/401.yaml#/401
59+
'403':
60+
$ref: ../../../../components/responses/403.yaml#/403
61+
'404':
62+
$ref: ../../../../components/responses/404.yaml#/404

src/api/posts.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,52 @@ const notifications = require('../notifications');
2222

2323
const postsAPI = module.exports;
2424

25+
postsAPI.submitPollVote = async function (caller, data) {
26+
if (!caller.uid) {
27+
throw new Error('[[error:not-logged-in]]');
28+
}
29+
30+
if (!data || !data.pid || typeof data.optionIndex !== 'number') {
31+
throw new Error('[[error:invalid-data]]');
32+
}
33+
34+
const { pid, optionIndex } = data;
35+
36+
// Get the poll for this post
37+
const Polls = require('../polls/model');
38+
const Votes = require('../polls/votes');
39+
40+
const poll = await Polls.getPollByPostId(pid);
41+
if (!poll) {
42+
throw new Error('[[error:no-poll-found]]');
43+
}
44+
45+
// Check if user already voted
46+
const { pollId } = poll;
47+
const userId = caller.uid;
48+
49+
try {
50+
// Record the vote (this will throw if user already voted)
51+
await Votes.recordVote({ pollId, userId, optionIndex });
52+
53+
// Get updated counts
54+
const counts = await Votes.getCounts(pollId);
55+
56+
// Return the updated poll information
57+
return {
58+
pollId,
59+
optionIndex,
60+
counts,
61+
voted: true,
62+
};
63+
} catch (err) {
64+
if (err.code === 'ALREADY_VOTED') {
65+
throw new Error('[[error:already-voted]]');
66+
}
67+
throw err;
68+
}
69+
};
70+
2571
postsAPI.get = async function (caller, data) {
2672
const [userPrivileges, post, voted] = await Promise.all([
2773
privileges.posts.get([data.pid], caller.uid),

src/controllers/write/posts.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,9 @@ Posts.notifyQueuedPostOwner = async (req, res) => {
209209
const { id } = req.params;
210210
await api.posts.notifyQueuedPostOwner(req, { id, message: req.body.message });
211211
helpers.formatApiResponse(200, res);
212+
};
213+
214+
Posts.submitPollVote = async (req, res) => {
215+
const result = await api.posts.submitPollVote(req, { pid: req.params.pid, optionIndex: req.body.optionIndex });
216+
helpers.formatApiResponse(200, res, result);
212217
};

src/polls/model.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22

3-
const db = require.main.require('./src/database');
3+
const db = require('../database');
44
const { v4: uuid } = require('uuid');
55

66
const PollModel = {

src/polls/votes.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22

3-
const db = require.main.require('./src/database');
3+
const db = require('../database');
44

55
const Votes = {
66
/**

src/routes/write/posts.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module.exports = function () {
2626

2727
setupApiRoute(router, 'put', '/:pid/vote', [...middlewares, middleware.checkRequired.bind(null, ['delta'])], controllers.write.posts.vote);
2828
setupApiRoute(router, 'delete', '/:pid/vote', middlewares, controllers.write.posts.unvote);
29+
setupApiRoute(router, 'post', '/:pid/poll/vote', [...middlewares, middleware.checkRequired.bind(null, ['optionIndex'])], controllers.write.posts.submitPollVote);
2930
setupApiRoute(router, 'get', '/:pid/voters', [middleware.assert.post], controllers.write.posts.getVoters);
3031
setupApiRoute(router, 'get', '/:pid/upvoters', [middleware.assert.post], controllers.write.posts.getUpvoters);
3132

test/poll-vote-api-direct.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use strict';
2+
3+
// Prime NodeBB require paths + use the mock DB (so this test doesn't hit real Redis)
4+
require('../require-main');
5+
require('./mocks/databasemock');
6+
7+
const assert = require('assert');
8+
const Polls = require('../src/polls/model');
9+
const apiPosts = require('../src/api/posts');
10+
11+
describe('Poll Vote API Function (Issue 4)', function () {
12+
let pollId;
13+
let testPid;
14+
const testUid = 123;
15+
16+
before(async function () {
17+
// Create a test poll
18+
const poll = await Polls.createPoll({
19+
postId: 'post-456',
20+
question: 'API Test Poll Question',
21+
options: ['Option A', 'Option B', 'Option C'],
22+
createdBy: 'admin',
23+
});
24+
pollId = poll.pollId;
25+
testPid = 'post-456';
26+
});
27+
28+
it('should allow a user to vote via the API function', async function () {
29+
const caller = {
30+
uid: testUid,
31+
ip: '127.0.0.1',
32+
};
33+
34+
const data = {
35+
pid: testPid,
36+
optionIndex: 1,
37+
};
38+
39+
const result = await apiPosts.submitPollVote(caller, data);
40+
41+
// Check the response
42+
assert.ok(result);
43+
assert.strictEqual(result.optionIndex, 1);
44+
assert.strictEqual(result.voted, true);
45+
assert.ok(result.pollId);
46+
assert.strictEqual(result.counts['1'], 1);
47+
});
48+
49+
it('should prevent double voting via the API function', async function () {
50+
const caller = {
51+
uid: testUid,
52+
ip: '127.0.0.1',
53+
};
54+
55+
const data = {
56+
pid: testPid,
57+
optionIndex: 2, // Try to vote for a different option
58+
};
59+
60+
let err;
61+
try {
62+
await apiPosts.submitPollVote(caller, data);
63+
} catch (e) {
64+
err = e;
65+
}
66+
67+
assert.ok(err, 'Expected error for double voting');
68+
assert.ok(err.message.includes('already-voted'), `Expected "already-voted" error, got: ${err.message}`);
69+
});
70+
71+
it('should reject unauthenticated users', async function () {
72+
const caller = {
73+
uid: 0, // Unauthenticated user
74+
ip: '127.0.0.1',
75+
};
76+
77+
const data = {
78+
pid: testPid,
79+
optionIndex: 0,
80+
};
81+
82+
let err;
83+
try {
84+
await apiPosts.submitPollVote(caller, data);
85+
} catch (e) {
86+
err = e;
87+
}
88+
89+
assert.ok(err, 'Expected error for unauthenticated user');
90+
assert.ok(err.message.includes('not-logged-in'), `Expected "not-logged-in" error, got: ${err.message}`);
91+
});
92+
});

test/poll-vote-api.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict';
2+
3+
// Prime NodeBB require paths + use the mock DB (so this test doesn't hit real Redis)
4+
require('../require-main');
5+
require('./mocks/databasemock');
6+
7+
const assert = require('assert');
8+
const Polls = require('../src/polls/model');
9+
const Votes = require('../src/polls/votes');
10+
11+
describe('Poll Vote API (Issue 4)', function () {
12+
let pollId;
13+
const testUid = 'user123';
14+
const testPid = 'post456';
15+
16+
before(async function () {
17+
// Create a test poll attached to a post
18+
const poll = await Polls.createPoll({
19+
postId: testPid,
20+
question: 'Test Poll Question',
21+
options: ['Option A', 'Option B', 'Option C'],
22+
createdBy: 'admin',
23+
});
24+
pollId = poll.pollId;
25+
});
26+
27+
it('should allow a user to vote on a poll', async function () {
28+
// Test the core voting logic
29+
const result = await Votes.recordVote({ pollId, userId: testUid, optionIndex: 0 });
30+
assert.strictEqual(result, true);
31+
32+
// Verify the vote was recorded
33+
const hasVoted = await Votes.hasUserVoted(pollId, testUid);
34+
assert.strictEqual(hasVoted, true);
35+
36+
// Check the user's choice
37+
const choice = await Votes.getUserChoice(pollId, testUid);
38+
assert.strictEqual(choice, 0);
39+
40+
// Check vote counts
41+
const counts = await Votes.getCounts(pollId);
42+
assert.strictEqual(counts[0], 1);
43+
assert.strictEqual(counts[1] || 0, 0);
44+
assert.strictEqual(counts[2] || 0, 0);
45+
});
46+
47+
it('should prevent double voting by the same user', async function () {
48+
let err;
49+
try {
50+
await Votes.recordVote({ pollId, userId: testUid, optionIndex: 1 });
51+
} catch (e) { err = e; }
52+
assert.ok(err, 'Expected an error for double voting');
53+
assert.strictEqual(err.code, 'ALREADY_VOTED');
54+
});
55+
56+
it('should handle multiple users voting on different options', async function () {
57+
const anotherUser = 'user456';
58+
const thirdUser = 'user789';
59+
60+
// Second user votes for option 1
61+
const result1 = await Votes.recordVote({ pollId, userId: anotherUser, optionIndex: 1 });
62+
assert.strictEqual(result1, true);
63+
64+
// Third user votes for option 2
65+
const result2 = await Votes.recordVote({ pollId, userId: thirdUser, optionIndex: 2 });
66+
assert.strictEqual(result2, true);
67+
68+
// Check final vote counts
69+
const counts = await Votes.getCounts(pollId);
70+
assert.strictEqual(counts[0], 1); // First user's vote
71+
assert.strictEqual(counts[1], 1); // Second user's vote
72+
assert.strictEqual(counts[2], 1); // Third user's vote
73+
});
74+
75+
it('should validate vote parameters', async function () {
76+
// Test missing pollId
77+
let err;
78+
try {
79+
await Votes.recordVote({ userId: 'test', optionIndex: 0 });
80+
} catch (e) { err = e; }
81+
assert.ok(err, 'Expected an error for missing pollId');
82+
assert.strictEqual(err.code, 'BAD_INPUT');
83+
84+
// Test missing userId
85+
err = null;
86+
try {
87+
await Votes.recordVote({ pollId, optionIndex: 0 });
88+
} catch (e) { err = e; }
89+
assert.ok(err, 'Expected an error for missing userId');
90+
assert.strictEqual(err.code, 'BAD_INPUT');
91+
92+
// Test invalid optionIndex
93+
err = null;
94+
try {
95+
await Votes.recordVote({ pollId, userId: 'newuser', optionIndex: 'invalid' });
96+
} catch (e) { err = e; }
97+
assert.ok(err, 'Expected an error for invalid optionIndex');
98+
assert.strictEqual(err.code, 'BAD_INPUT');
99+
});
100+
});

0 commit comments

Comments
 (0)