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

This commit is contained in:
2026-06-05 19:15:46 +02:00
parent f28f127a92
commit 17132d4124
27 changed files with 857 additions and 515 deletions
+126
View File
@@ -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();
});
});
-77
View File
@@ -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({});
});
});
-76
View File
@@ -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');
});