feat: nová stránka pro návrhy na vylepšení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
|
||||
]
|
||||
+2
-2
@@ -14,7 +14,7 @@ import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { storageReady } from "./storage";
|
||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||
import votingRoutes from "./routes/votingRoutes";
|
||||
import suggestionRoutes from "./routes/suggestionRoutes";
|
||||
import easterEggRoutes from "./routes/easterEggRoutes";
|
||||
import statsRoutes from "./routes/statsRoutes";
|
||||
import notificationRoutes from "./routes/notificationRoutes";
|
||||
@@ -199,7 +199,7 @@ app.get("/api/data", async (req, res) => {
|
||||
// Ostatní routes
|
||||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||||
app.use("/api/food", foodRoutes);
|
||||
app.use("/api/voting", votingRoutes);
|
||||
app.use("/api/suggestions", suggestionRoutes);
|
||||
app.use("/api/easterEggs", easterEggRoutes);
|
||||
app.use("/api/stats", statsRoutes);
|
||||
app.use("/api/notifications", notificationRoutes);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from "../suggestions";
|
||||
import { AddSuggestionData, VoteSuggestionData, DeleteSuggestionData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/list", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await listSuggestions(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/add", async (req: Request<{}, any, AddSuggestionData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body?.title || !req.body?.description) {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await addSuggestion(login, req.body.title, req.body.description);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/vote", async (req: Request<{}, any, VoteSuggestionData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body?.id || !req.body?.direction) {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await voteSuggestion(login, req.body.id, req.body.direction);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/delete", async (req: Request<{}, any, DeleteSuggestionData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body?.id) {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await deleteSuggestion(login, req.body.id);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,33 +0,0 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
|
||||
import { GetVotesData, UpdateVoteData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await getUserVotes(login);
|
||||
res.status(200).json(data);
|
||||
});
|
||||
|
||||
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (req.body?.option == null || req.body?.active == null) {
|
||||
res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
try {
|
||||
const data = await updateFeatureVote(login, req.body.option, req.body.active);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.get("/stats", async (req, res, next) => {
|
||||
try {
|
||||
const data = await getVotingStats();
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,142 @@
|
||||
import crypto from "crypto";
|
||||
import { Suggestion, VoteDirection } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
|
||||
/** Interní reprezentace návrhu uložená ve storage (včetně seznamů hlasujících). */
|
||||
interface StoredSuggestion {
|
||||
id: string;
|
||||
author: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
/** Loginy uživatelů hlasujících PRO návrh */
|
||||
upvoters: string[];
|
||||
/** Loginy uživatelů hlasujících PROTI návrhu */
|
||||
downvoters: string[];
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const STORAGE_KEY = 'suggestions';
|
||||
|
||||
/** Načte interní seznam návrhů ze storage. */
|
||||
async function loadSuggestions(): Promise<StoredSuggestion[]> {
|
||||
return (await storage.getData<StoredSuggestion[]>(STORAGE_KEY)) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Převede interní návrh na DTO pro daného uživatele - skryje seznamy hlasujících
|
||||
* a doplní hlas přihlášeného uživatele a příznak vlastnictví.
|
||||
*/
|
||||
function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
|
||||
let myVote: VoteDirection | undefined;
|
||||
if (suggestion.upvoters.includes(login)) {
|
||||
myVote = 'up';
|
||||
} else if (suggestion.downvoters.includes(login)) {
|
||||
myVote = 'down';
|
||||
}
|
||||
return {
|
||||
id: suggestion.id,
|
||||
author: suggestion.author,
|
||||
title: suggestion.title,
|
||||
description: suggestion.description,
|
||||
voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
|
||||
myVote,
|
||||
isMine: suggestion.author === login,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí seznam návrhů jako DTO pro daného uživatele, seřazený sestupně dle skóre
|
||||
* (při shodě skóre stabilně dle data vytvoření vzestupně).
|
||||
*
|
||||
* @param login login přihlášeného uživatele
|
||||
*/
|
||||
export async function listSuggestions(login: string): Promise<Suggestion[]> {
|
||||
const suggestions = await loadSuggestions();
|
||||
return suggestions
|
||||
.map(s => toDto(s, login))
|
||||
.sort((a, b) => b.voteScore - a.voteScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Přidá nový návrh. Autorovi se automaticky nastaví hlas pro.
|
||||
*
|
||||
* @param login login autora
|
||||
* @param title název návrhu
|
||||
* @param description detailní popis návrhu
|
||||
* @returns aktualizovaný seznam návrhů jako DTO
|
||||
*/
|
||||
export async function addSuggestion(login: string, title: string, description: string): Promise<Suggestion[]> {
|
||||
const trimmedTitle = title?.trim();
|
||||
const trimmedDescription = description?.trim();
|
||||
if (!trimmedTitle) {
|
||||
throw new Error('Název návrhu nesmí být prázdný');
|
||||
}
|
||||
if (!trimmedDescription) {
|
||||
throw new Error('Popis návrhu nesmí být prázdný');
|
||||
}
|
||||
const suggestions = await loadSuggestions();
|
||||
suggestions.push({
|
||||
id: crypto.randomUUID(),
|
||||
author: login,
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription,
|
||||
createdAt: new Date().toISOString(),
|
||||
// Autor automaticky hlasuje pro svůj návrh
|
||||
upvoters: [login],
|
||||
downvoters: [],
|
||||
});
|
||||
await storage.setData(STORAGE_KEY, suggestions);
|
||||
return listSuggestions(login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Přepne hlas uživatele u návrhu. Klik na již aktivní směr hlas zruší,
|
||||
* opačný směr stávající hlas přepíše.
|
||||
*
|
||||
* @param login login hlasujícího uživatele
|
||||
* @param id identifikátor návrhu
|
||||
* @param direction směr hlasu, na který uživatel klikl
|
||||
* @returns aktualizovaný seznam návrhů jako DTO
|
||||
*/
|
||||
export async function voteSuggestion(login: string, id: string, direction: VoteDirection): Promise<Suggestion[]> {
|
||||
const suggestions = await loadSuggestions();
|
||||
const suggestion = suggestions.find(s => s.id === id);
|
||||
if (!suggestion) {
|
||||
throw new Error('Návrh nebyl nalezen');
|
||||
}
|
||||
const hadUp = suggestion.upvoters.includes(login);
|
||||
const hadDown = suggestion.downvoters.includes(login);
|
||||
// Nejprve odebereme případný stávající hlas uživatele
|
||||
suggestion.upvoters = suggestion.upvoters.filter(l => l !== login);
|
||||
suggestion.downvoters = suggestion.downvoters.filter(l => l !== login);
|
||||
// Hlas přidáme pouze pokud uživatel neklikl na již aktivní směr (jinak ho jen zrušíme)
|
||||
if (direction === 'up' && !hadUp) {
|
||||
suggestion.upvoters.push(login);
|
||||
} else if (direction === 'down' && !hadDown) {
|
||||
suggestion.downvoters.push(login);
|
||||
}
|
||||
await storage.setData(STORAGE_KEY, suggestions);
|
||||
return listSuggestions(login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh.
|
||||
*
|
||||
* @param login login uživatele požadujícího smazání
|
||||
* @param id identifikátor návrhu ke smazání
|
||||
* @returns aktualizovaný seznam návrhů jako DTO
|
||||
*/
|
||||
export async function deleteSuggestion(login: string, id: string): Promise<Suggestion[]> {
|
||||
const suggestions = await loadSuggestions();
|
||||
const suggestion = suggestions.find(s => s.id === id);
|
||||
if (!suggestion) {
|
||||
throw new Error('Návrh nebyl nalezen');
|
||||
}
|
||||
if (suggestion.author !== login) {
|
||||
throw new Error('Smazat lze pouze vlastní návrh');
|
||||
}
|
||||
const filtered = suggestions.filter(s => s.id !== id);
|
||||
await storage.setData(STORAGE_KEY, filtered);
|
||||
return listSuggestions(login);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions';
|
||||
|
||||
const AUTHOR = 'tomas';
|
||||
const VOTER = 'petr';
|
||||
const OTHER = 'jana';
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
/** Pomocná funkce: vytvoří návrh a vrátí jeho id (z pohledu autora). */
|
||||
async function createSuggestion(author = AUTHOR, title = 'Tmavý režim', description = 'Přidat tmavý režim aplikace') {
|
||||
const list = await addSuggestion(author, title, description);
|
||||
return list.find(s => s.title === title)!.id;
|
||||
}
|
||||
|
||||
describe('addSuggestion', () => {
|
||||
test('přidá návrh a autorovi nastaví hlas pro', async () => {
|
||||
const list = await addSuggestion(AUTHOR, 'Tmavý režim', 'Popis');
|
||||
expect(list).toHaveLength(1);
|
||||
const s = list[0];
|
||||
expect(s.author).toBe(AUTHOR);
|
||||
expect(s.title).toBe('Tmavý režim');
|
||||
expect(s.description).toBe('Popis');
|
||||
expect(s.voteScore).toBe(1);
|
||||
expect(s.myVote).toBe('up');
|
||||
expect(s.isMine).toBe(true);
|
||||
});
|
||||
|
||||
test('ořízne mezery a odmítne prázdný název i popis', async () => {
|
||||
await expect(addSuggestion(AUTHOR, ' ', 'popis')).rejects.toThrow();
|
||||
await expect(addSuggestion(AUTHOR, 'název', ' ')).rejects.toThrow();
|
||||
const list = await addSuggestion(AUTHOR, ' Název ', ' Popis ');
|
||||
expect(list[0].title).toBe('Název');
|
||||
expect(list[0].description).toBe('Popis');
|
||||
});
|
||||
|
||||
test('jeden uživatel může přidat více návrhů', async () => {
|
||||
await addSuggestion(AUTHOR, 'První', 'popis');
|
||||
const list = await addSuggestion(AUTHOR, 'Druhý', 'popis');
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSuggestions', () => {
|
||||
test('řadí sestupně dle skóre', async () => {
|
||||
const lowId = await createSuggestion(AUTHOR, 'Nízké', 'popis');
|
||||
const highId = await createSuggestion(OTHER, 'Vysoké', 'popis');
|
||||
// "Vysoké" dostane další hlas pro
|
||||
await voteSuggestion(VOTER, highId, 'up');
|
||||
|
||||
const list = await listSuggestions(VOTER);
|
||||
expect(list[0].id).toBe(highId);
|
||||
expect(list[0].voteScore).toBe(2);
|
||||
expect(list[1].id).toBe(lowId);
|
||||
});
|
||||
|
||||
test('myVote a isMine jsou relativní k uživateli', async () => {
|
||||
const id = await createSuggestion(AUTHOR);
|
||||
const asAuthor = (await listSuggestions(AUTHOR))[0];
|
||||
expect(asAuthor.isMine).toBe(true);
|
||||
expect(asAuthor.myVote).toBe('up');
|
||||
|
||||
const asOther = (await listSuggestions(VOTER))[0];
|
||||
expect(asOther.isMine).toBe(false);
|
||||
expect(asOther.myVote).toBeUndefined();
|
||||
// Seznamy hlasujících se klientovi neposílají
|
||||
expect((asOther as any).upvoters).toBeUndefined();
|
||||
expect(id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('voteSuggestion', () => {
|
||||
test('hlas pro a proti od jiného uživatele', async () => {
|
||||
const id = await createSuggestion();
|
||||
let list = await voteSuggestion(VOTER, id, 'up');
|
||||
expect(list[0].voteScore).toBe(2);
|
||||
expect(list.find(s => s.id === id)!.voteScore).toBe(2);
|
||||
|
||||
// přehlasování z pro na proti
|
||||
list = await voteSuggestion(VOTER, id, 'down');
|
||||
// autor +1, voter -1 => 0
|
||||
expect(list[0].voteScore).toBe(0);
|
||||
expect((await listSuggestions(VOTER))[0].myVote).toBe('down');
|
||||
});
|
||||
|
||||
test('klik na aktivní směr hlas zruší', async () => {
|
||||
const id = await createSuggestion();
|
||||
await voteSuggestion(VOTER, id, 'up');
|
||||
const list = await voteSuggestion(VOTER, id, 'up');
|
||||
// zůstává jen autorův hlas
|
||||
expect(list[0].voteScore).toBe(1);
|
||||
expect((await listSuggestions(VOTER))[0].myVote).toBeUndefined();
|
||||
});
|
||||
|
||||
test('autor může svůj automatický hlas odebrat', async () => {
|
||||
const id = await createSuggestion();
|
||||
const list = await voteSuggestion(AUTHOR, id, 'up');
|
||||
expect(list[0].voteScore).toBe(0);
|
||||
expect((await listSuggestions(AUTHOR))[0].myVote).toBeUndefined();
|
||||
});
|
||||
|
||||
test('hlasování pro neexistující návrh vyhodí chybu', async () => {
|
||||
await expect(voteSuggestion(VOTER, 'neexistuje', 'up')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSuggestion', () => {
|
||||
test('autor smaže svůj návrh včetně hlasů', async () => {
|
||||
const id = await createSuggestion();
|
||||
await voteSuggestion(VOTER, id, 'up');
|
||||
const list = await deleteSuggestion(AUTHOR, id);
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('cizí uživatel nemůže smazat návrh', async () => {
|
||||
const id = await createSuggestion();
|
||||
await expect(deleteSuggestion(VOTER, id)).rejects.toThrow();
|
||||
expect(await listSuggestions(AUTHOR)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('smazání neexistujícího návrhu vyhodí chybu', async () => {
|
||||
await expect(deleteSuggestion(AUTHOR, 'neexistuje')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { FeatureRequest } from '../../../types/gen/types.gen';
|
||||
|
||||
const OPT_A = FeatureRequest.STATISTICS;
|
||||
const OPT_B = FeatureRequest.UI;
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
describe('updateFeatureVote', () => {
|
||||
test('přidá hlas pro nového uživatele', async () => {
|
||||
const result = await updateFeatureVote('alice', OPT_A, true);
|
||||
expect(result['alice']).toContain(OPT_A);
|
||||
});
|
||||
|
||||
test('vyhodí chybu při duplicitním hlasování', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
await expect(updateFeatureVote('alice', OPT_A, true)).rejects.toThrow('hlasovali');
|
||||
});
|
||||
|
||||
test('odebere hlas', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
await updateFeatureVote('alice', OPT_A, false);
|
||||
const stats = await getVotingStats();
|
||||
expect(stats[OPT_A] ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
test('odebrání neexistujícího hlasu je no-op', async () => {
|
||||
await expect(updateFeatureVote('alice', OPT_A, false)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('odebrání posledního hlasu odstraní login ze storage', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
const data = await updateFeatureVote('alice', OPT_A, false);
|
||||
expect('alice' in data).toBe(false);
|
||||
});
|
||||
|
||||
test('vyhodí chybu po 4 hlasech', async () => {
|
||||
const options = Object.values(FeatureRequest);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await updateFeatureVote('alice', options[i], true);
|
||||
}
|
||||
await expect(updateFeatureVote('alice', options[4], true)).rejects.toThrow('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserVotes', () => {
|
||||
test('vrátí hlasy uživatele', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
const votes = await getUserVotes('alice');
|
||||
expect(votes).toContain(OPT_A);
|
||||
});
|
||||
|
||||
test('vrátí prázdné pole pro uživatele bez hlasů', async () => {
|
||||
const votes = await getUserVotes('neexistujici');
|
||||
expect(votes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVotingStats', () => {
|
||||
test('vrátí agregované počty hlasů', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
await updateFeatureVote('bob', OPT_A, true);
|
||||
await updateFeatureVote('bob', OPT_B, true);
|
||||
|
||||
const stats = await getVotingStats();
|
||||
expect(stats[OPT_A]).toBe(2);
|
||||
expect(stats[OPT_B]).toBe(1);
|
||||
});
|
||||
|
||||
test('vrátí prázdný objekt bez hlasů', async () => {
|
||||
const stats = await getVotingStats();
|
||||
expect(stats).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import bodyParser from 'body-parser';
|
||||
import { generateToken } from '../auth';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { FeatureRequest } from '../../../types/gen/types.gen';
|
||||
import votingRouter from '../routes/votingRoutes';
|
||||
|
||||
const VALID_OPTION = FeatureRequest.STATISTICS;
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(bodyParser.json());
|
||||
app.use('/api/voting', votingRouter);
|
||||
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||
res.status(400).json({ error: err.message });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
const TOKEN = `Bearer ${generateToken('testuser')}`;
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
test('GET /getVotes vrátí 200 s prázdným polem pro nového uživatele', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/voting/getVotes')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
test('GET /getVotes vrátí 401 bez tokenu', async () => {
|
||||
const res = await request(buildApp()).get('/api/voting/getVotes');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('POST /updateVote přidá hlas a vrátí 200', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({ option: VALID_OPTION, active: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST /updateVote vrátí 400 pro chybějící parametry', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /updateVote vrátí 400 při duplicitním hlasu', async () => {
|
||||
const app = buildApp();
|
||||
await request(app)
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({ option: VALID_OPTION, active: true });
|
||||
const res = await request(app)
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({ option: VALID_OPTION, active: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('hlasovali');
|
||||
});
|
||||
|
||||
test('GET /stats vrátí 200 s objektem', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/voting/stats')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
|
||||
interface VotingData {
|
||||
[login: string]: FeatureRequest[],
|
||||
}
|
||||
|
||||
export interface VotingStatsResult {
|
||||
[feature: string]: number;
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const STORAGE_KEY = 'voting';
|
||||
|
||||
/**
|
||||
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
|
||||
*
|
||||
* @param login login uživatele
|
||||
* @returns pole voleb
|
||||
*/
|
||||
export async function getUserVotes(login: string) {
|
||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||
return data?.[login] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje hlas uživatele pro konkrétní volbu.
|
||||
*
|
||||
* @param login login uživatele
|
||||
* @param option volba
|
||||
* @param active příznak, zda volbu přidat nebo odebrat
|
||||
* @returns aktuální data
|
||||
*/
|
||||
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
|
||||
let data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||
data ??= {};
|
||||
if (!(login in data)) {
|
||||
data[login] = [];
|
||||
}
|
||||
const index = data[login].indexOf(option);
|
||||
if (index > -1) {
|
||||
if (active) {
|
||||
throw new Error('Pro tuto možnost jste již hlasovali');
|
||||
} else {
|
||||
data[login].splice(index, 1);
|
||||
if (data[login].length === 0) {
|
||||
delete data[login];
|
||||
}
|
||||
}
|
||||
} else if (active) {
|
||||
if (data[login].length == 4) {
|
||||
throw new Error('Je možné hlasovat pro maximálně 4 možnosti');
|
||||
}
|
||||
data[login].push(option);
|
||||
}
|
||||
await storage.setData(STORAGE_KEY, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
|
||||
*
|
||||
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
|
||||
*/
|
||||
export async function getVotingStats(): Promise<VotingStatsResult> {
|
||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||
const stats: VotingStatsResult = {};
|
||||
if (data) {
|
||||
for (const votes of Object.values(data)) {
|
||||
for (const feature of votes) {
|
||||
stats[feature] = (stats[feature] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
Reference in New Issue
Block a user