diff --git a/.gitignore b/.gitignore index 90dc6fd..34dc9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules types/gen -**.DS_Store \ No newline at end of file +**.DS_Store +.mcp.json +.claude/settings.local.json +server/public/ diff --git a/.woodpecker/workflow.yaml b/.woodpecker/workflow.yaml index bfbff41..06bad7c 100644 --- a/.woodpecker/workflow.yaml +++ b/.woodpecker/workflow.yaml @@ -1,10 +1,18 @@ variables: - &node_image "node:22-alpine" + - &playwright_image "mcr.microsoft.com/playwright:v1.59.1-jammy" - &branch "master" +# Spustit na všech větvích a pull requestech. +# Docker build probíhá jen na master větvi (viz when: v posledních krocích). when: - - event: push - branch: *branch + - event: [push, pull_request] + +services: + redis: + image: redis/redis-stack-server:7.2.0-RC3 + environment: + REDIS_ARGS: "--save '' --loglevel warning" steps: - name: Generate TypeScript types @@ -13,33 +21,81 @@ steps: - cd types - yarn install --frozen-lockfile - yarn openapi-ts + - name: Install server dependencies image: *node_image commands: - cd server - yarn install --frozen-lockfile depends_on: [Generate TypeScript types] + - name: Install client dependencies image: *node_image commands: - cd client - yarn install --frozen-lockfile depends_on: [Generate TypeScript types] - - name: Build server + + - name: Install e2e dependencies + image: *playwright_image + commands: + - cd e2e + - yarn install --frozen-lockfile + depends_on: [Generate TypeScript types] + + - name: Server unit tests + image: *node_image + environment: + NODE_ENV: test + JWT_SECRET: test-secret-min-32-chars-aaaaaaa! + MOCK_DATA: "true" + STORAGE: json + commands: + - cd server + - yarn test depends_on: [Install server dependencies] + + - name: Build server image: *node_image commands: - cd server - yarn build + depends_on: [Install server dependencies] + - name: Build client - depends_on: [Install client dependencies] image: *node_image commands: - cd client - yarn build + depends_on: [Install client dependencies] + + - name: Playwright E2E tests + image: *playwright_image + environment: + CI: "true" + NODE_ENV: test + JWT_SECRET: test-secret-min-32-chars-aaaaaaa! + MOCK_DATA: "true" + STORAGE: redis + REDIS_HOST: redis + REDIS_PORT: "6379" + HTTP_REMOTE_USER_ENABLED: "true" + HTTP_REMOTE_USER_HEADER_NAME: remote-user + HTTP_REMOTE_TRUSTED_IPS: "0.0.0.0/0,::/0" + commands: + # Zkopírujeme build klienta do server/public, aby Express mohl SPA servírovat + - cp -r client/dist server/public + - cd e2e + - yarn playwright install firefox --with-deps + - yarn test + depends_on: [Build server, Build client, Install e2e dependencies] + - name: Build Docker image depends_on: [Build server, Build client] image: woodpeckerci/plugin-docker-buildx + when: + - event: push + branch: *branch settings: dockerfile: Dockerfile-Woodpecker platforms: linux/amd64 @@ -51,11 +107,14 @@ steps: from_secret: REPO_PASSWORD repo: from_secret: REPO_NAME + - name: Discord notification - build image: appleboy/drone-discord depends_on: [Build Docker image] when: - status: [success, failure] + event: push + branch: *branch settings: webhook_id: from_secret: DISCORD_WEBHOOK_ID diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..945fcd0 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..5f1ef37 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@luncher/e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:debug": "playwright test --debug", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..9301772 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,60 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1 +// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer +// readiness poll to time out even though the server is listening. +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001'; + +// Server env vars injected for local runs. In CI these are set at the step level. +const serverEnv: Record = { + NODE_ENV: 'test', + MOCK_DATA: 'true', + STORAGE: process.env.STORAGE ?? 'json', + JWT_SECRET: process.env.JWT_SECRET ?? 'e2e-test-secret-min-32-chars-aaaa', + HTTP_REMOTE_USER_ENABLED: 'true', + HTTP_REMOTE_USER_HEADER_NAME: 'remote-user', + HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1', +}; +if (process.env.REDIS_HOST) { + serverEnv.REDIS_HOST = process.env.REDIS_HOST; + serverEnv.REDIS_PORT = process.env.REDIS_PORT ?? '6379'; +} + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: BASE_URL, + // Default: every test authenticates as e2e-user via trusted header. + // Tests that need the real login form should override this in their own context. + extraHTTPHeaders: { + 'remote-user': 'e2e-user', + }, + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + ], + // Pre-built server must be started before tests. In CI the step does this + // explicitly. Locally: build types+server+client, cp -r client/dist server/public, + // then `cd e2e && yarn test` OR let webServer below do it if reuseExistingServer=true + // is set and the server is already running. + webServer: { + command: 'node dist/server/src/index.js', + cwd: path.resolve(__dirname, '../server'), + // Poll a dedicated health endpoint — polling '/' can stall in Express 5 when + // server/public/ doesn't exist in the working directory (no finalhandler match). + url: `http://127.0.0.1:3001/api/health`, + timeout: 15_000, + reuseExistingServer: !process.env.CI, + env: serverEnv, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 0000000..5835a83 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,21 @@ +import { Page, APIRequestContext } from '@playwright/test'; + +/** Přihlásí uživatele přes POST /api/login a uloží JWT do localStorage. */ +export async function loginViaApi(page: Page, login: string): Promise { + const response = await page.request.post('/api/login', { + headers: { 'Content-Type': 'application/json', 'remote-user': login }, + data: {}, + }); + const token = await response.json() as string; + await page.goto('/'); + await page.evaluate((t) => localStorage.setItem('token', t), token); +} + +/** Vyčistí stav pizza dne pro zadaný dayIndex (0=pondělí…4=pátek) přes dev API. */ +export async function clearPizzaDay(request: APIRequestContext): Promise { + const today = new Date('2025-01-10'); // MOCK_DATA pins to Friday = dayIndex 4 + await request.post('/api/dev/clear', { + headers: { 'Content-Type': 'application/json', 'remote-user': 'e2e-user' }, + data: { dayIndex: 4 }, + }); +} diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 0000000..0df71d0 --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +// Tento test záměrně NEPOUŽÍVÁ trusted-header – testuje reálný login formulář. +test.use({ extraHTTPHeaders: {} }); + +test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => { + // Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje + // hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login) + // a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže, + // čímž formulář zůstane viditelný. + await page.route('**/api/login', async (route) => { + const body = route.request().postData(); + let login: string | undefined; + try { login = body ? JSON.parse(body)?.login : undefined; } catch {} + await route.continue({ + headers: login + ? { ...route.request().headers(), 'remote-user': login } + : route.request().headers(), + }); + }); + + await page.goto('/'); + + // Formulář musí být viditelný – auto-login selhal (nepřišla hlavička) + const loginInput = page.locator('#login-input'); + await expect(loginInput).toBeVisible({ timeout: 10_000 }); + + // Vyplnění loginu a odeslání Enterem + await loginInput.fill('testuser'); + await loginInput.press('Enter'); + + // Po přihlášení musí zmizet login formulář + await expect(loginInput).not.toBeVisible({ timeout: 10_000 }); + + // JWT musí být uloženo v localStorage jako 3-dílný token + const token = await page.evaluate(() => localStorage.getItem('token')); + expect(token).toBeTruthy(); + expect((token as string).split('.')).toHaveLength(3); +}); + +test('trusted-header přihlášení proběhne automaticky bez formuláře', async ({ page, context }) => { + // Obnoví trusted header (přepíše prázdný extraHTTPHeaders z test.use výše) + await context.setExtraHTTPHeaders({ 'remote-user': 'e2e-auto-user' }); + await page.goto('/'); + + // Login formulář by se neměl nikdy zobrazit, nebo se ihned schová + await page.waitForLoadState('networkidle'); + const loginInput = page.locator('#login-input'); + await expect(loginInput).not.toBeVisible({ timeout: 5_000 }); +}); diff --git a/e2e/tests/pick-food.spec.ts b/e2e/tests/pick-food.spec.ts new file mode 100644 index 0000000..090ee20 --- /dev/null +++ b/e2e/tests/pick-food.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { clearPizzaDay } from './helpers'; + +test.beforeEach(async ({ page, request }) => { + // Vyčistíme volby dne, aby testy neovlivnily navzájem + await request.post('/api/dev/clear', { + data: { dayIndex: 4 }, + }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + // Počkáme, až se zobrazí volba stravování + await expect(page.locator('.choice-section select').first()).toBeVisible({ timeout: 10_000 }); +}); + +test('výběr restaurace zobrazí seznam jídel', async ({ page }) => { + const locationSelect = page.locator('.choice-section select').first(); + + // Vybereme Sladovnickou – mock menu existuje + await locationSelect.selectOption('SLADOVNICKA'); + + // Po výběru restaurace se zobrazí druhý select s jídly + const foodSelect = page.locator('.choice-section select').nth(1); + await expect(foodSelect).toBeVisible({ timeout: 5_000 }); + + // Select musí obsahovat alespoň 2 možnosti (empty + ≥1 jídlo) + const options = foodSelect.locator('option'); + expect(await options.count()).toBeGreaterThan(1); +}); + +test('výběr jídla se uloží a přetrvá po reload', async ({ page }) => { + const locationSelect = page.locator('.choice-section select').first(); + await locationSelect.selectOption('SLADOVNICKA'); + + const foodSelect = page.locator('.choice-section select').nth(1); + await expect(foodSelect).toBeVisible({ timeout: 5_000 }); + + // Vybereme první nenulovou možnost + const options = await foodSelect.locator('option:not([value=""])').all(); + if (options.length === 0) { + test.skip(); // Mock data nejsou dostupná pro tuto restauraci + } + const firstValue = await options[0].getAttribute('value'); + await foodSelect.selectOption({ value: firstValue! }); + + // Počkáme, až se volba přenese na server + await page.waitForLoadState('networkidle'); + + // Po reload musí volba přetrvat v tabulce choices + await page.reload(); + await page.waitForLoadState('networkidle'); + const choicesTable = page.locator('.choices-table'); + await expect(choicesTable).toBeVisible({ timeout: 5_000 }); + await expect(choicesTable.locator('text=Sladovnická')).toBeVisible(); +}); + +test('přepnutí na NEOBEDVAM odstraní výběr restaurace', async ({ page }) => { + // Nejprve zvolíme restauraci + const locationSelect = page.locator('.choice-section select').first(); + await locationSelect.selectOption('SLADOVNICKA'); + await page.waitForLoadState('networkidle'); + + // Přepneme na "Neobědvám" + await locationSelect.selectOption('NEOBEDVAM'); + await page.waitForLoadState('networkidle'); + + // Tabulka choices musí zobrazovat "Neobědvám" + const choicesTable = page.locator('.choices-table'); + await expect(choicesTable).toBeVisible({ timeout: 5_000 }); + await expect(choicesTable.locator('text=Neobědvám')).toBeVisible(); +}); diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts new file mode 100644 index 0000000..792dd89 --- /dev/null +++ b/e2e/tests/pizza-day.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +// Pizza day testy musí běžet sekvenčně (sdílejí stav mock dne) +test.describe.serial('pizza day životní cyklus', () => { + + test.beforeEach(async ({ request }) => { + // Vyčistíme data mock dne před každým testem + await request.post('/api/dev/clear', { data: { dayIndex: 4 } }); + }); + + test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const pizzaSection = page.locator('.pizza-section'); + await expect(pizzaSection).toBeVisible({ timeout: 10_000 }); + await expect(pizzaSection.locator('text=není aktuálně založen')).toBeVisible(); + }); + + test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // --- CREATED --- + const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); + await expect(createBtn).toBeVisible({ timeout: 10_000 }); + await createBtn.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.pizza-section')).toContainText('spravován uživatelem', { timeout: 5_000 }); + + // Přidáme pizzu přes API (obejde komplex SelectSearch) + const token = await page.evaluate(() => localStorage.getItem('token')); + const addResp = await page.request.post('/api/pizza/add', { + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { pizzaIndex: 0, pizzaSizeIndex: 0 }, + }); + expect(addResp.ok()).toBeTruthy(); + + // Reload – server aktualizoval data přes WebSocket, ale reload je jistější + await page.reload(); + await page.waitForLoadState('networkidle'); + + // --- LOCK --- + const lockBtn = page.locator('.pizza-section button', { hasText: 'Uzamknout' }); + await expect(lockBtn).toBeEnabled({ timeout: 5_000 }); + await lockBtn.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.pizza-section')).toContainText('uzamčeny', { timeout: 5_000 }); + + // --- ORDERED --- + const orderBtn = page.locator('.pizza-section button', { hasText: 'Objednáno' }); + await expect(orderBtn).toBeEnabled({ timeout: 5_000 }); + await orderBtn.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.pizza-section')).toContainText('objednány', { timeout: 5_000 }); + + // --- DELIVERED --- + const deliverBtn = page.locator('.pizza-section button', { hasText: 'Doručeno' }); + await expect(deliverBtn).toBeVisible({ timeout: 5_000 }); + // window.confirm dialog − Playwright automaticky potvrdí + page.on('dialog', dialog => dialog.accept()); + await deliverBtn.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.pizza-section')).toContainText('doručeny', { timeout: 5_000 }); + }); +}); diff --git a/e2e/tests/qr.spec.ts b/e2e/tests/qr.spec.ts new file mode 100644 index 0000000..b8a0ceb --- /dev/null +++ b/e2e/tests/qr.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page, request }) => { + // Naseedujeme 5 uživatelů pro dnešní den – GenerateQrModal pracuje se stávajícími choices + await request.post('/api/dev/generate', { data: { dayIndex: 4, count: 5 } }); + + // Přednastavíme bankovní účet v localStorage (SettingsContext čte z LS při inicializaci) + await page.goto('/'); + await page.evaluate(() => { + localStorage.setItem('bank_account_number', '2400000000/2010'); + localStorage.setItem('bank_account_holder_name', 'Test User'); + }); + // Reload tak, aby SettingsContext načetl nové hodnoty z localStorage + await page.reload(); + await page.waitForLoadState('networkidle'); +}); + +test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ page }) => { + // Otevření nastavení + await page.locator('#basic-nav-dropdown').click(); + await page.locator('text=Nastavení').click(); + + // Modal musí být viditelný + await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 }); + + // Změníme číslo účtu + const accountInput = page.getByPlaceholder('123456-1234567890/1234'); + await accountInput.clear(); + await accountInput.fill('1234567890/5500'); + + // Změníme jméno + const nameInput = page.getByPlaceholder('Jan Novák'); + await nameInput.clear(); + await nameInput.fill('Nové Jméno'); + + // Uložíme + await page.locator('.modal-footer button', { hasText: 'Uložit' }).click(); + + // Ověříme v localStorage + const bankAccount = await page.evaluate(() => localStorage.getItem('bank_account_number')); + const holderName = await page.evaluate(() => localStorage.getItem('bank_account_holder_name')); + expect(bankAccount).toBe('1234567890/5500'); + expect(holderName).toBe('Nové Jméno'); +}); + +test('otevře modal Generování QR kódů pokud je nastaven účet', async ({ page }) => { + // Otevření dropdown menu + await page.locator('#basic-nav-dropdown').click(); + await page.locator('text=Generování QR kódů').click(); + + // Modal se otevře + await expect(page.locator('.modal')).toBeVisible({ timeout: 5_000 }); + // Modal musí obsahovat seznam uživatelů nebo prázdný stav + await expect(page.locator('.modal-body')).toBeVisible(); +}); + +test('upozorní pokud není nastaven bankovní účet', async ({ page }) => { + // Odebereme nastavení účtu + await page.evaluate(() => { + localStorage.removeItem('bank_account_number'); + localStorage.removeItem('bank_account_holder_name'); + }); + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Dialog místo modalu + page.on('dialog', async dialog => { + expect(dialog.message()).toContain('číslo účtu'); + await dialog.accept(); + }); + + await page.locator('#basic-nav-dropdown').click(); + await page.locator('text=Generování QR kódů').click(); + + // Modal se NESMÍ otevřít + await expect(page.locator('.modal')).not.toBeVisible({ timeout: 3_000 }); +}); diff --git a/e2e/tests/view-menus.spec.ts b/e2e/tests/view-menus.spec.ts new file mode 100644 index 0000000..a8f6b8e --- /dev/null +++ b/e2e/tests/view-menus.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + // Trusted-header login runs automatically when Login mounts. + // networkidle zaručí, že fetch('/api/data') byl dokončen. + await page.goto('/'); + await page.waitForLoadState('networkidle'); +}); + +test('zobrazí mock datum 10.01.2025', async ({ page }) => { + // MOCK_DATA=true pins today to 2025-01-10 + await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 }); +}); + +test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => { + // Každá restaurace je obalena v .restaurant-card + const cards = page.locator('.restaurant-card'); + await expect(cards).toHaveCount(4, { timeout: 10_000 }); +}); + +test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => { + await expect(page.locator('.restaurant-card').first()).toBeVisible({ timeout: 10_000 }); + + // Každá karta musí mít aspoň jeden řádek v .food-table + const cards = page.locator('.restaurant-card'); + const count = await cards.count(); + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + const rows = card.locator('.food-table tr'); + expect(await rows.count()).toBeGreaterThan(0); + } +}); + +test('zobrazí volbu stravování před menu', async ({ page }) => { + // Sekce .choice-section obsahuje select pro výběr stravování + const choiceSection = page.locator('.choice-section'); + await expect(choiceSection).toBeVisible({ timeout: 10_000 }); + await expect(choiceSection.locator('select').first()).toBeVisible(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..9b8b996 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/e2e/yarn.lock b/e2e/yarn.lock new file mode 100644 index 0000000..91d4403 --- /dev/null +++ b/e2e/yarn.lock @@ -0,0 +1,46 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@playwright/test@^1.50.0": + version "1.59.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6" + integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg== + dependencies: + playwright "1.59.1" + +"@types/node@^22.0.0": + version "22.19.17" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.17.tgz#09c71fb34ba2510f8ac865361b1fcb9552b8a581" + integrity sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q== + dependencies: + undici-types "~6.21.0" + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +playwright-core@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2" + integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg== + +playwright@1.59.1: + version "1.59.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a" + integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw== + dependencies: + playwright-core "1.59.1" + optionalDependencies: + fsevents "2.3.2" + +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== diff --git a/server/jest.config.js b/server/jest.config.js new file mode 100644 index 0000000..81185ff --- /dev/null +++ b/server/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['/src/tests/**/*.test.ts'], + setupFiles: ['/src/tests/helpers/setupEnv.ts'], +}; diff --git a/server/src/index.ts b/server/src/index.ts index 59c2533..4fce188 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,7 @@ import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToke import { getPendingQrs } from "./pizza"; import { initWebsocket } from "./websocket"; import { startReminderScheduler } from "./pushReminder"; +import { storageReady } from "./storage"; import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import votingRoutes from "./routes/votingRoutes"; @@ -56,6 +57,10 @@ if (HTTP_REMOTE_USER_ENABLED) { // ----------- Metody nevyžadující token -------------- +app.get("/api/health", (_req, res) => { + res.status(200).json({ ok: true }); +}); + app.get("/api/whoami", (req, res) => { if (!HTTP_REMOTE_USER_ENABLED) { res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' }); @@ -189,9 +194,11 @@ app.use((err: any, req: any, res: any, next: any) => { const PORT = process.env.PORT ?? 3001; const HOST = process.env.HOST ?? '0.0.0.0'; -server.listen(PORT, () => { - console.log(`Server listening on ${HOST}, port ${PORT}`); - startReminderScheduler(); +storageReady.then(() => { + server.listen(PORT, () => { + console.log(`Server listening on ${HOST}, port ${PORT}`); + startReminderScheduler(); + }); }); // Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí diff --git a/server/src/mock.ts b/server/src/mock.ts index 6620fbf..db57404 100644 --- a/server/src/mock.ts +++ b/server/src/mock.ts @@ -1458,26 +1458,17 @@ export const getSalatListMock = () => { } export const getStatsMock = (): WeeklyStats => { + const mkDay = (date: string, di: number) => ({ + date, + locations: Object.keys(LunchChoice).reduce((prev, cur, ci) => ( + { ...prev, [cur]: (di * 7 + ci * 3) % 10 } + ), {} as Record), + }); return [ - { - date: '24.02.', - locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) } - }, - { - date: '25.02.', - locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) } - }, - { - date: '26.02.', - locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) } - }, - { - date: '27.02.', - locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) } - }, - { - date: '28.02.', - locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) } - } + mkDay('24.02.', 0), + mkDay('25.02.', 1), + mkDay('26.02.', 2), + mkDay('27.02.', 3), + mkDay('28.02.', 4), ]; } \ No newline at end of file diff --git a/server/src/restaurants.ts b/server/src/restaurants.ts index eeb311c..2eb0d1e 100644 --- a/server/src/restaurants.ts +++ b/server/src/restaurants.ts @@ -40,7 +40,7 @@ const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.h * @param text vstupní text * @returns true, pokud text představuje polévku */ -const isTextSoupName = (text: string): boolean => { +export const isTextSoupName = (text: string): boolean => { for (const name of SOUP_NAMES) { if (text.toLowerCase().includes(name)) { return true; @@ -49,11 +49,11 @@ const isTextSoupName = (text: string): boolean => { return false; } -const capitalize = (word: string): string => { +export const capitalize = (word: string): string => { return word.charAt(0).toUpperCase() + word.slice(1); } -const sanitizeText = (text: string): string => { +export const sanitizeText = (text: string): string => { return text.replace('\t', '').replace(' , ', ', ').trim(); } @@ -64,7 +64,7 @@ const sanitizeText = (text: string): string => { * @param name původní název jídla * @returns objekt obsahující vyčištěný název a pole alergenů */ -const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => { +export const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => { // Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/; const match = regex.exec(name); diff --git a/server/src/routes/devRoutes.ts b/server/src/routes/devRoutes.ts index b717b5f..631684a 100644 --- a/server/src/routes/devRoutes.ts +++ b/server/src/routes/devRoutes.ts @@ -42,7 +42,7 @@ const RESTAURANTS_WITH_MENU = [ * Middleware pro kontrolu DEV režimu */ function requireDevMode(req: any, res: any, next: any) { - if (ENVIRONMENT !== 'development') { + if (ENVIRONMENT !== 'development' && ENVIRONMENT !== 'test') { return res.status(403).json({ error: 'Tento endpoint je dostupný pouze ve vývojovém režimu' }); } next(); diff --git a/server/src/service.ts b/server/src/service.ts index 2c02c81..4774662 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -29,7 +29,7 @@ export const getDateForWeekIndex = (index: number) => { } /** Vrátí "prázdná" (implicitní) data pro předaný den. */ -function getEmptyData(date?: Date): ClientData { +export function getEmptyData(date?: Date): ClientData { const usedDate = date || getToday(); return { todayDayIndex: getDayOfWeekIndex(getToday()), @@ -61,7 +61,7 @@ export async function getData(date?: Date): Promise { * @param date datum * @returns databázový klíč */ -function getMenuKey(date: Date) { +export function getMenuKey(date: Date) { const weekNumber = getWeekNumber(date); return `${MENU_PREFIX}_${date.getFullYear()}_${weekNumber}`; } diff --git a/server/src/storage/index.ts b/server/src/storage/index.ts index 3ac8483..bdb31f7 100644 --- a/server/src/storage/index.ts +++ b/server/src/storage/index.ts @@ -19,12 +19,9 @@ if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) { throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'"); } -(async () => { - if (storage.initialize) { - await storage.initialize(); - } -})(); - +export const storageReady: Promise = storage.initialize + ? storage.initialize() + : Promise.resolve(); export default function getStorage(): StorageInterface { return storage; diff --git a/server/src/storage/redis.ts b/server/src/storage/redis.ts index f92720e..bd158c7 100644 --- a/server/src/storage/redis.ts +++ b/server/src/storage/redis.ts @@ -14,7 +14,7 @@ export default class RedisStorage implements StorageInterface { } async initialize() { - client.connect(); + await client.connect(); } async hasData(key: string) { diff --git a/server/src/tests/auth.test.ts b/server/src/tests/auth.test.ts new file mode 100644 index 0000000..afbea8e --- /dev/null +++ b/server/src/tests/auth.test.ts @@ -0,0 +1,79 @@ +import { generateToken, verify, getLogin, getTrusted } from '../auth'; + +const VALID_SECRET = 'test-secret-min-32-chars-aaaaaaa!'; + +beforeEach(() => { + process.env.JWT_SECRET = VALID_SECRET; +}); + +afterEach(() => { + delete process.env.JWT_SECRET; +}); + +describe('generateToken', () => { + test('vrátí token pro platný login', () => { + const token = generateToken('alice'); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + + test('vyhodí chybu bez JWT_SECRET', () => { + delete process.env.JWT_SECRET; + expect(() => generateToken('alice')).toThrow('JWT_SECRET'); + }); + + test('vyhodí chybu pro příliš krátký JWT_SECRET', () => { + process.env.JWT_SECRET = 'short'; + expect(() => generateToken('alice')).toThrow('32'); + }); + + test('vyhodí chybu pro prázdný login', () => { + expect(() => generateToken('')).toThrow('login'); + expect(() => generateToken(' ')).toThrow('login'); + }); + + test('vyhodí chybu pro chybějící login', () => { + expect(() => generateToken(undefined)).toThrow('login'); + }); +}); + +describe('verify', () => { + test('vrátí true pro platný token', () => { + const token = generateToken('alice'); + expect(verify(token)).toBe(true); + }); + + test('vrátí false pro podvrženou signaturu', () => { + const token = generateToken('alice'); + const tampered = token.slice(0, -5) + 'XXXXX'; + expect(verify(tampered)).toBe(false); + }); + + test('vrátí false pro token podepsaný jiným secret', () => { + process.env.JWT_SECRET = 'other-secret-min-32-chars-bbbbb!'; + const tokenOther = generateToken('alice'); + process.env.JWT_SECRET = VALID_SECRET; + expect(verify(tokenOther)).toBe(false); + }); +}); + +describe('getLogin / getTrusted', () => { + test('round-trip: getLogin vrátí správný login', () => { + const token = generateToken('bob'); + expect(getLogin(token)).toBe('bob'); + }); + + test('trusted=false je výchozí hodnota', () => { + const token = generateToken('alice'); + expect(getTrusted(token)).toBe(false); + }); + + test('trusted=true je zachováno', () => { + const token = generateToken('alice', true); + expect(getTrusted(token)).toBe(true); + }); + + test('getLogin vyhodí chybu pro chybějící token', () => { + expect(() => getLogin(undefined)).toThrow('token'); + }); +}); diff --git a/server/src/tests/helpers/setupEnv.ts b/server/src/tests/helpers/setupEnv.ts new file mode 100644 index 0000000..174392a --- /dev/null +++ b/server/src/tests/helpers/setupEnv.ts @@ -0,0 +1,4 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-min-32-chars-aaaaaaa!'; +process.env.MOCK_DATA = 'true'; +process.env.STORAGE = 'json'; diff --git a/server/src/tests/pizza.test.ts b/server/src/tests/pizza.test.ts new file mode 100644 index 0000000..383833f --- /dev/null +++ b/server/src/tests/pizza.test.ts @@ -0,0 +1,148 @@ +import { PizzaDayState, PizzaSize } 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() })); +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 }] }, + ]), + downloadSalaty: jest.fn().mockResolvedValue([]), +})); + +import { + createPizzaDay, + deletePizzaDay, + lockPizzaDay, + unlockPizzaDay, + finishPizzaOrder, + finishPizzaDelivery, + addPizzaOrder, + removeAllUserPizzas, +} from '../pizza'; + +const PIZZA: any = { id: 1, name: 'Margherita', variants: [] }; +const SIZE: PizzaSize = { varId: 10, size: 'střední', price: 150 }; + +beforeEach(() => mockStorageData.clear()); + +describe('createPizzaDay', () => { + test('vytvoří pizza day ve stavu CREATED', async () => { + const data = await createPizzaDay('alice'); + expect(data.pizzaDay?.state).toBe(PizzaDayState.CREATED); + expect(data.pizzaDay?.creator).toBe('alice'); + }); + + test('vyhodí chybu, pokud pizza day již existuje', async () => { + await createPizzaDay('alice'); + await expect(createPizzaDay('alice')).rejects.toThrow('existuje'); + }); +}); + +describe('deletePizzaDay', () => { + test('smaže pizza day tvůrcem', async () => { + await createPizzaDay('alice'); + const data = await deletePizzaDay('alice'); + expect(data.pizzaDay).toBeUndefined(); + }); + + test('vyhodí chybu pro jiného uživatele', async () => { + await createPizzaDay('alice'); + await expect(deletePizzaDay('bob')).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); + }); + + test('vyhodí chybu bez aktivního pizza day', async () => { + await expect(addPizzaOrder('bob', PIZZA, SIZE)).rejects.toThrow('neexistuje'); + }); +}); + +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); + }); + + 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); + }); +}); + +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(); + }); + + test('je no-op bez pizza day', async () => { + const data = await removeAllUserPizzas('bob'); + expect(data.pizzaDay).toBeUndefined(); + }); +}); diff --git a/server/src/tests/restaurants.test.ts b/server/src/tests/restaurants.test.ts new file mode 100644 index 0000000..511941c --- /dev/null +++ b/server/src/tests/restaurants.test.ts @@ -0,0 +1,106 @@ +import { isTextSoupName, capitalize, sanitizeText, parseAllergens } from '../restaurants'; + +describe('isTextSoupName', () => { + test('rozpozná "polévka"', () => { + expect(isTextSoupName('Polévka dne')).toBe(true); + }); + + test('rozpozná "česnečka"', () => { + expect(isTextSoupName('Česnečka s krutony')).toBe(true); + }); + + test('rozpozná "vývar"', () => { + expect(isTextSoupName('Hovězí vývar s nudlemi')).toBe(true); + }); + + test('rozpozná "slepičí s " (parciální shoda pro slepičí vývar)', () => { + expect(isTextSoupName('Slepičí s nudlemi')).toBe(true); + }); + + test('neklasifikuje hlavní jídlo jako polévku', () => { + expect(isTextSoupName('Svíčková na smetaně s knedlíky')).toBe(false); + }); + + test('neklasifikuje prázdný řetězec', () => { + expect(isTextSoupName('')).toBe(false); + }); + + test('není case-sensitive', () => { + expect(isTextSoupName('POLÉVKA DNEŠKA')).toBe(true); + }); +}); + +describe('capitalize', () => { + test('zformátuje první písmeno na velké', () => { + expect(capitalize('svíčková')).toBe('Svíčková'); + }); + + test('nechá velká písmena beze změny', () => { + expect(capitalize('ABC')).toBe('ABC'); + }); + + test('prázdný řetězec zůstane prázdný', () => { + expect(capitalize('')).toBe(''); + }); + + test('jednoznakový řetězec', () => { + expect(capitalize('a')).toBe('A'); + }); +}); + +describe('sanitizeText', () => { + test('odstraní tabulátor (první výskyt)', () => { + // replace('\t', '') odstraní tab bez přidání mezery + expect(sanitizeText('\tKnedlíky')).toBe('Knedlíky'); + }); + + test('nahradí první " , " za ", "', () => { + // replace(' , ', ', ') nahrazuje pouze první výskyt + expect(sanitizeText('Knedlíky , zelí')).toBe('Knedlíky, zelí'); + }); + + test('ořízne okrajové mezery', () => { + expect(sanitizeText(' Jídlo ')).toBe('Jídlo'); + }); + + test('kombinace: tab + mezera okolo čárky', () => { + expect(sanitizeText('\tKnedlíky , zelí ')).toBe('Knedlíky, zelí'); + }); +}); + +describe('parseAllergens', () => { + test('extrahuje alergeny na konci řetězce', () => { + const result = parseAllergens('Svíčková 1,3,7'); + expect(result.cleanName).toBe('Svíčková'); + expect(result.allergens).toEqual([1, 3, 7]); + }); + + test('toleruje mezery okolo čárek v alergenech', () => { + const result = parseAllergens('Řízek 1, 3, 7'); + expect(result.allergens).toEqual([1, 3, 7]); + }); + + test('vrátí prázdná pole pro jídlo bez alergenů', () => { + const result = parseAllergens('Ovocný salát'); + expect(result.cleanName).toBe('Ovocný salát'); + expect(result.allergens).toEqual([]); + }); + + test('nesplete se s číslem uprostřed názvu', () => { + const result = parseAllergens('Jídlo č. 5 bez alergenů'); + expect(result.cleanName).toBe('Jídlo č. 5 bez alergenů'); + expect(result.allergens).toEqual([]); + }); + + test('single alergen', () => { + const result = parseAllergens('Houby 7'); + expect(result.cleanName).toBe('Houby'); + expect(result.allergens).toEqual([7]); + }); + + test('prázdný řetězec vrátí prázdné výsledky', () => { + const result = parseAllergens(''); + expect(result.cleanName).toBe(''); + expect(result.allergens).toEqual([]); + }); +}); diff --git a/server/src/tests/service.test.ts b/server/src/tests/service.test.ts new file mode 100644 index 0000000..9428d51 --- /dev/null +++ b/server/src/tests/service.test.ts @@ -0,0 +1,78 @@ +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(), +})); + +import { getDateForWeekIndex, getMenuKey, getEmptyData } from '../service'; +import { formatDate } from '../utils'; + +// MOCK_DATA=true pins "today" to 2025-01-10 (Friday, week 2) +// Monday of that week = 2025-01-06, ..., Friday = 2025-01-10 + +describe('getDateForWeekIndex', () => { + test('index 0 (pondělí) vrátí 2025-01-06', () => { + expect(formatDate(getDateForWeekIndex(0))).toBe('2025-01-06'); + }); + + test('index 4 (pátek) vrátí 2025-01-10', () => { + expect(formatDate(getDateForWeekIndex(4))).toBe('2025-01-10'); + }); + + test('index 2 (středa) vrátí 2025-01-08', () => { + expect(formatDate(getDateForWeekIndex(2))).toBe('2025-01-08'); + }); + + test('neplatný index (-1) vrátí dnešek bez vyhození chyby', () => { + const result = getDateForWeekIndex(-1); + expect(result).toBeInstanceOf(Date); + }); + + test('neplatný index (5) vrátí dnešek bez vyhození chyby', () => { + const result = getDateForWeekIndex(5); + expect(result).toBeInstanceOf(Date); + }); +}); + +describe('getMenuKey', () => { + test('vrátí klíč ve tvaru menu_RRRR_TT', () => { + const date = new Date('2025-01-10'); + const key = getMenuKey(date); + expect(key).toMatch(/^menu_\d{4}_\d+$/); + }); + + test('dvě data ve stejném týdnu mají stejný klíč', () => { + expect(getMenuKey(new Date('2025-01-06'))).toBe(getMenuKey(new Date('2025-01-10'))); + }); + + test('dvě data z různých týdnů mají různé klíče', () => { + expect(getMenuKey(new Date('2025-01-06'))).not.toBe(getMenuKey(new Date('2025-01-13'))); + }); +}); + +describe('getEmptyData', () => { + test('vrátí strukturu s prázdnými choices', () => { + const data = getEmptyData(new Date('2025-01-10')); + expect(data.choices).toEqual({}); + }); + + test('vrátí dayIndex=4 pro pátek', () => { + const data = getEmptyData(new Date('2025-01-10')); + expect(data.dayIndex).toBe(4); + }); + + test('isWeekend=false pro pracovní den', () => { + const data = getEmptyData(new Date('2025-01-10')); + expect(data.isWeekend).toBe(false); + }); + + test('isWeekend=true pro víkend', () => { + const data = getEmptyData(new Date('2025-01-11')); + expect(data.isWeekend).toBe(true); + }); +}); diff --git a/server/src/tests/utils.test.ts b/server/src/tests/utils.test.ts new file mode 100644 index 0000000..b5d4846 --- /dev/null +++ b/server/src/tests/utils.test.ts @@ -0,0 +1,90 @@ +import { formatDate, getUsersByLocation, parseToken, checkQueryParams, checkBodyParams, getIsWeekend } from '../utils'; + +describe('formatDate', () => { + const d = new Date('2025-01-10'); + + test('výchozí formát YYYY-MM-DD', () => { + expect(formatDate(d)).toBe('2025-01-10'); + }); + + test('vlastní formát DD.MM.YYYY', () => { + expect(formatDate(d, 'DD.MM.YYYY')).toBe('10.01.2025'); + }); + + test('nulové doplnění dne a měsíce', () => { + expect(formatDate(new Date('2025-03-05'))).toBe('2025-03-05'); + }); +}); + +describe('getIsWeekend', () => { + test('pondělí není víkend', () => { + expect(getIsWeekend(new Date('2025-01-06'))).toBe(false); + }); + test('pátek není víkend', () => { + expect(getIsWeekend(new Date('2025-01-10'))).toBe(false); + }); + test('sobota je víkend', () => { + expect(getIsWeekend(new Date('2025-01-11'))).toBe(true); + }); + test('neděle je víkend', () => { + expect(getIsWeekend(new Date('2025-01-12'))).toBe(true); + }); +}); + +describe('getUsersByLocation', () => { + const choices = { + SLADOVNICKA: { alice: { trusted: false, selectedFoods: [] } }, + TECHTOWER: { bob: { trusted: true, selectedFoods: [] } }, + } as any; + + test('vrátí spolužáky ze stejného místa', () => { + expect(getUsersByLocation(choices, 'alice')).toEqual(['alice']); + }); + + test('vrátí prázdné pole pro neznámý login', () => { + expect(getUsersByLocation(choices, 'charlie')).toEqual([]); + }); + + test('vrátí prázdné pole pro chybějící login', () => { + expect(getUsersByLocation(choices, undefined)).toEqual([]); + }); +}); + +describe('parseToken', () => { + test('vrátí token z Authorization hlavičky', () => { + const req = { headers: { authorization: 'Bearer mytoken' } }; + expect(parseToken(req)).toBe('mytoken'); + }); + + test('vrátí undefined pro chybějící hlavičku', () => { + expect(parseToken({ headers: {} })).toBeUndefined(); + }); + + test('vrátí undefined pro chybějící req', () => { + expect(parseToken(undefined)).toBeUndefined(); + }); +}); + +describe('checkQueryParams', () => { + test('nevyhodí chybu pro přítomné parametry', () => { + const req = { query: { date: '2025-01-10', location: 'SLADOVNICKA' } }; + expect(() => checkQueryParams(req, ['date', 'location'])).not.toThrow(); + }); + + test('vyhodí chybu pro chybějící parametr', () => { + const req = { query: { date: '2025-01-10' } }; + expect(() => checkQueryParams(req, ['date', 'location'])).toThrow("'location'"); + }); +}); + +describe('checkBodyParams', () => { + test('nevyhodí chybu pro přítomné parametry', () => { + const req = { body: { login: 'alice' } }; + expect(() => checkBodyParams(req, ['login'])).not.toThrow(); + }); + + test('vyhodí chybu pro chybějící parametr', () => { + const req = { body: {} }; + expect(() => checkBodyParams(req, ['login'])).toThrow("'login'"); + }); +}); diff --git a/server/src/tests/voting.test.ts b/server/src/tests/voting.test.ts new file mode 100644 index 0000000..2c6d8ab --- /dev/null +++ b/server/src/tests/voting.test.ts @@ -0,0 +1,66 @@ +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(), +})); + +import { updateFeatureVote, getVotingStats } from '../voting'; + +beforeEach(() => mockStorageData.clear()); + +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); + }); + + test('vyhodí chybu při duplicitním hlasování', async () => { + await updateFeatureVote('alice', feat, true); + await expect(updateFeatureVote('alice', feat, true)).rejects.toThrow('hlasovali'); + }); + + test('odebere hlas', async () => { + await updateFeatureVote('alice', feat, true); + await updateFeatureVote('alice', feat, false); + const stats = await getVotingStats(); + expect(stats[feat] ?? 0).toBe(0); + }); + + test('odebrání neexistujícího hlasu je no-op', async () => { + await expect(updateFeatureVote('alice', feat, false)).resolves.not.toThrow(); + }); + + 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); + } + await expect(updateFeatureVote('alice', 'FE' as FeatureRequest, true)).rejects.toThrow('4'); + }); +}); + +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); + + const stats = await getVotingStats(); + expect(stats['FA']).toBe(2); + expect(stats['FB']).toBe(1); + }); + + test('vrátí prázdný objekt bez hlasů', async () => { + const stats = await getVotingStats(); + expect(stats).toEqual({}); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 49e80bb..813b1b1 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,6 +3,9 @@ "src/**/*", "../types/**/*" ], + "exclude": [ + "src/tests/**/*" + ], "compilerOptions": { "target": "ES2022", "module": "Node16",