Skip to content

Commit 83f6654

Browse files
feat: add winner endpoint (#59)
* feat: add winner to routes and services * chore(tests): made tests synchronous and added winner endpoint test * chore(tests): handle tie edge case * fix(winner): adjust query to sum votes * chore(winner): add admin list restriction * chore(winner): add results to response * fix: remove test user from default admin list
1 parent 51edb7d commit 83f6654

7 files changed

Lines changed: 176 additions & 68 deletions

File tree

app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import express from "express";
33
import ProposalsRouter from "./routes/proposals";
44
import TopicsRouter from "./routes/topics";
55
import DraftsRouter from "./routes/drafts";
6+
import WinnerRouter from "./routes/winner"
67
import cors from 'cors';
78
import { logRequest, clerkAuth } from "./middleware";
89

@@ -16,6 +17,7 @@ app.use(clerkAuth);
1617
app.use("/proposals", ProposalsRouter);
1718
app.use("/topics", TopicsRouter);
1819
app.use("/drafts", DraftsRouter);
20+
app.use("/winner", WinnerRouter);
1921
app.use("/health", (req, res) => {
2022
res.status(200).json({ message: "Ok" });
2123
});

config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {TEST_USER} from "./helpers";
2+
3+
const admins = [
4+
'zenlex@zenlex.dev',
5+
'alec@helmturner.dev',
6+
'niledixon475@gmail.com',
7+
'cryskayecarr@gmail.com'
8+
]
9+
10+
if (process.env.NODE_ENV === 'test') {
11+
admins.push(TEST_USER.userEmail)
12+
}
13+
14+
module.exports = {
15+
admins
16+
};

routes/winner.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import express from 'express';
2+
import WinnerService from '../services/winner'
3+
import config from '../config'
4+
5+
const router = express.Router();
6+
7+
router.get("/", async (req, res) => {
8+
const { userEmail } = req.user;
9+
const { admins } = config;
10+
if (!admins.includes(userEmail)) {
11+
res.status(401).json({message: 'Unauthorized'})
12+
}
13+
try {
14+
const winner = await WinnerService.getWinner();
15+
return res.status(200).json(winner)
16+
} catch (e) {
17+
console.log(e)
18+
return res.status(500).json({ message: 'Server Error' })
19+
}
20+
})
21+
22+
export default router;

services/winner.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { getPool } from '../database';
2+
import {sql} from 'slonik';
3+
import {Proposal, ProposalState} from "../types/proposal";
4+
5+
async function getWinner(): Promise<Proposal> {
6+
const pool = await getPool();
7+
return await pool.connect(async (connection) => {
8+
const proposals = await connection.any(sql.type(ProposalState)`
9+
SELECT
10+
p.*,
11+
json_agg(
12+
json_build_object('value', v.vote, 'comment', v.comment)
13+
) FILTER (WHERE v.vote IS NOT NULL) as results
14+
FROM proposals p
15+
JOIN votes v ON p.id = v.proposal_id
16+
WHERE p.status = 'open'
17+
GROUP BY p.id
18+
HAVING SUM(v.vote) = (
19+
SELECT MAX(vote_sum)
20+
FROM (
21+
SELECT SUM(v.vote) as vote_sum
22+
FROM proposals p
23+
JOIN votes v ON p.id = v.proposal_id
24+
WHERE p.status = 'open'
25+
GROUP BY p.id
26+
) as subquery
27+
)
28+
`);
29+
if (proposals.length === 0) {
30+
throw new Error('No open proposals found');
31+
}
32+
const randomIndex = Math.floor(Math.random() * proposals.length);
33+
const chosenProposal = proposals[randomIndex];
34+
const updatedProposal = await connection.one(sql.type(Proposal)`
35+
UPDATE proposals
36+
SET status = 'closed'
37+
WHERE id = ${chosenProposal.id}
38+
RETURNING *
39+
`);
40+
41+
return {
42+
...updatedProposal,
43+
results: chosenProposal.results,
44+
};
45+
});
46+
}
47+
48+
export default { getWinner };

tests/drafts.test.ts

Lines changed: 0 additions & 63 deletions
This file was deleted.

tests/helpers/seedDatabase.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ export function seedDatabase({ numUsers = 50, numAuthors = 5, seed = 1 }: SeedOp
7474
}
7575
}
7676

