From fe6bb3290e94c81208d3ec7a560b702f2ce8ae1b Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 00:25:10 +0200 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20p=C5=99id=C3=A1n=C3=AD=20test?= =?UTF-8?q?=C5=AF=20=E2=80=93=20Jest=20unit=20testy=20+=20Playwright=20E2E?= =?UTF-8?q?=20+=20CI=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - Jest unit testy (88 testů): auth, utils, restaurants, service, voting, pizza - in-memory storage mock pro izolaci testů - oprava race condition při inicializaci Redis (storageReady promise) - dev route dostupná i pro NODE_ENV=test - getStatsMock deterministický (nahrazení Math.random) - exporty interních helperů pro testovatelnost - /api/health endpoint pro Playwright readiness check - tsconfig vylučuje test soubory z produkčního buildu E2E (e2e/): - Playwright s Firefoxem + Chromiem - testy: login, menu, výběr jídla, pizza day životní cyklus, QR/nastavení - trusted-header auth bypass pro testy, video + trace při selhání CI (Woodpecker): - pipeline spouštěna na všech větvích a PR (nejen master) - redis-stack-server service pro E2E – čistý Redis per větev automaticky - kroky: unit testy, build, E2E testy (parallel kde možné) - Docker build zůstává pouze pro master Co-Authored-By: Claude Opus (extra usage) 4.7 --- .gitignore | 5 +- .woodpecker/workflow.yaml | 67 +++++++++++- e2e/.gitignore | 3 + e2e/package.json | 16 +++ e2e/playwright.config.ts | 60 +++++++++++ e2e/tests/helpers.ts | 21 ++++ e2e/tests/login.spec.ts | 50 +++++++++ e2e/tests/pick-food.spec.ts | 70 +++++++++++++ e2e/tests/pizza-day.spec.ts | 65 ++++++++++++ e2e/tests/qr.spec.ts | 77 ++++++++++++++ e2e/tests/view-menus.spec.ts | 39 +++++++ e2e/tsconfig.json | 11 ++ e2e/yarn.lock | 46 +++++++++ server/jest.config.js | 5 + server/src/index.ts | 13 ++- server/src/mock.ts | 31 ++---- server/src/restaurants.ts | 8 +- server/src/routes/devRoutes.ts | 2 +- server/src/service.ts | 4 +- server/src/storage/index.ts | 9 +- server/src/storage/redis.ts | 2 +- server/src/tests/auth.test.ts | 79 ++++++++++++++ server/src/tests/helpers/setupEnv.ts | 4 + server/src/tests/pizza.test.ts | 148 +++++++++++++++++++++++++++ server/src/tests/restaurants.test.ts | 106 +++++++++++++++++++ server/src/tests/service.test.ts | 78 ++++++++++++++ server/src/tests/utils.test.ts | 90 ++++++++++++++++ server/src/tests/voting.test.ts | 66 ++++++++++++ server/tsconfig.json | 3 + 29 files changed, 1136 insertions(+), 42 deletions(-) create mode 100644 e2e/.gitignore create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/helpers.ts create mode 100644 e2e/tests/login.spec.ts create mode 100644 e2e/tests/pick-food.spec.ts create mode 100644 e2e/tests/pizza-day.spec.ts create mode 100644 e2e/tests/qr.spec.ts create mode 100644 e2e/tests/view-menus.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/yarn.lock create mode 100644 server/jest.config.js create mode 100644 server/src/tests/auth.test.ts create mode 100644 server/src/tests/helpers/setupEnv.ts create mode 100644 server/src/tests/pizza.test.ts create mode 100644 server/src/tests/restaurants.test.ts create mode 100644 server/src/tests/service.test.ts create mode 100644 server/src/tests/utils.test.ts create mode 100644 server/src/tests/voting.test.ts 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", -- 2.52.0 From d3224a36d51a913d545bba297c11b6885b204225 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 18:33:28 +0200 Subject: [PATCH 02/20] fix: oprava HTTP_REMOTE_TRUSTED_IPS pro CI Playwright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxy-addr nepodporuje CIDR notaci (0.0.0.0/0), takže server havaroval při startu. V CI kontejneru se browser připojuje ze smyčkového rozhraní, takže 127.0.0.1,::1,::ffff:127.0.0.1 stačí. Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/workflow.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/workflow.yaml b/.woodpecker/workflow.yaml index 06bad7c..efd41eb 100644 --- a/.woodpecker/workflow.yaml +++ b/.woodpecker/workflow.yaml @@ -81,7 +81,7 @@ steps: 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" + HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1" commands: # Zkopírujeme build klienta do server/public, aby Express mohl SPA servírovat - cp -r client/dist server/public -- 2.52.0 From 467e3c155a55de1c7b8cd409d3da1cdb08858938 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 18:45:26 +0200 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20E2E=20testy=20p=C5=99epnuty=20na?= =?UTF-8?q?=20json=20storage,=20odstran=C4=9Bna=20Redis=20slu=C5=BEba?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redis/redis-stack-server:7.2.0-RC3 havaroval v CI kvůli chybě inicializace RedisAI modulu, takže se server nikdy nepřipojil a webServer timeout vyprchával. E2E testy testují chování aplikace, ne storage backend – json storage stačí. Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/workflow.yaml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.woodpecker/workflow.yaml b/.woodpecker/workflow.yaml index efd41eb..1143a87 100644 --- a/.woodpecker/workflow.yaml +++ b/.woodpecker/workflow.yaml @@ -8,12 +8,6 @@ variables: when: - 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 image: *node_image @@ -76,9 +70,7 @@ steps: NODE_ENV: test JWT_SECRET: test-secret-min-32-chars-aaaaaaa! MOCK_DATA: "true" - STORAGE: redis - REDIS_HOST: redis - REDIS_PORT: "6379" + STORAGE: json HTTP_REMOTE_USER_ENABLED: "true" HTTP_REMOTE_USER_HEADER_NAME: remote-user HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1" -- 2.52.0 From bfe819020d1f62ba8b2b6de2afda2c0ba41deb46 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:07:52 +0200 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20redis-stack-server=20RC3=20?= =?UTF-8?q?=E2=86=92=207.4.0-v1,=20obnova=20Redis=20pro=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7.2.0-RC3 havaroval kvůli RedisAI modulu (odstraněn ve verzi 7.4). Stable 7.4.0-v1 RedisAI neobsahuje, RedisJSON zůstává. Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/workflow.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.woodpecker/workflow.yaml b/.woodpecker/workflow.yaml index 1143a87..2c529c6 100644 --- a/.woodpecker/workflow.yaml +++ b/.woodpecker/workflow.yaml @@ -8,6 +8,12 @@ variables: when: - event: [push, pull_request] +services: + redis: + image: redis/redis-stack-server:7.4.0-v1 + environment: + REDIS_ARGS: "--save '' --loglevel warning" + steps: - name: Generate TypeScript types image: *node_image @@ -70,7 +76,9 @@ steps: NODE_ENV: test JWT_SECRET: test-secret-min-32-chars-aaaaaaa! MOCK_DATA: "true" - STORAGE: json + STORAGE: redis + REDIS_HOST: redis + REDIS_PORT: "6379" HTTP_REMOTE_USER_ENABLED: "true" HTTP_REMOTE_USER_HEADER_NAME: remote-user HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1" -- 2.52.0 From 091294f7f39a4c17743ad3a6a636e835266ddcd2 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:31:20 +0200 Subject: [PATCH 05/20] feat: migrace CI z Woodpecker na Gitea Actions Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yaml | 256 ++++++++++++++++++++++++++++++++++++++ .woodpecker/workflow.yaml | 123 ------------------ 2 files changed, 256 insertions(+), 123 deletions(-) create mode 100644 .gitea/workflows/ci.yaml delete mode 100644 .woodpecker/workflow.yaml diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..f44b363 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,256 @@ +name: CI + +on: + push: + branches: + - '**' + pull_request: + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + + # ─── 1. Generate OpenAPI types ──────────────────────────────────────────── + + generate-types: + name: Generate TypeScript types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: types/yarn.lock + + - run: cd types && yarn install --frozen-lockfile && yarn openapi-ts + + - uses: actions/upload-artifact@v4 + with: + name: types-gen + path: types/gen + + # ─── 2a. Server unit tests ──────────────────────────────────────────────── + + server-test: + name: Server unit tests + runs-on: ubuntu-latest + needs: generate-types + env: + NODE_ENV: test + JWT_SECRET: test-secret-min-32-chars-aaaaaaa! + MOCK_DATA: 'true' + STORAGE: json + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: server/yarn.lock + + - uses: actions/download-artifact@v4 + with: + name: types-gen + path: types/gen + + - run: cd server && yarn install --frozen-lockfile && yarn test + + # ─── 2b. Build server ───────────────────────────────────────────────────── + + server-build: + name: Build server + runs-on: ubuntu-latest + needs: generate-types + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: server/yarn.lock + + - uses: actions/download-artifact@v4 + with: + name: types-gen + path: types/gen + + - run: cd server && yarn install --frozen-lockfile && yarn build + + - uses: actions/upload-artifact@v4 + with: + name: server-dist + path: server/dist + + # ─── 2c. Build client ───────────────────────────────────────────────────── + + client-build: + name: Build client + runs-on: ubuntu-latest + needs: generate-types + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: client/yarn.lock + + - uses: actions/download-artifact@v4 + with: + name: types-gen + path: types/gen + + - run: cd client && yarn install --frozen-lockfile && yarn build + + - uses: actions/upload-artifact@v4 + with: + name: client-dist + path: client/dist + + # ─── 3. Playwright E2E tests ────────────────────────────────────────────── + + e2e: + name: Playwright E2E tests + runs-on: ubuntu-latest + needs: [server-build, client-build] + container: mcr.microsoft.com/playwright:v1.59.1-jammy + services: + redis: + image: redis/redis-stack-server:7.4.0-v1 + env: + REDIS_ARGS: "--save '' --loglevel warning" + env: + 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: '127.0.0.1,::1,::ffff:127.0.0.1' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: | + server/yarn.lock + e2e/yarn.lock + + - uses: actions/download-artifact@v4 + with: + name: server-dist + path: server/dist + + - uses: actions/download-artifact@v4 + with: + name: client-dist + path: client/dist + + - name: Install server dependencies + run: cd server && yarn install --frozen-lockfile + + - name: Copy client build into server/public + run: cp -r client/dist server/public + + - name: Install e2e dependencies and browsers + run: cd e2e && yarn install --frozen-lockfile && yarn playwright install firefox --with-deps + + - name: Run Playwright tests + run: cd e2e && yarn test + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/test-results + + # ─── 4. Build and push Docker image (master only) ───────────────────────── + + docker-build: + name: Build and push Docker image + runs-on: ubuntu-latest + needs: [server-build, client-build, server-test, e2e] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: server/yarn.lock + + - uses: actions/download-artifact@v4 + with: + name: server-dist + path: server/dist + + - uses: actions/download-artifact@v4 + with: + name: client-dist + path: client/dist + + - name: Install server production dependencies + run: cd server && yarn install --frozen-lockfile --production + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ secrets.REPO_URL }} + username: ${{ secrets.REPO_USERNAME }} + password: ${{ secrets.REPO_PASSWORD }} + + - uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile-Woodpecker + platforms: linux/amd64 + push: true + tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest + + # ─── 5. Discord notification (master only, always) ──────────────────────── + + discord-notify: + name: Discord notification + runs-on: ubuntu-latest + needs: docker-build + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/master' + steps: + - name: Send webhook + env: + DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }} + DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} + BUILD_RESULT: ${{ needs.docker-build.result }} + RUN_NUMBER: ${{ github.run_number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }} + run: | + if [ "$BUILD_RESULT" = "success" ]; then + MSG="✅ Sestavení #${RUN_NUMBER} proběhlo úspěšně." + else + MSG="❌ Sestavení #${RUN_NUMBER} selhalo." + fi + FULL_MSG="${MSG} + +Pipeline: ${RUN_URL} +Poslední commit: ${COMMIT_MESSAGE}Autor: ${COMMIT_AUTHOR}" + curl -s -X POST \ + "https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')" diff --git a/.woodpecker/workflow.yaml b/.woodpecker/workflow.yaml deleted file mode 100644 index 2c529c6..0000000 --- a/.woodpecker/workflow.yaml +++ /dev/null @@ -1,123 +0,0 @@ -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, pull_request] - -services: - redis: - image: redis/redis-stack-server:7.4.0-v1 - environment: - REDIS_ARGS: "--save '' --loglevel warning" - -steps: - - name: Generate TypeScript types - image: *node_image - commands: - - 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: 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 - 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: "127.0.0.1,::1,::ffff:127.0.0.1" - 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 - registry: - from_secret: REPO_URL - username: - from_secret: REPO_USERNAME - password: - 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 - webhook_token: - from_secret: DISCORD_WEBHOOK_TOKEN - message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}" -- 2.52.0 From 99260a325024f6cbe3f1bc1ccbe30cbe9e4da7fd Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:34:25 +0200 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20oprava=20YAML=20chyby=20v=20discor?= =?UTF-8?q?d-notify=20kroku=20(v=C3=ADce=C5=99=C3=A1dkov=C3=BD=20string)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f44b363..3bbeab8 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -246,10 +246,8 @@ jobs: else MSG="❌ Sestavení #${RUN_NUMBER} selhalo." fi - FULL_MSG="${MSG} - -Pipeline: ${RUN_URL} -Poslední commit: ${COMMIT_MESSAGE}Autor: ${COMMIT_AUTHOR}" + FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \ + "$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")" curl -s -X POST \ "https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \ -H "Content-Type: application/json" \ -- 2.52.0 From 2067c21a292b16cd8928d3e9cc417bc4309d41f6 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:39:51 +0200 Subject: [PATCH 07/20] =?UTF-8?q?fix:=20instalace=20yarn=20p=C5=99es=20npm?= =?UTF-8?q?=20p=C5=99ed=20setup-node=20(yarn=20nebyl=20v=20PATH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yaml | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3bbeab8..4da6ed8 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -23,8 +23,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - cache: yarn - cache-dependency-path: types/yarn.lock + + - run: npm install -g yarn - run: cd types && yarn install --frozen-lockfile && yarn openapi-ts @@ -50,8 +50,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - cache: yarn - cache-dependency-path: server/yarn.lock + + - run: npm install -g yarn - uses: actions/download-artifact@v4 with: @@ -72,8 +72,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - cache: yarn - cache-dependency-path: server/yarn.lock + + - run: npm install -g yarn - uses: actions/download-artifact@v4 with: @@ -99,8 +99,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - cache: yarn - cache-dependency-path: client/yarn.lock + + - run: npm install -g yarn - uses: actions/download-artifact@v4 with: @@ -140,14 +140,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: yarn - cache-dependency-path: | - server/yarn.lock - e2e/yarn.lock - - uses: actions/download-artifact@v4 with: name: server-dist @@ -191,8 +183,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - cache: yarn - cache-dependency-path: server/yarn.lock + + - run: npm install -g yarn - uses: actions/download-artifact@v4 with: -- 2.52.0 From e83cf145948dea2ac8dfcf954fbc634a9a0c891f Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:43:14 +0200 Subject: [PATCH 08/20] =?UTF-8?q?fix:=20downgrade=20artifact=20actions=20n?= =?UTF-8?q?a=20v3=20(v4=20nepodporov=C3=A1no=20na=20Gitea/GHES)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 4da6ed8..add97e3 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -28,7 +28,7 @@ jobs: - run: cd types && yarn install --frozen-lockfile && yarn openapi-ts - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v3 with: name: types-gen path: types/gen @@ -53,7 +53,7 @@ jobs: - run: npm install -g yarn - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: types-gen path: types/gen @@ -75,14 +75,14 @@ jobs: - run: npm install -g yarn - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: types-gen path: types/gen - run: cd server && yarn install --frozen-lockfile && yarn build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v3 with: name: server-dist path: server/dist @@ -102,14 +102,14 @@ jobs: - run: npm install -g yarn - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: types-gen path: types/gen - run: cd client && yarn install --frozen-lockfile && yarn build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v3 with: name: client-dist path: client/dist @@ -140,12 +140,12 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: server-dist path: server/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: client-dist path: client/dist @@ -162,7 +162,7 @@ jobs: - name: Run Playwright tests run: cd e2e && yarn test - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v3 if: failure() with: name: playwright-report @@ -186,12 +186,12 @@ jobs: - run: npm install -g yarn - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: server-dist path: server/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v3 with: name: client-dist path: client/dist -- 2.52.0 From d91c48c5991054e9c34169373414208c5179c58a Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:46:24 +0200 Subject: [PATCH 09/20] =?UTF-8?q?fix:=20instalace=20types/node=5Fmodules?= =?UTF-8?q?=20p=C5=99ed=20buildem=20serveru=20(tsc=20kompiluje=20../types/?= =?UTF-8?q?**)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index add97e3..8cd3378 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -80,6 +80,8 @@ jobs: name: types-gen path: types/gen + - run: cd types && yarn install --frozen-lockfile + - run: cd server && yarn install --frozen-lockfile && yarn build - uses: actions/upload-artifact@v3 -- 2.52.0 From 85cda34881c6c17418096326e566ef3899f32171 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 19:56:17 +0200 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20instalace=20types=20p=C5=99ed=20bu?= =?UTF-8?q?ildem=20klienta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8cd3378..352ac25 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -109,6 +109,8 @@ jobs: name: types-gen path: types/gen + - run: cd types && yarn install --frozen-lockfile + - run: cd client && yarn install --frozen-lockfile && yarn build - uses: actions/upload-artifact@v3 -- 2.52.0 From ec6df8700b01b4778a5e46e5534cb87240adcbb6 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:06:17 +0200 Subject: [PATCH 11/20] =?UTF-8?q?fix:=20Discord=20notifikace=20i=20p=C5=99?= =?UTF-8?q?i=20selh=C3=A1n=C3=AD=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 352ac25..e377844 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -224,7 +224,7 @@ jobs: discord-notify: name: Discord notification runs-on: ubuntu-latest - needs: docker-build + needs: [server-build, client-build, server-test, e2e, docker-build] if: always() && github.event_name == 'push' && github.ref == 'refs/heads/master' steps: - name: Send webhook -- 2.52.0 From f400d1c5f2d73bc6679180c9948b5cbd0babb253 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:16:10 +0200 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20notifikace=20p=C5=99es=20ntfy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 52 ++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e377844..48bb8bb 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - '**' + - "**" pull_request: concurrency: @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - run: npm install -g yarn @@ -42,14 +42,14 @@ jobs: env: NODE_ENV: test JWT_SECRET: test-secret-min-32-chars-aaaaaaa! - MOCK_DATA: 'true' + MOCK_DATA: "true" STORAGE: json steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - run: npm install -g yarn @@ -71,7 +71,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - run: npm install -g yarn @@ -100,7 +100,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - run: npm install -g yarn @@ -123,7 +123,7 @@ jobs: e2e: name: Playwright E2E tests runs-on: ubuntu-latest - needs: [server-build, client-build] + needs: [ server-build, client-build ] container: mcr.microsoft.com/playwright:v1.59.1-jammy services: redis: @@ -131,16 +131,16 @@ jobs: env: REDIS_ARGS: "--save '' --loglevel warning" env: - CI: 'true' + CI: "true" NODE_ENV: test JWT_SECRET: test-secret-min-32-chars-aaaaaaa! - MOCK_DATA: 'true' + MOCK_DATA: "true" STORAGE: redis REDIS_HOST: redis - REDIS_PORT: '6379' - HTTP_REMOTE_USER_ENABLED: 'true' + REDIS_PORT: "6379" + HTTP_REMOTE_USER_ENABLED: "true" HTTP_REMOTE_USER_HEADER_NAME: remote-user - HTTP_REMOTE_TRUSTED_IPS: '127.0.0.1,::1,::ffff:127.0.0.1' + HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1" steps: - uses: actions/checkout@v4 @@ -161,7 +161,8 @@ jobs: run: cp -r client/dist server/public - name: Install e2e dependencies and browsers - run: cd e2e && yarn install --frozen-lockfile && yarn playwright install firefox --with-deps + run: cd e2e && yarn install --frozen-lockfile && yarn playwright install firefox + --with-deps - name: Run Playwright tests run: cd e2e && yarn test @@ -179,14 +180,14 @@ jobs: docker-build: name: Build and push Docker image runs-on: ubuntu-latest - needs: [server-build, client-build, server-test, e2e] + needs: [ server-build, client-build, server-test, e2e ] if: github.event_name == 'push' && github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - run: npm install -g yarn @@ -219,28 +220,32 @@ jobs: push: true tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest - # ─── 5. Discord notification (master only, always) ──────────────────────── + # ─── 5. Notifications ──────────────────────── - discord-notify: - name: Discord notification + notify: + name: Notify runs-on: ubuntu-latest - needs: [server-build, client-build, server-test, e2e, docker-build] - if: always() && github.event_name == 'push' && github.ref == 'refs/heads/master' + needs: [ server-build, client-build, server-test, e2e, docker-build ] + if: always() && github.event_name == 'push' steps: - name: Send webhook env: DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }} DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} + NTFY_URL: ${{ secrets.NTFY_URL }} BUILD_RESULT: ${{ needs.docker-build.result }} RUN_NUMBER: ${{ github.run_number }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ + github.run_id }} COMMIT_MESSAGE: ${{ github.event.head_commit.message }} COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }} run: | if [ "$BUILD_RESULT" = "success" ]; then MSG="✅ Sestavení #${RUN_NUMBER} proběhlo úspěšně." + NTFY_TAGS="white_check_mark" else MSG="❌ Sestavení #${RUN_NUMBER} selhalo." + NTFY_TAGS="x" fi FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \ "$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")" @@ -248,3 +253,8 @@ jobs: "https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \ -H "Content-Type: application/json" \ --data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')" + curl -s -X POST "${NTFY_URL}" \ + -H "Title: Luncher CI #${RUN_NUMBER}" \ + -H "Tags: ${NTFY_TAGS}" \ + -H "Click: ${RUN_URL}" \ + -d "${FULL_MSG}" -- 2.52.0 From e9c570b3d554492615f4823488928b6aac3d4850 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:29:25 +0200 Subject: [PATCH 13/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pizza-day.spec.ts | 6 ++++++ e2e/tests/qr.spec.ts | 13 +++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index 792dd89..1eb087d 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -11,6 +11,9 @@ test.describe.serial('pizza day životní cyklus', () => { test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => { await page.goto('/'); await page.waitForLoadState('networkidle'); + // Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day" + await page.locator('select').selectOption({ label: 'Pizza day' }); + 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(); @@ -19,6 +22,9 @@ test.describe.serial('pizza day životní cyklus', () => { test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => { await page.goto('/'); await page.waitForLoadState('networkidle'); + // Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day" + await page.locator('select').selectOption({ label: 'Pizza day' }); + await page.waitForLoadState('networkidle'); // --- CREATED --- const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); diff --git a/e2e/tests/qr.spec.ts b/e2e/tests/qr.spec.ts index b8a0ceb..3440c01 100644 --- a/e2e/tests/qr.spec.ts +++ b/e2e/tests/qr.spec.ts @@ -23,18 +23,19 @@ test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ p // Modal musí být viditelný await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 }); - // Změníme číslo účtu + // Změníme číslo účtu – pressSequentially zajistí spuštění React onChange na každý znak const accountInput = page.getByPlaceholder('123456-1234567890/1234'); - await accountInput.clear(); - await accountInput.fill('1234567890/5500'); + await accountInput.click({ clickCount: 3 }); + await accountInput.pressSequentially('1234567890/5500'); // Změníme jméno const nameInput = page.getByPlaceholder('Jan Novák'); - await nameInput.clear(); - await nameInput.fill('Nové Jméno'); + await nameInput.click({ clickCount: 3 }); + await nameInput.pressSequentially('Nové Jméno'); - // Uložíme + // Uložíme a počkáme na zavření modalu await page.locator('.modal-footer button', { hasText: 'Uložit' }).click(); + await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 }); // Ověříme v localStorage const bankAccount = await page.evaluate(() => localStorage.getItem('bank_account_number')); -- 2.52.0 From ecbbeb2cecdb0a7541f4554419b69cb05f5f6b56 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:41:57 +0200 Subject: [PATCH 14/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pizza-day.spec.ts | 2 ++ e2e/tests/qr.spec.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index 1eb087d..869ee56 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -30,6 +30,8 @@ test.describe.serial('pizza day životní cyklus', () => { const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); await expect(createBtn).toBeVisible({ timeout: 10_000 }); await createBtn.click(); + // Reload zajistí aktuální stav – aktualizace přichází přes WebSocket, který networkidle nečeká + await page.reload(); await page.waitForLoadState('networkidle'); await expect(page.locator('.pizza-section')).toContainText('spravován uživatelem', { timeout: 5_000 }); diff --git a/e2e/tests/qr.spec.ts b/e2e/tests/qr.spec.ts index 3440c01..20777c8 100644 --- a/e2e/tests/qr.spec.ts +++ b/e2e/tests/qr.spec.ts @@ -24,9 +24,10 @@ test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ p await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 }); // Změníme číslo účtu – pressSequentially zajistí spuštění React onChange na každý znak + // Číslo 1000000005 je platné (kontrolní součet mod 11 = 0), jinak by validace zamítla uložení const accountInput = page.getByPlaceholder('123456-1234567890/1234'); await accountInput.click({ clickCount: 3 }); - await accountInput.pressSequentially('1234567890/5500'); + await accountInput.pressSequentially('1000000005/5500'); // Změníme jméno const nameInput = page.getByPlaceholder('Jan Novák'); @@ -40,7 +41,7 @@ test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ p // 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(bankAccount).toBe('1000000005/5500'); expect(holderName).toBe('Nové Jméno'); }); -- 2.52.0 From d7c8a4663d8c24550ad5f43c87a0cbe42d579d77 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:48:05 +0200 Subject: [PATCH 15/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pizza-day.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index 869ee56..e19a284 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -29,8 +29,10 @@ test.describe.serial('pizza day životní cyklus', () => { // --- CREATED --- const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); await expect(createBtn).toBeVisible({ timeout: 10_000 }); + // Čekáme na odpověď API před reloadem – jinak by reload přerušil probíhající request + const createResponse = page.waitForResponse(resp => resp.url().includes('/api/pizza/create')); await createBtn.click(); - // Reload zajistí aktuální stav – aktualizace přichází přes WebSocket, který networkidle nečeká + await createResponse; await page.reload(); await page.waitForLoadState('networkidle'); await expect(page.locator('.pizza-section')).toContainText('spravován uživatelem', { timeout: 5_000 }); -- 2.52.0 From db1fe473cd554307aa6fd528561f98fb3c459b26 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:56:48 +0200 Subject: [PATCH 16/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pizza-day.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index e19a284..b8ecd51 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -30,7 +30,7 @@ test.describe.serial('pizza day životní cyklus', () => { const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); await expect(createBtn).toBeVisible({ timeout: 10_000 }); // Čekáme na odpověď API před reloadem – jinak by reload přerušil probíhající request - const createResponse = page.waitForResponse(resp => resp.url().includes('/api/pizza/create')); + const createResponse = page.waitForResponse(resp => resp.url().includes('/api/pizzaDay/create')); await createBtn.click(); await createResponse; await page.reload(); @@ -39,7 +39,7 @@ test.describe.serial('pizza day životní cyklus', () => { // 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', { + const addResp = await page.request.post('/api/pizzaDay/add', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { pizzaIndex: 0, pizzaSizeIndex: 0 }, }); -- 2.52.0 From 9383cd7d4c5bf71fa82cbe9556038c76256fe171 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 21:12:50 +0200 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20oprava=20pou=C5=BEit=C3=AD=20yarn?= =?UTF-8?q?=20v=20Gitea=20Actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 48bb8bb..ab228b5 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: with: node-version: "22" - - run: npm install -g yarn + - run: corepack enable - run: cd types && yarn install --frozen-lockfile && yarn openapi-ts @@ -51,7 +51,7 @@ jobs: with: node-version: "22" - - run: npm install -g yarn + - run: corepack enable - uses: actions/download-artifact@v3 with: @@ -73,7 +73,7 @@ jobs: with: node-version: "22" - - run: npm install -g yarn + - run: corepack enable - uses: actions/download-artifact@v3 with: @@ -102,7 +102,7 @@ jobs: with: node-version: "22" - - run: npm install -g yarn + - run: corepack enable - uses: actions/download-artifact@v3 with: @@ -189,7 +189,7 @@ jobs: with: node-version: "22" - - run: npm install -g yarn + - run: corepack enable - uses: actions/download-artifact@v3 with: -- 2.52.0 From ace4130171000d7570653576bb899134fc247220 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 21:27:16 +0200 Subject: [PATCH 18/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pizza-day.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index b8ecd51..38cdbc2 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -20,6 +20,8 @@ test.describe.serial('pizza day životní cyklus', () => { }); test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => { + // Tento test má více kroků a server při MOCK_DATA=true záměrně zpožďuje scraping pizz o 3s + test.setTimeout(60_000); await page.goto('/'); await page.waitForLoadState('networkidle'); // Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day" @@ -30,7 +32,11 @@ test.describe.serial('pizza day životní cyklus', () => { const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); await expect(createBtn).toBeVisible({ timeout: 10_000 }); // Čekáme na odpověď API před reloadem – jinak by reload přerušil probíhající request - const createResponse = page.waitForResponse(resp => resp.url().includes('/api/pizzaDay/create')); + // Server s MOCK_DATA=true záměrně zpožďuje stahování pizz o 3s, proto velkorysý timeout + const createResponse = page.waitForResponse( + resp => resp.url().includes('/api/pizzaDay/create'), + { timeout: 15_000 }, + ); await createBtn.click(); await createResponse; await page.reload(); -- 2.52.0 From 6b2deff215d0d3898c10897c5c053e9b70a199e3 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 21:40:32 +0200 Subject: [PATCH 19/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pizza-day.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index 38cdbc2..9325048 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -28,6 +28,9 @@ test.describe.serial('pizza day životní cyklus', () => { await page.locator('select').selectOption({ label: 'Pizza day' }); await page.waitForLoadState('networkidle'); + // Přijmeme všechny window.confirm() dialogy v celém testu (vytvoření i doručení pizza dne) + page.on('dialog', dialog => dialog.accept()); + // --- CREATED --- const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); await expect(createBtn).toBeVisible({ timeout: 10_000 }); @@ -72,8 +75,6 @@ test.describe.serial('pizza day životní cyklus', () => { // --- 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 }); -- 2.52.0 From 70ed59ab9db52da11eda58f46982e35ed8ad5c02 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 22:06:46 +0200 Subject: [PATCH 20/20] =?UTF-8?q?test:=20opravy=20Playwright=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/pick-food.spec.ts | 1 - server/src/routes/devRoutes.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/pick-food.spec.ts b/e2e/tests/pick-food.spec.ts index 090ee20..3860263 100644 --- a/e2e/tests/pick-food.spec.ts +++ b/e2e/tests/pick-food.spec.ts @@ -1,5 +1,4 @@ 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 diff --git a/server/src/routes/devRoutes.ts b/server/src/routes/devRoutes.ts index 631684a..97999cc 100644 --- a/server/src/routes/devRoutes.ts +++ b/server/src/routes/devRoutes.ts @@ -141,8 +141,9 @@ router.post("/clear", async (req: Request<{}, any, any>, res, next) => { const dateKey = formatDate(date); const data = await storage.getData(dateKey); - // Vymažeme všechny volby + // Vymažeme všechny volby i aktivní pizza day data.choices = {}; + delete data.pizzaDay; await storage.setData(dateKey, data); -- 2.52.0