Skip to content

Commit

Permalink
feat: add winner endpoint (#59)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
randomontherun authored Jun 19, 2024
1 parent 51edb7d commit 83f6654
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 68 deletions.
2 changes: 2 additions & 0 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express from "express";
import ProposalsRouter from "./routes/proposals";
import TopicsRouter from "./routes/topics";
import DraftsRouter from "./routes/drafts";
import WinnerRouter from "./routes/winner"
import cors from 'cors';
import { logRequest, clerkAuth } from "./middleware";

Expand All @@ -16,6 +17,7 @@ app.use(clerkAuth);
app.use("/proposals", ProposalsRouter);
app.use("/topics", TopicsRouter);
app.use("/drafts", DraftsRouter);
app.use("/winner", WinnerRouter);
app.use("/health", (req, res) => {
res.status(200).json({ message: "Ok" });
});
Expand Down
16 changes: 16 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {TEST_USER} from "./helpers";

const admins = [
'zenlex@zenlex.dev',
'alec@helmturner.dev',
'niledixon475@gmail.com',
'cryskayecarr@gmail.com'
]

if (process.env.NODE_ENV === 'test') {
admins.push(TEST_USER.userEmail)
}

module.exports = {
admins
};
22 changes: 22 additions & 0 deletions routes/winner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import express from 'express';
import WinnerService from '../services/winner'
import config from '../config'

const router = express.Router();

router.get("/", async (req, res) => {
const { userEmail } = req.user;
const { admins } = config;
if (!admins.includes(userEmail)) {
res.status(401).json({message: 'Unauthorized'})
}
try {
const winner = await WinnerService.getWinner();
return res.status(200).json(winner)
} catch (e) {
console.log(e)
return res.status(500).json({ message: 'Server Error' })
}
})

export default router;
48 changes: 48 additions & 0 deletions services/winner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { getPool } from '../database';
import {sql} from 'slonik';
import {Proposal, ProposalState} from "../types/proposal";

async function getWinner(): Promise<Proposal> {
const pool = await getPool();
return await pool.connect(async (connection) => {
const proposals = await connection.any(sql.type(ProposalState)`
SELECT
p.*,
json_agg(
json_build_object('value', v.vote, 'comment', v.comment)
) FILTER (WHERE v.vote IS NOT NULL) as results
FROM proposals p
JOIN votes v ON p.id = v.proposal_id
WHERE p.status = 'open'
GROUP BY p.id
HAVING SUM(v.vote) = (
SELECT MAX(vote_sum)
FROM (
SELECT SUM(v.vote) as vote_sum
FROM proposals p
JOIN votes v ON p.id = v.proposal_id
WHERE p.status = 'open'
GROUP BY p.id
) as subquery
)
`);
if (proposals.length === 0) {
throw new Error('No open proposals found');
}
const randomIndex = Math.floor(Math.random() * proposals.length);
const chosenProposal = proposals[randomIndex];
const updatedProposal = await connection.one(sql.type(Proposal)`
UPDATE proposals
SET status = 'closed'
WHERE id = ${chosenProposal.id}
RETURNING *
`);

return {
...updatedProposal,
results: chosenProposal.results,
};
});
}

export default { getWinner };
63 changes: 0 additions & 63 deletions tests/drafts.test.ts

This file was deleted.

6 changes: 3 additions & 3 deletions tests/helpers/seedDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ export function seedDatabase({ numUsers = 50, numAuthors = 5, seed = 1 }: SeedOp
}
}

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

await VotesService.store(
VotesService.factory(),
VotesService.factory({value: value}),
proposalId,
userEmail
)
Expand All @@ -89,6 +89,6 @@ export function seedDatabase({ numUsers = 50, numAuthors = 5, seed = 1 }: SeedOp
addVotesForProposal,
addUserVote,
users,
authors
authors,
};
}
87 changes: 85 additions & 2 deletions tests/proposals.test.ts → tests/suite.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { it, describe, expect } from "vitest";
import { TEST_SERVER_URL } from "./global.setup";
import { resetDatabase } from "../database";
import {PendingProposal, Proposal} from "../types/proposal";
import {Proposal} from "../types/proposal";
import { seedDatabase } from "./helpers/seedDatabase";
import { TEST_USER } from "../helpers";
import assertDatabaseHas from './helpers/assertDatabaseHas';
import ProposalsService from '../services/proposals';
import DraftsService from "../services/drafts";

const seedDb = seedDatabase();


describe('test suite works', () => {
it("should hit health check", async () => {
const res = await fetch(`${TEST_SERVER_URL}/health`);
expect(res.status).toEqual(200);
});
})

/****************************************
* PROPOSALS
****************************************/

describe("Proposals API", () => {
it("returns 404 on no proposals", async () => {
await resetDatabase();
Expand Down Expand Up @@ -145,3 +157,74 @@ describe("Proposals API", () => {
});
});
});

/****************************************
* DRAFTS
****************************************/

describe('smoke tests', () => {
it('index route 404 on empty db', async () => {
await resetDatabase();
const res = await fetch(`${TEST_SERVER_URL}/drafts`);
const data = await res.json();
expect(res.status).toEqual(404);
})

it('store route', async () => {
const res = await fetch(`${TEST_SERVER_URL}/drafts/`, {
method: 'POST',
body: JSON.stringify({})
});
expect(res.status).toEqual(201);
})
})

describe('factory and count services', () => {
it('should create and count drafts', async () => {
await resetDatabase()
const email = 'test@example.com';
const customTitle = 'Custom Title';
const draftData = DraftsService.factory({ title: customTitle });
await DraftsService.store(draftData, email);
const count = await DraftsService.count();
expect(count).toBeGreaterThan(0);
await assertDatabaseHas("drafts", { title: customTitle });
});
});

/****************************************
* WINNER
****************************************/

describe("winner endpoint", () => {
it("should return leading proposal and mark closed", async () => {
await resetDatabase();
const proposals = await seedDb.addProposals(2);
const winningProposal = proposals[0];
await seedDb.addUserVote(winningProposal.id, undefined, 2);
await seedDb.addUserVote(proposals[1].id, undefined, -2)
const res = await fetch(`${TEST_SERVER_URL}/winner`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.id).toEqual(winningProposal.id);
expect(data.status).toBe('closed');
})
})

describe("winner endpoint - tied proposals", () => {
it("should return a random winner in case of tie", async () => {
await resetDatabase();
const proposals = await seedDb.addProposals(3);
for (const proposal of proposals) {
await seedDb.addUserVote(proposal.id, undefined, 2)
}
const res = await fetch(`${TEST_SERVER_URL}/winner`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.status).toBe('closed');
const remainingProposals = proposals.filter(p => p.id !== data.id);
for (const proposal of remainingProposals) {
expect(proposal.status).toBe('open');
}
})
})

0 comments on commit 83f6654

Please sign in to comment.