77-
async function addUserVote(proposalId: number, userEmail?: string) {
77+
async function addUserVote(proposalId: number, userEmail?: string, value: number = 1) {
7878
userEmail = userEmail || (faker.helpers.arrayElement(users)).email;
7979

8080
await VotesService.store(
81-
VotesService.factory(),
81+
VotesService.factory({value: value}),
8282
proposalId,
8383
userEmail
8484
)
@@ -89,6 +89,6 @@ export function seedDatabase({ numUsers = 50, numAuthors = 5, seed = 1 }: SeedOp
8989
addVotesForProposal,
9090
addUserVote,
9191
users,
92-
authors
92+
authors,
9393
};
9494
}
Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
import { it, describe, expect } from "vitest";
22
import { TEST_SERVER_URL } from "./global.setup";
33
import { resetDatabase } from "../database";
4-
import {PendingProposal, Proposal} from "../types/proposal";
4+
import {Proposal} from "../types/proposal";
55
import { seedDatabase } from "./helpers/seedDatabase";
66
import { TEST_USER } from "../helpers";
77
import assertDatabaseHas from './helpers/assertDatabaseHas';
8-
import ProposalsService from '../services/proposals';
8+
import DraftsService from "../services/drafts";
99

1010
const seedDb = seedDatabase();
1111

12+
13+
describe('test suite works', () => {
14+
it("should hit health check", async () => {
15+
const res = await fetch(`${TEST_SERVER_URL}/health`);
16+
expect(res.status).toEqual(200);
17+
});
18+
})
19+
20+
/****************************************
21+
* PROPOSALS
22+
****************************************/
23+
1224
describe("Proposals API", () => {
1325
it("returns 404 on no proposals", async () => {
1426
await resetDatabase();
@@ -145,3 +157,74 @@ describe("Proposals API", () => {
145157
});
146158
});
147159
});
160+
161+
/****************************************
162+
* DRAFTS
163+
****************************************/
164+
165+
describe('smoke tests', () => {
166+
it('index route 404 on empty db', async () => {
167+
await resetDatabase();
168+
const res = await fetch(`${TEST_SERVER_URL}/drafts`);
169+
const data = await res.json();
170+
expect(res.status).toEqual(404);
171+
})
172+
173+
it('store route', async () => {
174+
const res = await fetch(`${TEST_SERVER_URL}/drafts/`, {
175+
method: 'POST',
176+
body: JSON.stringify({})
177+
});
178+
expect(res.status).toEqual(201);
179+
})
180+
})
181+
182+
describe('factory and count services', () => {
183+
it('should create and count drafts', async () => {
184+
await resetDatabase()
185+
const email = 'test@example.com';
186+
const customTitle = 'Custom Title';
187+
const draftData = DraftsService.factory({ title: customTitle });
188+
await DraftsService.store(draftData, email);
189+
const count = await DraftsService.count();
190+
expect(count).toBeGreaterThan(0);
191+
await assertDatabaseHas("drafts", { title: customTitle });
192+
});
193+
});
194+
195+
/****************************************
196+
* WINNER
197+
****************************************/
198+
199+
describe("winner endpoint", () => {
200+
it("should return leading proposal and mark closed", async () => {
201+
await resetDatabase();
202+
const proposals = await seedDb.addProposals(2);
203+
const winningProposal = proposals[0];
204+
await seedDb.addUserVote(winningProposal.id, undefined, 2);
205+
await seedDb.addUserVote(proposals[1].id, undefined, -2)
206+
const res = await fetch(`${TEST_SERVER_URL}/winner`);
207+
expect(res.status).toBe(200);
208+
const data = await res.json();
209+
expect(data.id).toEqual(winningProposal.id);
210+
expect(data.status).toBe('closed');
211+
})
212+
})
213+
214+
describe("winner endpoint - tied proposals", () => {
215+
it("should return a random winner in case of tie", async () => {
216+
await resetDatabase();
217+
const proposals = await seedDb.addProposals(3);
218+
for (const proposal of proposals) {
219+
await seedDb.addUserVote(proposal.id, undefined, 2)
220+
}
221+
const res = await fetch(`${TEST_SERVER_URL}/winner`);
222+
expect(res.status).toBe(200);
223+
const data = await res.json();
224+
expect(data.status).toBe('closed');
225+
const remainingProposals = proposals.filter(p => p.id !== data.id);
226+
for (const proposal of remainingProposals) {
227+
expect(proposal.status).toBe('open');
228+
}
229+
})
230+
})

0 commit comments

Comments
 (0)