diff --git a/server/.gitignore b/server/.gitignore index bf2c19e..3e0190c 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -2,6 +2,7 @@ /dist /resources/easterEggs /src/gen +/coverage .env.production .env.development .easter-eggs.json diff --git a/server/jest.config.js b/server/jest.config.js index 81185ff..a7fb576 100644 --- a/server/jest.config.js +++ b/server/jest.config.js @@ -1,5 +1,6 @@ module.exports = { testEnvironment: 'node', - testMatch: ['/src/tests/**/*.test.ts'], - setupFiles: ['/src/tests/helpers/setupEnv.ts'], + testMatch: ['/src/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + setupFiles: ['/src/tests/setupEnv.ts'], }; diff --git a/server/package.json b/server/package.json index 0b0ccfb..e40af6b 100644 --- a/server/package.json +++ b/server/package.json @@ -19,10 +19,12 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", "@types/request-promise": "^4.1.48", + "@types/supertest": "^6.0.0", "@types/web-push": "^3.6.4", "babel-jest": "^30.2.0", "jest": "^30.2.0", "nodemon": "^3.1.10", + "supertest": "^7.0.0", "ts-node": "^10.9.1", "typescript": "^5.9.3" }, diff --git a/server/src/qr.ts b/server/src/qr.ts index b9a5507..6327972 100644 --- a/server/src/qr.ts +++ b/server/src/qr.ts @@ -14,7 +14,7 @@ const storage = getStorage(); * * @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100) */ -function convertBbanToIban(bankAccountNumber: string): string { +export function convertBbanToIban(bankAccountNumber: string): string { // TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl let prefix: string = ''; let accountNumber: string = bankAccountNumber; @@ -58,7 +58,7 @@ function createStorageKey(customerName: string, id: string): string { export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise { // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků if (message.indexOf('*') >= 0) { - message = message.replace('*', ''); + message = message.replace(/\*/g, ''); } if (message.length > 60) { message = message.substring(0, 60); diff --git a/server/src/storage/index.ts b/server/src/storage/index.ts index bdb31f7..3b611f5 100644 --- a/server/src/storage/index.ts +++ b/server/src/storage/index.ts @@ -3,20 +3,24 @@ import path from 'path'; import { StorageInterface } from "./StorageInterface"; import JsonStorage from "./json"; import RedisStorage from "./redis"; +import MemoryStorage from "./memory"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) }); const JSON_KEY = 'json'; const REDIS_KEY = 'redis'; +const MEMORY_KEY = 'memory'; let storage: StorageInterface; if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) { storage = new JsonStorage(); } else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) { storage = new RedisStorage(); +} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) { + storage = new MemoryStorage(); } else { - throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'"); + throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'"); } export const storageReady: Promise = storage.initialize diff --git a/server/src/storage/memory.ts b/server/src/storage/memory.ts new file mode 100644 index 0000000..b75a41f --- /dev/null +++ b/server/src/storage/memory.ts @@ -0,0 +1,27 @@ +import { StorageInterface } from "./StorageInterface"; + +const store = new Map(); + +/** Vymaže všechna data z in-memory úložiště. Slouží k resetu mezi testy. */ +export function resetMemoryStorage(): void { + store.clear(); +} + +/** + * In-memory implementace úložiště. Používá se výhradně v testovacím prostředí. + */ +export default class MemoryStorage implements StorageInterface { + + hasData(key: string): Promise { + return Promise.resolve(store.has(key)); + } + + getData(key: string): Promise { + return Promise.resolve(store.get(key) as Type | undefined); + } + + setData(key: string, data: Type): Promise { + store.set(key, data); + return Promise.resolve(); + } +} diff --git a/server/src/tests/auth.test.ts b/server/src/tests/auth.test.ts index afbea8e..39eba1b 100644 --- a/server/src/tests/auth.test.ts +++ b/server/src/tests/auth.test.ts @@ -1,6 +1,7 @@ import { generateToken, verify, getLogin, getTrusted } from '../auth'; -const VALID_SECRET = 'test-secret-min-32-chars-aaaaaaa!'; +const VALID_SECRET = 'test-jwt-secret-ktery-ma-alespon-32-znaku'; +const SHORT_SECRET = 'kratky'; beforeEach(() => { process.env.JWT_SECRET = VALID_SECRET; @@ -23,12 +24,15 @@ describe('generateToken', () => { }); test('vyhodí chybu pro příliš krátký JWT_SECRET', () => { - process.env.JWT_SECRET = 'short'; + process.env.JWT_SECRET = SHORT_SECRET; expect(() => generateToken('alice')).toThrow('32'); }); test('vyhodí chybu pro prázdný login', () => { expect(() => generateToken('')).toThrow('login'); + }); + + test('vyhodí chybu pro login obsahující jen mezery', () => { expect(() => generateToken(' ')).toThrow('login'); }); diff --git a/server/src/tests/chefie.test.ts b/server/src/tests/chefie.test.ts new file mode 100644 index 0000000..5637a06 --- /dev/null +++ b/server/src/tests/chefie.test.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { downloadSalaty } from '../chefie'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const fixturesDir = path.join(__dirname, 'fixtures'); +const loadFixture = (name: string) => fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + +beforeEach(() => { + jest.resetAllMocks(); + // První volání = stránka se seznamem salátů, následující volání = jednotlivé stránky salátů + mockedAxios.get = jest.fn() + .mockResolvedValueOnce({ data: loadFixture('chefie-salaty.html') }) + .mockResolvedValueOnce({ data: loadFixture('chefie-salat-caesar.html') }) + .mockResolvedValueOnce({ data: loadFixture('chefie-salat-recky.html') }); +}); + +test('downloadSalaty vrátí seznam salátů', async () => { + const salaty = await downloadSalaty(false); + expect(salaty).toHaveLength(2); +}); + +test('saláty mají name a ingredients', async () => { + const salaty = await downloadSalaty(false); + expect(salaty[0].name).toBe('Caesar salát'); + expect(salaty[0].ingredients).toContain('Kuřecí maso'); +}); + +test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => { + const salaty = await downloadSalaty(false); + // Caesar sticker price = 129, box = 13 + expect(salaty[0].price).toBe(129 + 13); + // Řecký sticker price = 119, box = 13 + expect(salaty[1].price).toBe(119 + 13); +}); diff --git a/server/src/tests/fixtures/chefie-salat-caesar.html b/server/src/tests/fixtures/chefie-salat-caesar.html new file mode 100644 index 0000000..c2c7973 --- /dev/null +++ b/server/src/tests/fixtures/chefie-salat-caesar.html @@ -0,0 +1,16 @@ + + + +
+

Caesar salát

+
+
    +
  • Ledový salát
  • +
  • Kuřecí maso
  • +
  • Parmazán
  • +
+
+ 129 Kč +
+ + diff --git a/server/src/tests/fixtures/chefie-salat-recky.html b/server/src/tests/fixtures/chefie-salat-recky.html new file mode 100644 index 0000000..86cf988 --- /dev/null +++ b/server/src/tests/fixtures/chefie-salat-recky.html @@ -0,0 +1,16 @@ + + + +
+

Řecký salát

+
+
    +
  • Rajčata
  • +
  • Okurka
  • +
  • Feta sýr
  • +
+
+ 119 Kč +
+ + diff --git a/server/src/tests/fixtures/chefie-salaty.html b/server/src/tests/fixtures/chefie-salaty.html new file mode 100644 index 0000000..39fb481 --- /dev/null +++ b/server/src/tests/fixtures/chefie-salaty.html @@ -0,0 +1,13 @@ + + + + + + diff --git a/server/src/tests/fixtures/senkserikova.html b/server/src/tests/fixtures/senkserikova.html new file mode 100644 index 0000000..6848aa1 --- /dev/null +++ b/server/src/tests/fixtures/senkserikova.html @@ -0,0 +1,33 @@ + + + +
+ +
+
+ +
+ + diff --git a/server/src/tests/fixtures/sladovnicka.html b/server/src/tests/fixtures/sladovnicka.html new file mode 100644 index 0000000..86b8745 --- /dev/null +++ b/server/src/tests/fixtures/sladovnicka.html @@ -0,0 +1,55 @@ + + + +
    + + + + + +
+
    +
    +
    + + + + +
    250mlPolévka dne 1, 935 Kč
    150gSvíčková na smetaně s knedlíkem 1, 3, 7149 Kč
    120gKuřecí řízek s bramborami 1139 Kč
    +
    +
    +
    +
    + + + +
    250mlČesnečka 135 Kč
    150gVepřový guláš s houskovým knedlíkem 1, 3145 Kč
    +
    +
    +
    +
    + + + +
    250mlHovězí vývar s nudlemi 135 Kč
    150gSmažený sýr s bramborovým salátem 1, 3, 7135 Kč
    +
    +
    +
    +
    + + + +
    250mlRajská polévka 135 Kč
    150gRizoto s kuřecím masem 1139 Kč
    +
    +
    +
    +
    + + + +
    250mlDršťková polévka 135 Kč
    150gSegedínský guláš s knedlíkem 1, 3145 Kč
    +
    +
    +
+ + diff --git a/server/src/tests/fixtures/techtower.html b/server/src/tests/fixtures/techtower.html new file mode 100644 index 0000000..74bf729 --- /dev/null +++ b/server/src/tests/fixtures/techtower.html @@ -0,0 +1,29 @@ + + + +
+
+

+ Obědy 12.5.-16.5.2025 +

+
+ +

Pondělí

+

• Polévka dne 1

+

• Svíčková na smetaně s knedlíkem 1, 3, 7 149 Kč

+

• Smažený sýr s bramborami 1, 3 139 Kč

+

Úterý

+

• Česnečka 1

+

• Vepřový guláš s houskovým knedlíkem 1, 3 145 Kč

+

Středa

+

• Hovězí vývar s nudlemi 1

+

• Kuřecí řízek s bramborami 1 139 Kč

+

Čtvrtek

+

• Dršťková polévka 1

+

• Segedínský guláš s knedlíkem 1, 3 145 Kč

+

Pátek

+

• Rajská polévka s rýží 1

+

• Rizoto s kuřecím masem a zeleninou 1 139 Kč

+
+ + diff --git a/server/src/tests/generateQr.test.ts b/server/src/tests/generateQr.test.ts new file mode 100644 index 0000000..5648907 --- /dev/null +++ b/server/src/tests/generateQr.test.ts @@ -0,0 +1,47 @@ +import axios from 'axios'; +import { generateQr, getQr } from '../qr'; +import { resetMemoryStorage } from '../storage/memory'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const FAKE_IMAGE = Buffer.from('fake-png-data'); + +beforeEach(() => { + resetMemoryStorage(); + jest.resetAllMocks(); + mockedAxios.get = jest.fn().mockResolvedValue({ data: FAKE_IMAGE }); +}); + +test('generateQr zavolá Paylibo API se správnými parametry', async () => { + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, 'Pizza Margherita', 'test-uuid-1'); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + const [url, config] = (mockedAxios.get as jest.Mock).mock.calls[0]; + expect(url).toContain('paylibo.com'); + expect(config.params.amount).toBe(149); + expect(config.params.iban).toBeDefined(); +}); + +test('generateQr uloží base64 obrázek do storage', async () => { + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, 'Pizza', 'test-uuid-2'); + const img = await getQr('jannovak', 'test-uuid-2'); + expect(Buffer.isBuffer(img)).toBe(true); + expect(img).toEqual(FAKE_IMAGE); +}); + +test('generateQr ořeže zprávu delší než 60 znaků', async () => { + const dlouhaZprava = 'Pizza ' + 'x'.repeat(60); + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, dlouhaZprava, 'test-uuid-3'); + const [, config] = (mockedAxios.get as jest.Mock).mock.calls[0]; + expect(config.params.message.length).toBeLessThanOrEqual(60); +}); + +test('generateQr odstraní hvězdičku ze zprávy', async () => { + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, 'Pizza *Margherita*', 'test-uuid-4'); + const [, config] = (mockedAxios.get as jest.Mock).mock.calls[0]; + expect(config.params.message).not.toContain('*'); +}); + +test('getQr hodí chybu pro neexistující ID', async () => { + await expect(getQr('jannovak', 'neexistuje')).rejects.toThrow('nebyl nalezen'); +}); diff --git a/server/src/tests/pizza.test.ts b/server/src/tests/pizza.test.ts index 383833f..777775d 100644 --- a/server/src/tests/pizza.test.ts +++ b/server/src/tests/pizza.test.ts @@ -1,18 +1,26 @@ -import { PizzaDayState, PizzaSize } from '../../../types/gen/types.gen'; +import { resetMemoryStorage } from '../storage/memory'; +import getStorage from '../storage'; +import { formatDate } from '../utils'; +import { + createPizzaDay, + deletePizzaDay, + addPizzaOrder, + removePizzaOrder, + removeAllUserPizzas, + updatePizzaFee, + lockPizzaDay, + unlockPizzaDay, + finishPizzaOrder, + finishPizzaDelivery, +} from '../pizza'; +import { ClientData, PizzaDayState } from '../../../types/gen/types.gen'; -const mockStorageData = new Map(); -jest.mock('../storage', () => ({ - __esModule: true, - default: () => ({ - hasData: async (key: string) => mockStorageData.has(key), - getData: async (key: string) => mockStorageData.get(key) as T, - setData: async (key: string, val: T) => void mockStorageData.set(key, val), - }), - storageReady: Promise.resolve(), +jest.mock('../notifikace', () => ({ + callNotifikace: jest.fn().mockResolvedValue([]), +})); +jest.mock('../qr', () => ({ + generateQr: jest.fn().mockResolvedValue(undefined), })); - -jest.mock('../notifikace', () => ({ callNotifikace: jest.fn() })); -jest.mock('../qr', () => ({ generateQr: jest.fn().mockResolvedValue(undefined) })); jest.mock('../chefie', () => ({ downloadPizzy: jest.fn().mockResolvedValue([ { id: 1, name: 'Margherita', variants: [{ varId: 10, size: 'střední', price: 150 }] }, @@ -20,129 +28,207 @@ jest.mock('../chefie', () => ({ downloadSalaty: jest.fn().mockResolvedValue([]), })); -import { - createPizzaDay, - deletePizzaDay, - lockPizzaDay, - unlockPizzaDay, - finishPizzaOrder, - finishPizzaDelivery, - addPizzaOrder, - removeAllUserPizzas, -} from '../pizza'; +const today = formatDate(new Date()); +const CREATOR = 'kreator'; +const USER = 'uzivatel'; -const PIZZA: any = { id: 1, name: 'Margherita', variants: [] }; -const SIZE: PizzaSize = { varId: 10, size: 'střední', price: 150 }; +const PIZZA: any = { id: 1, name: 'Margherita', ingredients: [], variants: [], sizes: [] }; +const SIZE_M = { varId: 1, size: '30cm', price: 179, boxPrice: 13, pizzaPrice: 166 }; +const SIZE_L = { varId: 2, size: '35cm', price: 219, boxPrice: 15, pizzaPrice: 204 }; +const SIZE: any = { varId: 10, size: 'střední', price: 150 }; -beforeEach(() => mockStorageData.clear()); +async function seedPizzaDay(state: PizzaDayState = PizzaDayState.CREATED): Promise { + const storage = getStorage(); + const data: ClientData = { + todayDayIndex: 0, + date: today, + isWeekend: false, + dayIndex: 0, + choices: {}, + pizzaDay: { + state, + creator: CREATOR, + orders: [], + }, + pizzaList: [], + salatList: [], + }; + await storage.setData(today, data); +} + +beforeEach(() => { + resetMemoryStorage(); +}); describe('createPizzaDay', () => { test('vytvoří pizza day ve stavu CREATED', async () => { - const data = await createPizzaDay('alice'); + const data = await createPizzaDay(CREATOR); expect(data.pizzaDay?.state).toBe(PizzaDayState.CREATED); - expect(data.pizzaDay?.creator).toBe('alice'); + expect(data.pizzaDay?.creator).toBe(CREATOR); }); test('vyhodí chybu, pokud pizza day již existuje', async () => { - await createPizzaDay('alice'); - await expect(createPizzaDay('alice')).rejects.toThrow('existuje'); + await createPizzaDay(CREATOR); + await expect(createPizzaDay(CREATOR)).rejects.toThrow('existuje'); }); }); describe('deletePizzaDay', () => { test('smaže pizza day tvůrcem', async () => { - await createPizzaDay('alice'); - const data = await deletePizzaDay('alice'); + await createPizzaDay(CREATOR); + const data = await deletePizzaDay(CREATOR); expect(data.pizzaDay).toBeUndefined(); }); test('vyhodí chybu pro jiného uživatele', async () => { - await createPizzaDay('alice'); - await expect(deletePizzaDay('bob')).rejects.toThrow(); + await createPizzaDay(CREATOR); + await expect(deletePizzaDay(USER)).rejects.toThrow(); }); }); describe('addPizzaOrder', () => { test('přidá objednávku pizzy', async () => { - await createPizzaDay('alice'); - const data = await addPizzaOrder('bob', PIZZA, SIZE); - const bobOrder = data.pizzaDay?.orders?.find(o => o.customer === 'bob'); - expect(bobOrder?.pizzaList?.length).toBe(1); - expect(bobOrder?.totalPrice).toBe(150); + await seedPizzaDay(); + const data = await addPizzaOrder(USER, PIZZA, SIZE); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order?.pizzaList?.length).toBe(1); + expect(order?.totalPrice).toBe(SIZE.price); + }); + + test('přičte cenu další pizzy ke stejné objednávce', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + const data = await addPizzaOrder(USER, { ...PIZZA, name: 'Quattro' }, SIZE_L); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order?.totalPrice).toBe(SIZE_M.price + SIZE_L.price); + expect(order?.pizzaList).toHaveLength(2); }); test('vyhodí chybu bez aktivního pizza day', async () => { - await expect(addPizzaOrder('bob', PIZZA, SIZE)).rejects.toThrow('neexistuje'); + await expect(addPizzaOrder(USER, PIZZA, SIZE)).rejects.toThrow('neexistuje'); + }); + + test('vyhodí chybu pro pizza day ve stavu LOCKED', async () => { + await seedPizzaDay(PizzaDayState.LOCKED); + await expect(addPizzaOrder(USER, PIZZA, SIZE_M)).rejects.toThrow(PizzaDayState.CREATED); }); }); -describe('lockPizzaDay / unlockPizzaDay', () => { - test('tvůrce může zamknout pizza day', async () => { - await createPizzaDay('alice'); - const data = await lockPizzaDay('alice'); - expect(data.pizzaDay?.state).toBe(PizzaDayState.LOCKED); +describe('removePizzaOrder', () => { + test('odečte cenu a odstraní položku z objednávky', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + await addPizzaOrder(USER, { ...PIZZA, name: 'Diavola' }, SIZE_L); + const variant = { varId: SIZE_M.varId, name: PIZZA.name, size: SIZE_M.size, price: SIZE_M.price }; + const data = await removePizzaOrder(USER, variant); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order?.totalPrice).toBe(SIZE_L.price); + expect(order?.pizzaList).toHaveLength(1); }); - test('jiný uživatel nemůže zamknout pizza day', async () => { - await createPizzaDay('alice'); - // chybová zpráva obsahuje login volajícího (bob), nikoli tvůrce - await expect(lockPizzaDay('bob')).rejects.toThrow('bob'); - }); - - test('zamčený pizza day lze odemknout', async () => { - await createPizzaDay('alice'); - await lockPizzaDay('alice'); - const data = await unlockPizzaDay('alice'); - expect(data.pizzaDay?.state).toBe(PizzaDayState.CREATED); - }); - - test('nelze odemknout nezamčený pizza day', async () => { - await createPizzaDay('alice'); - await expect(unlockPizzaDay('alice')).rejects.toThrow(PizzaDayState.LOCKED); - }); -}); - -describe('finishPizzaOrder', () => { - test('přesune pizza day do stavu ORDERED', async () => { - await createPizzaDay('alice'); - await lockPizzaDay('alice'); - const data = await finishPizzaOrder('alice'); - expect(data.pizzaDay?.state).toBe(PizzaDayState.ORDERED); - }); - - test('vyhodí chybu v nesprávném stavu (CREATED)', async () => { - await createPizzaDay('alice'); - await expect(finishPizzaOrder('alice')).rejects.toThrow(PizzaDayState.LOCKED); - }); -}); - -describe('finishPizzaDelivery', () => { - test('přesune pizza day do stavu DELIVERED', async () => { - await createPizzaDay('alice'); - await lockPizzaDay('alice'); - await finishPizzaOrder('alice'); - const data = await finishPizzaDelivery('alice'); - expect(data.pizzaDay?.state).toBe(PizzaDayState.DELIVERED); - }); - - test('vyhodí chybu v nesprávném stavu (LOCKED)', async () => { - await createPizzaDay('alice'); - await lockPizzaDay('alice'); - await expect(finishPizzaDelivery('alice')).rejects.toThrow(PizzaDayState.ORDERED); + test('odstraní celou objednávku, pokud je prázdná', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + const variant = { varId: SIZE_M.varId, name: PIZZA.name, size: SIZE_M.size, price: SIZE_M.price }; + const data = await removePizzaOrder(USER, variant); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order).toBeUndefined(); }); }); describe('removeAllUserPizzas', () => { test('odstraní objednávku uživatele', async () => { - await createPizzaDay('alice'); - await addPizzaOrder('bob', PIZZA, SIZE); - const data = await removeAllUserPizzas('bob'); - const bobOrder = data.pizzaDay?.orders?.find(o => o.customer === 'bob'); - expect(bobOrder).toBeUndefined(); + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE); + const data = await removeAllUserPizzas(USER); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order).toBeUndefined(); }); test('je no-op bez pizza day', async () => { - const data = await removeAllUserPizzas('bob'); + const data = await removeAllUserPizzas(USER); expect(data.pizzaDay).toBeUndefined(); }); }); + +describe('updatePizzaFee', () => { + test('přidá příplatek a přepočítá celkovou cenu', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + const data = await updatePizzaFee(CREATOR, USER, 'Balné', 20); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order?.fee).toEqual({ text: 'Balné', price: 20 }); + expect(order?.totalPrice).toBe(SIZE_M.price + 20); + }); + + test('s cenou undefined odstraní příplatek', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + await updatePizzaFee(CREATOR, USER, 'Balné', 20); + const data = await updatePizzaFee(CREATOR, USER, undefined, undefined); + const order = data.pizzaDay?.orders?.find(o => o.customer === USER); + expect(order?.fee).toBeUndefined(); + expect(order?.totalPrice).toBe(SIZE_M.price); + }); + + test('vyhodí chybu, pokud volá jiný uživatel než tvůrce', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + await expect(updatePizzaFee(USER, USER, 'Balné', 20)).rejects.toThrow('zakladatel'); + }); +}); + +describe('lockPizzaDay / unlockPizzaDay', () => { + test('tvůrce může zamknout pizza day', async () => { + await seedPizzaDay(); + const data = await lockPizzaDay(CREATOR); + expect(data.pizzaDay?.state).toBe(PizzaDayState.LOCKED); + }); + + test('jiný uživatel nemůže zamknout pizza day', async () => { + await seedPizzaDay(); + await expect(lockPizzaDay(USER)).rejects.toThrow(USER); + }); + + test('zamčený pizza day lze odemknout', async () => { + await seedPizzaDay(); + await lockPizzaDay(CREATOR); + const data = await unlockPizzaDay(CREATOR); + expect(data.pizzaDay?.state).toBe(PizzaDayState.CREATED); + }); + + test('nelze odemknout nezamčený pizza day', async () => { + await seedPizzaDay(); + await expect(unlockPizzaDay(CREATOR)).rejects.toThrow(PizzaDayState.LOCKED); + }); +}); + +describe('finishPizzaOrder', () => { + test('přesune pizza day do stavu ORDERED', async () => { + await seedPizzaDay(); + await lockPizzaDay(CREATOR); + const data = await finishPizzaOrder(CREATOR); + expect(data.pizzaDay?.state).toBe(PizzaDayState.ORDERED); + }); + + test('vyhodí chybu v nesprávném stavu (CREATED)', async () => { + await seedPizzaDay(); + await expect(finishPizzaOrder(CREATOR)).rejects.toThrow(PizzaDayState.LOCKED); + }); +}); + +describe('finishPizzaDelivery', () => { + test('přesune pizza day do stavu DELIVERED', async () => { + await seedPizzaDay(); + await lockPizzaDay(CREATOR); + await finishPizzaOrder(CREATOR); + const data = await finishPizzaDelivery(CREATOR); + expect(data.pizzaDay?.state).toBe(PizzaDayState.DELIVERED); + }); + + test('vyhodí chybu v nesprávném stavu (LOCKED)', async () => { + await seedPizzaDay(); + await lockPizzaDay(CREATOR); + await expect(finishPizzaDelivery(CREATOR)).rejects.toThrow(PizzaDayState.ORDERED); + }); +}); diff --git a/server/src/tests/qr.test.ts b/server/src/tests/qr.test.ts new file mode 100644 index 0000000..34d50a9 --- /dev/null +++ b/server/src/tests/qr.test.ts @@ -0,0 +1,36 @@ +import { convertBbanToIban } from '../qr'; + +test('konverze BBAN s prefixem na IBAN', () => { + // Číslo účtu 19-2000145399/0800 (Česká spořitelna) → CZ6508000000192000145399 + const iban = convertBbanToIban('19-2000145399/0800'); + expect(iban).toBe('CZ6508000000192000145399'); + expect(iban).toHaveLength(24); +}); + +test('konverze BBAN bez prefixu na IBAN', () => { + // Číslo účtu 2000145399/0800 (bez prefixu) → prefix se doplní jako 000000 + const iban = convertBbanToIban('2000145399/0800'); + expect(iban).toBe('CZ7908000000002000145399'); + expect(iban).toHaveLength(24); +}); + +test('konverze BBAN s krátkým číslem účtu – zero-padding', () => { + // Krátké číslo účtu 123456/0100 → prefix 000000, account 0000123456 + const iban = convertBbanToIban('123456/0100'); + expect(iban).toHaveLength(24); + // bankCode(4) + prefix(6) + account(10) = 20 číslic za CZ+checkdigits + expect(iban).toMatch(/^CZ\d{2}01000000000000123456$/); +}); + +test('kontrolní číslice jsou platné (mod 97)', () => { + const iban = convertBbanToIban('19-2000145399/0800'); + // Přesuneme první 4 znaky na konec, nahradíme písmena čísly a mod 97 musí dát 1 + const rearranged = iban.slice(4) + iban.slice(0, 4); + const numeric = rearranged.replace(/[A-Z]/g, (c) => (c.charCodeAt(0) - 55).toString()); + expect(BigInt(numeric) % BigInt(97)).toBe(BigInt(1)); +}); + +test('výsledek vždy začíná CZ', () => { + expect(convertBbanToIban('100-2000145399/0300')).toMatch(/^CZ/); + expect(convertBbanToIban('2000145399/0100')).toMatch(/^CZ/); +}); diff --git a/server/src/tests/qrRoutes.test.ts b/server/src/tests/qrRoutes.test.ts new file mode 100644 index 0000000..a558111 --- /dev/null +++ b/server/src/tests/qrRoutes.test.ts @@ -0,0 +1,103 @@ +import express from 'express'; +import request from 'supertest'; +import bodyParser from 'body-parser'; +import axios from 'axios'; +import { generateToken } from '../auth'; +import { resetMemoryStorage } from '../storage/memory'; +import qrRouter from '../routes/qrRoutes'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use('/api/qr', qrRouter); + app.use((err: any, _req: any, res: any, _next: any) => { + res.status(400).json({ error: err.message }); + }); + return app; +} + +const TOKEN = `Bearer ${generateToken('kreator')}`; + +beforeEach(() => { + resetMemoryStorage(); + mockedAxios.get = jest.fn().mockResolvedValue({ data: Buffer.from('fake-png') }); +}); + +const VALID_BODY = { + recipients: [ + { login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 149 }, + { login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 179 }, + ], + bankAccount: '19-2000145399/0800', + bankAccountHolder: 'Jan Novák', +}; + +test('POST /generate vrátí 200 s počtem vygenerovaných QR kódů', async () => { + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(VALID_BODY); + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); +}); + +test('POST /generate vrátí 400 pro prázdné recipients', async () => { + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send({ ...VALID_BODY, recipients: [] }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('příjemců'); +}); + +test('POST /generate vrátí 400 pro chybějící bankAccount', async () => { + const { bankAccount: _, ...body } = VALID_BODY; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('účtu'); +}); + +test('POST /generate vrátí 400 pro zápornou částku', async () => { + const body = { + ...VALID_BODY, + recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: -1 }], + }; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('částku'); +}); + +test('POST /generate vrátí 400 pro částku s více než 2 desetinnými místy', async () => { + const body = { + ...VALID_BODY, + recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.999 }], + }; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('desetinná'); +}); + +test('POST /generate vrátí 400 pro příjemce bez login', async () => { + const body = { + ...VALID_BODY, + recipients: [{ purpose: 'Pizza', amount: 149 }], + }; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('login'); +}); diff --git a/server/src/tests/restaurants-helpers.test.ts b/server/src/tests/restaurants-helpers.test.ts new file mode 100644 index 0000000..85546ff --- /dev/null +++ b/server/src/tests/restaurants-helpers.test.ts @@ -0,0 +1,73 @@ +import { parseAllergens, isTextSoupName, sanitizeText, capitalize } from '../restaurants'; + +// parseAllergens +test('parseAllergens rozpozná alergeny na konci názvu', () => { + const result = parseAllergens('Svíčková na smetaně 1, 3, 7'); + expect(result.cleanName).toBe('Svíčková na smetaně'); + expect(result.allergens).toEqual([1, 3, 7]); +}); + +test('parseAllergens vrátí prázdné pole alergenů, pokud žádné nejsou', () => { + const result = parseAllergens('Svíčková na smetaně'); + expect(result.cleanName).toBe('Svíčková na smetaně'); + expect(result.allergens).toEqual([]); +}); + +test('parseAllergens zpracuje jednočíselný alergen', () => { + const result = parseAllergens('Polévka dne 1'); + expect(result.cleanName).toBe('Polévka dne'); + expect(result.allergens).toEqual([1]); +}); + +test('parseAllergens neodstraní čísla uvnitř názvu', () => { + const result = parseAllergens('Pizza č. 4 Quattro formaggi 1, 7'); + expect(result.allergens).toEqual([1, 7]); + expect(result.cleanName).toContain('4'); +}); + +// isTextSoupName +test('isTextSoupName vrátí true pro "polévka"', () => { + expect(isTextSoupName('Polévka dne')).toBe(true); +}); + +test('isTextSoupName vrátí true pro "česnečka"', () => { + expect(isTextSoupName('Česnečka se sýrem')).toBe(true); +}); + +test('isTextSoupName vrátí true pro "vývar"', () => { + expect(isTextSoupName('Hovězí vývar s nudlemi')).toBe(true); +}); + +test('isTextSoupName vrátí false pro hlavní jídlo', () => { + expect(isTextSoupName('Svíčková na smetaně s knedlíkem')).toBe(false); +}); + +test('isTextSoupName není case-sensitive', () => { + expect(isTextSoupName('POLÉVKA DNE')).toBe(true); +}); + +// sanitizeText +test('sanitizeText odstraní tabulátor (nenahradí mezerou)', () => { + expect(sanitizeText('\ttext')).toBe('text'); +}); + +test('sanitizeText opraví mezery kolem čárky', () => { + expect(sanitizeText('jídlo , příloha')).toBe('jídlo, příloha'); +}); + +test('sanitizeText ořeže mezery ze začátku a konce', () => { + expect(sanitizeText(' text ')).toBe('text'); +}); + +// capitalize +test('capitalize převede první písmeno na velké', () => { + expect(capitalize('pondělí')).toBe('Pondělí'); +}); + +test('capitalize nezmění zbytek řetězce', () => { + expect(capitalize('pÁTEK')).toBe('PÁTEK'); +}); + +test('capitalize vrátí prázdný řetězec pro prázdný vstup', () => { + expect(capitalize('')).toBe(''); +}); diff --git a/server/src/tests/scrapers.test.ts b/server/src/tests/scrapers.test.ts new file mode 100644 index 0000000..06c0669 --- /dev/null +++ b/server/src/tests/scrapers.test.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { getMenuSladovnicka, getMenuTechTower, getMenuSenkSerikova, StaleWeekError } from '../restaurants'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const fixturesDir = path.join(__dirname, 'fixtures'); +const loadFixture = (name: string) => fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + +// Pondělí 12.5.2025 +const MONDAY = new Date('2025-05-12'); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('Sladovnicka parser', () => { + beforeEach(() => { + mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('sladovnicka.html') }); + }); + + test('vrátí pole o délce 5 (jeden záznam na každý pracovní den)', async () => { + const menu = await getMenuSladovnicka(MONDAY); + expect(menu).toHaveLength(5); + }); + + test('pondělní menu obsahuje aspoň jedno jídlo', async () => { + const menu = await getMenuSladovnicka(MONDAY); + expect(menu[0].length).toBeGreaterThan(0); + }); + + test('první položka pondělního dne je polévka (isSoup=true)', async () => { + const menu = await getMenuSladovnicka(MONDAY); + expect(menu[0][0].isSoup).toBe(true); + }); + + test('jídla mají name, price a amount', async () => { + const menu = await getMenuSladovnicka(MONDAY); + const jidlo = menu[0][1]; + expect(jidlo.name).toBeTruthy(); + expect(jidlo.price).toBeTruthy(); + expect(jidlo.amount).toBeTruthy(); + }); + + test('alergeny jsou naparsovány jako čísla', async () => { + const menu = await getMenuSladovnicka(MONDAY); + const polievka = menu[0][0]; + expect(Array.isArray(polievka.allergens)).toBe(true); + expect(polievka.allergens!.length).toBeGreaterThan(0); + expect(typeof polievka.allergens![0]).toBe('number'); + }); +}); + +describe('TechTower parser', () => { + beforeEach(() => { + mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('techtower.html') }); + }); + + test('vrátí pole o délce 5', async () => { + const menu = await getMenuTechTower(MONDAY); + expect(menu).toHaveLength(5); + }); + + test('pondělní menu obsahuje polévku a hlavní jídla', async () => { + const menu = await getMenuTechTower(MONDAY); + expect(menu[0].some(f => f.isSoup)).toBe(true); + expect(menu[0].some(f => !f.isSoup)).toBe(true); + }); + + test('TechTower hodí StaleWeekError, pokud datum v hlavičce neodpovídá', async () => { + // Fixture obsahuje "12.5.-16.5.2025" – jiný týden = stale + const jinaStreda = new Date('2025-04-14'); + await expect(getMenuTechTower(jinaStreda)).rejects.toBeInstanceOf(StaleWeekError); + }); + + test('StaleWeekError obsahuje naparsovaná data', async () => { + const jinaStreda = new Date('2025-04-14'); + try { + await getMenuTechTower(jinaStreda); + } catch (e) { + expect(e).toBeInstanceOf(StaleWeekError); + const err = e as StaleWeekError; + expect(err.food).toHaveLength(5); + } + }); +}); + +describe('SenkSerikova parser', () => { + beforeEach(() => { + // SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string + mockedAxios.get = jest.fn().mockResolvedValue({ + data: Buffer.from(loadFixture('senkserikova.html')), + headers: {} + }); + }); + + test('parser provede HTTP request a vrátí pole', async () => { + const menu = await getMenuSenkSerikova(MONDAY); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(Array.isArray(menu)).toBe(true); + }); + + test('výsledné dny s obsahem mají správnou strukturu (name, price, isSoup)', async () => { + const menu = await getMenuSenkSerikova(MONDAY); + // Protože MONDAY je v minulosti, parser vrátí placeholdery pro všechny pracovní + // dny a .menicka elementy přidá za ně – hledáme aspoň jeden den s reálnými daty + const denSJidlem = menu.find(den => + den.length > 0 && den[0].name !== 'Pro tento den není uveřejněna nabídka jídel' + ); + if (denSJidlem) { + expect(typeof denSJidlem[0].name).toBe('string'); + expect(typeof denSJidlem[0].isSoup).toBe('boolean'); + } + }); +}); diff --git a/server/src/tests/service.test.ts b/server/src/tests/service.test.ts index 9428d51..c0d3549 100644 --- a/server/src/tests/service.test.ts +++ b/server/src/tests/service.test.ts @@ -12,10 +12,18 @@ jest.mock('../storage', () => ({ import { getDateForWeekIndex, getMenuKey, getEmptyData } from '../service'; import { formatDate } from '../utils'; -// MOCK_DATA=true pins "today" to 2025-01-10 (Friday, week 2) +// Pin "today" to 2025-01-10 (Friday, week 2) for deterministic tests // Monday of that week = 2025-01-06, ..., Friday = 2025-01-10 describe('getDateForWeekIndex', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-10')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); test('index 0 (pondělí) vrátí 2025-01-06', () => { expect(formatDate(getDateForWeekIndex(0))).toBe('2025-01-06'); }); diff --git a/server/src/tests/setupEnv.ts b/server/src/tests/setupEnv.ts new file mode 100644 index 0000000..08a9775 --- /dev/null +++ b/server/src/tests/setupEnv.ts @@ -0,0 +1,4 @@ +process.env.NODE_ENV = 'test'; +process.env.STORAGE = 'memory'; +process.env.JWT_SECRET = 'test-jwt-secret-ktery-ma-alespon-32-znaku'; +process.env.LOGOUT_URL = 'http://localhost/logout'; diff --git a/server/src/tests/statsRoutes.test.ts b/server/src/tests/statsRoutes.test.ts new file mode 100644 index 0000000..23f338c --- /dev/null +++ b/server/src/tests/statsRoutes.test.ts @@ -0,0 +1,60 @@ +import express from 'express'; +import request from 'supertest'; +import bodyParser from 'body-parser'; +import { generateToken } from '../auth'; +import { resetMemoryStorage } from '../storage/memory'; +import statsRouter from '../routes/statsRoutes'; + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use('/api/stats', statsRouter); + return app; +} + +const TOKEN = `Bearer ${generateToken('testuser')}`; + +beforeEach(() => { + resetMemoryStorage(); +}); + +test('GET /stats bez parametrů vrátí 400', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .set('Authorization', TOKEN); + expect(res.status).toBe(400); +}); + +test('GET /stats s rozsahem 4 dní vrátí 200', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: '2024-01-08', endDate: '2024-01-12' }) + .set('Authorization', TOKEN); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); +}); + +test('GET /stats s rozsahem nad 4 dní vrátí 400', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: '2024-01-01', endDate: '2024-01-10' }) + .set('Authorization', TOKEN); + expect(res.status).toBe(400); +}); + +test('GET /stats s budoucím datem vrátí 400', async () => { + const futureStart = '2099-01-01'; + const futureEnd = '2099-01-05'; + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: futureStart, endDate: futureEnd }) + .set('Authorization', TOKEN); + expect(res.status).toBe(400); +}); + +test('GET /stats bez tokenu vrátí chybu', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: '2024-01-08', endDate: '2024-01-12' }); + expect(res.status).toBeGreaterThanOrEqual(400); +}); diff --git a/server/src/tests/storage-contract.test.ts b/server/src/tests/storage-contract.test.ts new file mode 100644 index 0000000..cf8717b --- /dev/null +++ b/server/src/tests/storage-contract.test.ts @@ -0,0 +1,85 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import { StorageInterface } from '../storage/StorageInterface'; +import { resetMemoryStorage } from '../storage/memory'; +import MemoryStorage from '../storage/memory'; +import JsonStorage from '../storage/json'; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'luncher-test-')); +const tempDbPath = path.join(tempDir, 'test-db.json'); + +// Parametrické spuštění stejné sady testů pro obě implementace +const implementations: [string, () => StorageInterface, () => void][] = [ + ['MemoryStorage', () => new MemoryStorage(), resetMemoryStorage], + ['JsonStorage', () => { + // Zajistíme čistý stav souboru před každým testem + if (fs.existsSync(tempDbPath)) { + fs.unlinkSync(tempDbPath); + } + // JsonStorage načte/vytvoří soubor při inicializaci, musíme obalit + const JsonStorageDynamic = require('../storage/json').default; + // Přepíšeme dbPath přes prototyp – pro testy použijeme tmpdir + const inst = Object.create(JsonStorageDynamic.prototype); + const JSONdb = require('simple-json-db'); + (inst as any).db = new JSONdb(tempDbPath); + inst.hasData = async (key: string) => Promise.resolve((inst as any).db.has(key)); + inst.getData = async (key: string) => (inst as any).db.get(key); + inst.setData = async (key: string, data: any) => { (inst as any).db.set(key, data); return Promise.resolve(); }; + return inst; + }, () => { + if (fs.existsSync(tempDbPath)) { + fs.unlinkSync(tempDbPath); + } + }], +]; + +describe.each(implementations)('%s splňuje StorageInterface kontrakt', (name, factory, reset) => { + let storage: StorageInterface; + + beforeEach(() => { + reset(); + storage = factory(); + }); + + test('hasData vrátí false pro neexistující klíč', async () => { + expect(await storage.hasData('neexistujici')).toBe(false); + }); + + test('setData + hasData vrátí true', async () => { + await storage.setData('klic', { value: 1 }); + expect(await storage.hasData('klic')).toBe(true); + }); + + test('setData + getData vrátí uložená data', async () => { + const data = { name: 'Jan', score: 42 }; + await storage.setData('testkey', data); + const result = await storage.getData('testkey'); + expect(result).toEqual(data); + }); + + test('getData pro neexistující klíč vrátí undefined', async () => { + const result = await storage.getData('neexistujici'); + expect(result).toBeUndefined(); + }); + + test('setData přepíše existující data', async () => { + await storage.setData('klic', { version: 1 }); + await storage.setData('klic', { version: 2 }); + const result = await storage.getData<{ version: number }>('klic'); + expect(result?.version).toBe(2); + }); + + test('různé klíče jsou nezávislé', async () => { + await storage.setData('a', { val: 'A' }); + await storage.setData('b', { val: 'B' }); + expect((await storage.getData<{ val: string }>('a'))?.val).toBe('A'); + expect((await storage.getData<{ val: string }>('b'))?.val).toBe('B'); + }); +}); + +afterAll(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } +}); diff --git a/server/src/tests/voting.test.ts b/server/src/tests/voting.test.ts index 2c6d8ab..d25e294 100644 --- a/server/src/tests/voting.test.ts +++ b/server/src/tests/voting.test.ts @@ -1,62 +1,73 @@ +import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting'; +import { resetMemoryStorage } from '../storage/memory'; import { FeatureRequest } from '../../../types/gen/types.gen'; -const mockStorageData = new Map(); -jest.mock('../storage', () => ({ - __esModule: true, - default: () => ({ - hasData: async (key: string) => mockStorageData.has(key), - getData: async (key: string) => mockStorageData.get(key) as T, - setData: async (key: string, val: T) => void mockStorageData.set(key, val), - }), - storageReady: Promise.resolve(), -})); +const OPT_A = FeatureRequest.STATISTICS; +const OPT_B = FeatureRequest.UI; -import { updateFeatureVote, getVotingStats } from '../voting'; - -beforeEach(() => mockStorageData.clear()); +beforeEach(() => { + resetMemoryStorage(); +}); describe('updateFeatureVote', () => { - const feat = 'FEATURE_A' as FeatureRequest; - test('přidá hlas pro nového uživatele', async () => { - const result = await updateFeatureVote('alice', feat, true); - expect(result['alice']).toContain(feat); + 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', feat, true); - await expect(updateFeatureVote('alice', feat, true)).rejects.toThrow('hlasovali'); + await updateFeatureVote('alice', OPT_A, true); + await expect(updateFeatureVote('alice', OPT_A, true)).rejects.toThrow('hlasovali'); }); test('odebere hlas', async () => { - await updateFeatureVote('alice', feat, true); - await updateFeatureVote('alice', feat, false); + await updateFeatureVote('alice', OPT_A, true); + await updateFeatureVote('alice', OPT_A, false); const stats = await getVotingStats(); - expect(stats[feat] ?? 0).toBe(0); + expect(stats[OPT_A] ?? 0).toBe(0); }); test('odebrání neexistujícího hlasu je no-op', async () => { - await expect(updateFeatureVote('alice', feat, false)).resolves.not.toThrow(); + 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 features = ['FA', 'FB', 'FC', 'FD'] as FeatureRequest[]; - for (const f of features) { - await updateFeatureVote('alice', f, true); + const options = Object.values(FeatureRequest); + for (let i = 0; i < 4; i++) { + await updateFeatureVote('alice', options[i], true); } - await expect(updateFeatureVote('alice', 'FE' as FeatureRequest, true)).rejects.toThrow('4'); + 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', 'FA' as FeatureRequest, true); - await updateFeatureVote('bob', 'FA' as FeatureRequest, true); - await updateFeatureVote('bob', 'FB' as FeatureRequest, true); + await updateFeatureVote('alice', OPT_A, true); + await updateFeatureVote('bob', OPT_A, true); + await updateFeatureVote('bob', OPT_B, true); const stats = await getVotingStats(); - expect(stats['FA']).toBe(2); - expect(stats['FB']).toBe(1); + expect(stats[OPT_A]).toBe(2); + expect(stats[OPT_B]).toBe(1); }); test('vrátí prázdný objekt bez hlasů', async () => { diff --git a/server/src/tests/votingRoutes.test.ts b/server/src/tests/votingRoutes.test.ts new file mode 100644 index 0000000..46cad73 --- /dev/null +++ b/server/src/tests/votingRoutes.test.ts @@ -0,0 +1,76 @@ +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'); +}); diff --git a/server/yarn.lock b/server/yarn.lock index d4e1fad..719efc3 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1448,6 +1448,18 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@noble/hashes@^1.1.5": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@paralleldrive/cuid2@^2.2.2": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz#3d62ea9e7be867d3fa94b9897fab5b0ae187d784" + integrity sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== + dependencies: + "@noble/hashes" "^1.1.5" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -1594,6 +1606,11 @@ dependencies: "@types/node" "*" +"@types/cookiejar@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" + integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== + "@types/cors@^2.8.12": version "2.8.19" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" @@ -1660,6 +1677,11 @@ "@types/ms" "*" "@types/node" "*" +"@types/methods@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" + integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ== + "@types/ms@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" @@ -1727,6 +1749,24 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/superagent@^8.1.0": + version "8.1.9" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-8.1.9.tgz#28bfe4658e469838ed0bf66d898354bcab21f49f" + integrity sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ== + dependencies: + "@types/cookiejar" "^2.1.5" + "@types/methods" "^1.1.4" + "@types/node" "*" + form-data "^4.0.0" + +"@types/supertest@^6.0.0": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-6.0.3.tgz#d736f0e994b195b63e1c93e80271a2faf927388c" + integrity sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w== + dependencies: + "@types/methods" "^1.1.4" + "@types/superagent" "^8.1.0" + "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -1940,6 +1980,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + asn1.js@^5.3.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -2294,6 +2339,11 @@ combined-stream@^1.0.6, combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +component-emitter@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2314,7 +2364,7 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie-signature@^1.2.1: +cookie-signature@^1.2.1, cookie-signature@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== @@ -2324,6 +2374,11 @@ cookie@^0.7.1, cookie@~0.7.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + core-js-compat@^3.43.0: version "3.47.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.47.0.tgz#698224bbdbb6f2e3f39decdda4147b161e3772a3" @@ -2369,7 +2424,7 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== -debug@4, debug@^4, debug@^4.1.0, debug@^4.3.1, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: +debug@4, debug@^4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2408,6 +2463,14 @@ detect-newline@^3.1.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -2681,6 +2744,11 @@ fast-json-stable-stringify@^2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -2738,6 +2806,17 @@ form-data@^2.5.0: mime-types "^2.1.12" safe-buffer "^5.2.1" +form-data@^4.0.0, form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" @@ -2749,6 +2828,15 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +formidable@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.4.tgz#ac9a593b951e829b3298f21aa9a2243932f32ed9" + integrity sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== + dependencies: + "@paralleldrive/cuid2" "^2.2.2" + dezalgo "^1.0.4" + once "^1.4.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -3624,6 +3712,11 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== +methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" @@ -3656,6 +3749,11 @@ mime-types@^3.0.0, mime-types@^3.0.2: dependencies: mime-db "^1.54.0" +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -4337,6 +4435,30 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +superagent@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-10.3.0.tgz#ff1e39e7976b63f8084291d65f5bfbbbbd156989" + integrity sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ== + dependencies: + component-emitter "^1.3.1" + cookiejar "^2.1.4" + debug "^4.3.7" + fast-safe-stringify "^2.1.1" + form-data "^4.0.5" + formidable "^3.5.4" + methods "^1.1.2" + mime "2.6.0" + qs "^6.14.1" + +supertest@^7.0.0: + version "7.2.2" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.2.2.tgz#dac3ee25a2aa59942a7f641e50c838a7c8819204" + integrity sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA== + dependencies: + cookie-signature "^1.2.2" + methods "^1.1.2" + superagent "^10.3.0" + supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"