9 Commits

Author SHA1 Message Date
batmanisko fe6bb3290e feat: přidání testů – Jest unit testy + Playwright E2E + CI pipeline
ci/woodpecker/push/workflow Pipeline was canceled
ci/woodpecker/pr/workflow Pipeline was canceled
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 <noreply@anthropic.com>
2026-04-29 00:25:22 +02:00
batmanisko 1e1e23df80 feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
ci/woodpecker/push/workflow Pipeline was canceled
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}

Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
  PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
  per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number

Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean

Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
2026-04-28 22:44:32 +02:00
mates e5999852b7 docs: aktualizace CLAUDE.md
ci/woodpecker/push/workflow Pipeline was canceled
2026-04-28 13:40:32 +02:00
mates 4e7b83b667 fix: oprava parsování pro aktuální podobu TechTower
ci/woodpecker/push/workflow Pipeline failed
2026-04-28 12:50:19 +02:00
mates d6729388ab feat: podpora salátů z Pizza Chefie
ci/woodpecker/push/workflow Pipeline failed
2026-04-02 10:51:46 +02:00
mates e9696f722c feat: automatický výběr výchozího času
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 11:50:24 +01:00
mates fdeb2636c2 fix: potvrzovací dialog pro Pizza day akce (#44)
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 07:55:42 +01:00
mates 82ed16715f fix: odstranění textu "nepovinné"
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 07:40:38 +01:00
mates 44cf749bc9 feat: nový způsob zobrazování novinek
ci/woodpecker/push/workflow Pipeline is pending
fix: oprava kopírování changelogů do Docker image

fix: oprava kopírování changelogů do Docker image

fix: oprava
2026-03-08 10:55:50 +01:00
64 changed files with 1668 additions and 153 deletions
+4 -1
View File
@@ -1,3 +1,6 @@
node_modules
types/gen
**.DS_Store
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
+63 -4
View File
@@ -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
+6 -15
View File
@@ -45,17 +45,18 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
### Tests
```bash
cd server && yarn test # Jest (tests in server/src/tests/)
cd server && yarn test dates # Run one test file
cd server && yarn test -t "name" # Run by test name pattern
```
### Formatting
```bash
# Prettier available in client (no config file — uses defaults)
```
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
@@ -67,6 +68,7 @@ cd server && yarn test # Jest (tests in server/src/tests/)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
@@ -97,15 +99,4 @@ cd server && yarn test # Jest (tests in server/src/tests/)
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
## Code Search Strategy
When searching through the project for information, use the Task tool to spawn
subagents. Each subagent should read the relevant files and return a brief
summary of what it found (not the full file contents). This keeps the main
context window small and saves tokens. Only pull in full file contents once
you've identified the specific files that matter.
When using subagents to search, each subagent should return:
- File path
- Whether it's relevant (yes/no)
- 1-3 sentence summary of what's in the file
Do NOT return full file contents in subagent responses.
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
+5 -2
View File
@@ -82,8 +82,11 @@ COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
# Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
# Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data
VOLUME ["/data"]
+6 -2
View File
@@ -18,8 +18,12 @@ COPY ./server/dist ./
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi \
&& if [ -d ./server/changelogs ]; then cp -r ./server/changelogs ./server/changelogs; fi
EXPOSE 3000
+83 -34
View File
@@ -289,6 +289,7 @@ function App() {
try {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
await tryAutoSelectDepartureTime();
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
}
@@ -315,6 +316,10 @@ function App() {
foodChoiceRef.current.value = "";
}
choiceRef.current?.blur();
// Automatický výběr času odchodu pouze pro restaurace s menu
if (Object.keys(Restaurant).includes(locationKey)) {
await tryAutoSelectDepartureTime();
}
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
// Reset výběru zpět
@@ -339,6 +344,7 @@ function App() {
const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await tryAutoSelectDepartureTime();
}
}
}
@@ -387,32 +393,80 @@ function App() {
}
}
const handleCreatePizzaDay = async () => {
if (!window.confirm('Opravdu chcete založit Pizza day?')) return;
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}
const handleDeletePizzaDay = async () => {
if (!window.confirm('Opravdu chcete smazat Pizza day? Budou smazány i všechny dosud zadané objednávky.')) return;
await deletePizzaDay();
}
const handleLockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete uzamknout objednávky? Po uzamčení nebude možné přidávat ani odebírat objednávky.')) return;
await lockPizzaDay();
}
const handleUnlockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete odemknout objednávky? Uživatelé budou moci opět upravovat své objednávky.')) return;
await unlockPizzaDay();
}
const handleFinishOrder = async () => {
if (!window.confirm('Opravdu chcete označit objednávky jako objednané? Objednávky zůstanou zamčeny.')) return;
await finishOrder();
}
const handleReturnToLocked = async () => {
if (!window.confirm('Opravdu chcete vrátit stav zpět do "uzamčeno" (před objednáním)?')) return;
await lockPizzaDay();
}
const handleFinishDelivery = async () => {
if (!window.confirm(`Opravdu chcete označit pizzy jako doručené?${settings?.bankAccount && settings?.holderName ? ' Uživatelům bude vygenerován QR kód pro platbu.' : ''}`)) return;
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}
const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) {
if (!data?.pizzaList && !data?.salatList) {
return [];
}
const suggestions: SelectSearchOption[] = [];
data.pizzaList.forEach((pizza, index) => {
data.pizzaList?.forEach((pizza, index) => {
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
pizza.sizes.forEach((size, sizeIndex) => {
const name = `${size.size} (${size.price} Kč)`;
const value = `${index}|${sizeIndex}`;
const value = `pizza|${index}|${sizeIndex}`;
group.items?.push({ name, value });
})
suggestions.push(group);
})
});
if (data.salatList?.length) {
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
data.salatList.forEach((salat, index) => {
salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` });
});
suggestions.push(salatGroup);
}
return suggestions;
}, [data?.pizzaList]);
}, [data?.pizzaList, data?.salatList]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) {
if (auth?.login) {
if (typeof value !== 'string') {
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
}
const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
if (s[0] === 'salat') {
const salatIndex = Number.parseInt(s[1]);
await addPizza({ body: { salatIndex } });
} else {
const pizzaIndex = Number.parseInt(s[1]);
const pizzaSizeIndex = Number.parseInt(s[2]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
}
}
}
@@ -434,6 +488,16 @@ function App() {
}
}
// Automaticky nastaví preferovaný čas odchodu 10:45, pokud tento čas ještě nenastal (nebo jde o budoucí den)
const tryAutoSelectDepartureTime = async () => {
const preferredTime = "10:45" as DepartureTime;
const isToday = dayIndex === data?.todayDayIndex;
if ((!isToday || isInTheFuture(preferredTime)) && departureChoiceRef.current) {
departureChoiceRef.current.value = preferredTime;
await changeDepartureTime({ body: { time: preferredTime, dayIndex } });
}
}
const handleDayChange = async (dayIndex: number) => {
setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
@@ -584,7 +648,7 @@ function App() {
</Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p className="mt-3">Na co dobrého? <small style={{ color: 'var(--luncher-text-muted)' }}>(nepovinné)</small></p>
<p className="mt-3">Na co dobrého?</p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
@@ -595,7 +659,7 @@ function App() {
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option value="">Vyber čas...</option>
{Object.values(DepartureTime)
.filter(time => isInTheFuture(time))
.filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
@@ -722,10 +786,7 @@ function App() {
</span>
:
<div>
<Button onClick={async () => {
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button>
<Button onClick={handleCreatePizzaDay}>Založit Pizza day</Button>
<Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</div>
}
@@ -744,12 +805,8 @@ function App() {
{
data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay();
}}>Uzamknout</Button>
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={handleDeletePizzaDay}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={handleLockPizzaDay}>Uzamknout</Button>
</div>
}
</>
@@ -760,12 +817,8 @@ function App() {
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder();
}}>Objednáno</Button>
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={handleUnlockPizzaDay}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={handleFinishOrder}>Objednáno</Button>
</div>
}
</>
@@ -776,12 +829,8 @@ function App() {
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}}>Doručeno</Button>
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={handleReturnToLocked}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={handleFinishDelivery}>Doručeno</Button>
</div>
}
</>
@@ -798,7 +847,7 @@ function App() {
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
placeholder='Vyhledat pizzu nebo salát...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
+42 -14
View File
@@ -11,16 +11,12 @@ import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const CHANGELOG = [
"Nový moderní design aplikace",
"Oprava parsování Sladovnické a TechTower",
"Možnost označit se jako objednávající u volby \"budu objednávat\"",
"Možnost generovat QR kódy pro platby (i mimo Pizza day)",
];
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
const IS_DEV = process.env.NODE_ENV === 'development';
@@ -38,6 +34,7 @@ export default function Header({ choices, dayIndex }: Props) {
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
@@ -71,6 +68,19 @@ export default function Header({ choices, dayIndex }: Props) {
}
}, [auth?.login]);
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
const entries = response.data;
if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
}
@@ -197,7 +207,17 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
{IS_DEV && (
<>
<NavDropdown.Divider />
@@ -237,16 +257,24 @@ export default function Header({ choices, dayIndex }: Props) {
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}>
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<ul>
{CHANGELOG.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
+3
View File
@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/
+16
View File
@@ -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"
}
}
+60
View File
@@ -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<string, string> = {
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',
},
});
+21
View File
@@ -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<void> {
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<void> {
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 },
});
}
+50
View File
@@ -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 });
});
+70
View File
@@ -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();
});
+65
View File
@@ -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 });
});
});
+77
View File
@@ -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 });
});
+39
View File
@@ -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();
});
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
+46
View File
@@ -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==
+4
View File
@@ -0,0 +1,4 @@
[
"Zimní atmosféra",
"Skrytí podniku U Motlíků"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Přidání restaurace Zastávka u Michala"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Přidání restaurace Pivovarský šenk Šeříková"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost výběru podniku/jídla kliknutím"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Stránka se statistikami nejoblíbenějších voleb"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zobrazení počtu osob u každé volby"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Migrace na generované OpenApi"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Odebrání zimní atmosféry"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost ručního přenačtení menu"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Parsování a zobrazení alergenů"
]
+4
View File
@@ -0,0 +1,4 @@
[
"Oddělení přenačtení menu do vlastního dialogu",
"Podzimní atmosféra"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost převzetí poznámky ostatních uživatelů"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zimní atmosféra"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost označit se jako objednávající u volby \"Budu objednávat\""
]
+3
View File
@@ -0,0 +1,3 @@
[
"Podpora dark mode"
]
+7
View File
@@ -0,0 +1,7 @@
[
"Redesign aplikace pomocí Claude Code",
"Zobrazení uplynulého týdne i o víkendu",
"Podpora Discord, ntfy a Teams notifikací (v Nastavení)",
"Trvalé zobrazení QR kódů do ručního zavření",
"Zobrazení nejvíce požadovaných funkcí (na stránce Statistiky)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zobrazení sekce Pizza day pouze při volbě \"Pizza day\""
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost generování obecných QR kódů pro platby i mimo Pizza day (v uživatelském menu)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Podpora push notifikací pro připomenutí výběru oběda (v nastavení)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Oprava detekce zastaralého menu"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Automatické zobrazení dialogu s dosud nezobrazenými novinkami"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Automatický výběr výchozího času preferovaného odchodu"
]
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['<rootDir>/src/tests/**/*.test.ts'],
setupFiles: ['<rootDir>/src/tests/helpers/setupEnv.ts'],
};
+42 -3
View File
@@ -1,6 +1,7 @@
import axios from 'axios';
import { load } from 'cheerio';
import { getPizzaListMock } from './mock';
import { getPizzaListMock, getSalatListMock } from './mock';
import { Salat } from '../../types/gen/types.gen';
// TODO přesunout do types
type PizzaSize = {
@@ -20,7 +21,8 @@ type Pizza = {
// TODO mělo by být konfigurovatelné proměnnou z prostředí s tímhle jako default
const baseUrl = 'https://www.pizzachefie.cz';
const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`;
const pizzyUrl = `${baseUrl}/pizzy.html`;
const salayUrl = `${baseUrl}/salaty.html`;
const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`;
@@ -34,9 +36,12 @@ const boxPrices: { [key: string]: number } = {
"50cm": 25
}
// Cena obalu pro salát
const SALAT_BOX_PRICE = 13;
/**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
*
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
@@ -84,4 +89,38 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
});
}
return result;
}
/**
* Stáhne a scrapne aktuální saláty ze stránek Pizza Chefie.
* Příplatek za obal je pro každý salát pevně 13 Kč.
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
if (mock) {
return new Promise((resolve) => setTimeout(() => resolve(getSalatListMock()), 1000));
}
const html = await axios.get(salayUrl).then(res => res.data);
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
urls.push(buildPizzaUrl(element.attribs.href));
}
}
const result: Salat[] = [];
for (const url of urls) {
const salatHtml = await axios.get(url).then(res => res.data);
const name = $('.produkt > h2', salatHtml).first().text().trim();
const ingredients: string[] = [];
$('.prisady > li', salatHtml).each((i, elm) => {
ingredients.push($(elm).text());
});
const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]);
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
}
return result;
}
+12 -3
View File
@@ -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";
@@ -18,6 +19,7 @@ import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -55,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' });
@@ -168,6 +174,7 @@ app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
@@ -187,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í
+39 -20
View File
@@ -1429,27 +1429,46 @@ export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST;
}
// Mockovací data pro saláty
const MOCK_SALAT_LIST = [
{
name: "Greek",
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
price: 174 + 13,
},
{
name: "Caesar",
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
price: 184 + 13,
},
{
name: "Šopský salát",
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
price: 164 + 13,
},
{
name: "Těstovinový salát",
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
price: 184 + 13,
},
]
export const getSalatListMock = () => {
return MOCK_SALAT_LIST;
}
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<string, number>),
});
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),
];
}
+76 -6
View File
@@ -2,9 +2,9 @@ import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { downloadPizzy, downloadSalaty } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import { Pizza, Salat, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import crypto from "crypto";
const storage = getStorage();
@@ -26,7 +26,7 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
/**
* Uloží seznam dostupných pizz pro dnešní den.
*
*
* @param pizzaList seznam dostupných pizz
*/
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
@@ -39,6 +39,34 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
return clientData;
}
/**
* Vrátí seznam dostupných salátů pro dnešní den.
* Stáhne je, pokud je pro dnešní den nemá.
*/
export async function getSalatList(): Promise<Salat[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
if (!clientData.salatList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await saveSalatList(await downloadSalaty(mock));
}
return Promise.resolve(clientData.salatList);
}
/**
* Uloží seznam dostupných salátů pro dnešní den.
*
* @param salatList seznam dostupných salátů
*/
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.salatList = salatList;
await storage.setData(today, clientData);
return clientData;
}
/**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/
@@ -49,8 +77,8 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
throw Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
@@ -114,6 +142,46 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
return clientData;
}
/**
* Přidá objednávku salátu uživateli.
*
* @param login login uživatele
* @param salat zvolený salát
*/
export async function addSalatOrder(login: string, salat: Salat) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
}
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order);
}
const salatOrder: PizzaVariant = {
varId: 0,
name: salat.name,
size: "1 porce",
price: salat.price,
category: 'salat',
}
order.pizzaList ??= [];
order.pizzaList.push(salatOrder);
order.totalPrice += salatOrder.price;
await storage.setData(today, clientData);
return clientData;
}
/**
* Odstraní všechny pizzy uživatele (celou jeho objednávku).
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
@@ -271,7 +339,9 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
const id = crypto.randomUUID();
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
let message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení
+21 -6
View File
@@ -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);
@@ -280,6 +280,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
const $ = load(html);
let secondTry = false;
let thirdTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
let font = undefined;
@@ -288,7 +289,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
font = f;
}
})
// Druhý pokus - varianta "Jídelní lístek"
// Druhý pokus - varianta "Jídelní lístek" (starší formát)
if (!font) {
fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => {
@@ -298,12 +299,26 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}
})
}
// Třetí pokus - nový formát: font.wsw-41 s textem "Jídelní lístek" (vše v jednom bloku)
if (!font) {
fonts = $('font.wsw-41');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
thirdTry = true;
}
})
}
if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
const siblings = thirdTry
? $(font).parent().siblings('p')
: secondTry
? $(font).parent().parent().parent().siblings('p')
: $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
+50
View File
@@ -0,0 +1,50 @@
import express, { Request, Response } from "express";
import fs from "fs";
import path from "path";
const router = express.Router();
const CHANGELOGS_DIR = path.resolve(__dirname, "../../changelogs");
// In-memory cache: datum → seznam změn
const cache: Record<string, string[]> = {};
function loadAllChangelogs(): Record<string, string[]> {
let files: string[];
try {
files = fs.readdirSync(CHANGELOGS_DIR).filter(f => f.endsWith(".json"));
} catch {
return {};
}
for (const file of files) {
const date = file.replace(".json", "");
if (!cache[date]) {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8");
cache[date] = JSON.parse(content);
}
}
return cache;
}
router.get("/", (req: Request, res: Response) => {
const all = loadAllChangelogs();
const since = typeof req.query.since === "string" ? req.query.since : undefined;
// Seřazení od nejnovějšího po nejstarší
const sortedDates = Object.keys(all).sort((a, b) => b.localeCompare(a));
const filteredDates = since
? sortedDates.filter(date => date > since)
: sortedDates;
const result: Record<string, string[]> = {};
for (const date of filteredDates) {
result[date] = all[date];
}
res.status(200).json(result);
});
export default router;
+1 -1
View File
@@ -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();
+37 -21
View File
@@ -1,6 +1,6 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
@@ -24,27 +24,43 @@ router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
if (req.body?.salatIndex !== undefined && !isNaN(req.body.salatIndex)) {
// Přidání salátu
const salatIndex = req.body.salatIndex;
const salaty = await getSalatList();
if (!salaty) {
throw Error("Selhalo získání seznamu dostupných salátů.");
}
if (!salaty[salatIndex]) {
throw Error("Neplatný index salátu: " + salatIndex);
}
const data = await addSalatOrder(login, salaty[salatIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
} else {
// Přidání pizzy
if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) {
throw Error("Nebyl předán index pizzy ani salátu");
}
const pizzaIndex = req.body.pizzaIndex;
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList();
if (!pizzy) {
throw Error("Selhalo získání seznamu dostupných pizz.");
}
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
}
const pizzaIndex = req.body.pizzaIndex;
if (isNaN(req.body?.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList();
if (!pizzy) {
throw Error("Selhalo získání seznamu dostupných pizz.");
}
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
+2 -2
View File
@@ -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<ClientData> {
* @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}`;
}
+3 -6
View File
@@ -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<void> = storage.initialize
? storage.initialize()
: Promise.resolve();
export default function getStorage(): StorageInterface {
return storage;
+1 -1
View File
@@ -14,7 +14,7 @@ export default class RedisStorage implements StorageInterface {
}
async initialize() {
client.connect();
await client.connect();
}
async hasData(key: string) {
+79
View File
@@ -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');
});
});
+4
View File
@@ -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';
+148
View File
@@ -0,0 +1,148 @@
import { PizzaDayState, PizzaSize } from '../../../types/gen/types.gen';
const mockStorageData = new Map<string, any>();
jest.mock('../storage', () => ({
__esModule: true,
default: () => ({
hasData: async (key: string) => mockStorageData.has(key),
getData: async <T>(key: string) => mockStorageData.get(key) as T,
setData: async <T>(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();
});
});
+106
View File
@@ -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([]);
});
});
+78
View File
@@ -0,0 +1,78 @@
const mockStorageData = new Map<string, any>();
jest.mock('../storage', () => ({
__esModule: true,
default: () => ({
hasData: async (key: string) => mockStorageData.has(key),
getData: async <T>(key: string) => mockStorageData.get(key) as T,
setData: async <T>(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);
});
});
+90
View File
@@ -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'");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { FeatureRequest } from '../../../types/gen/types.gen';
const mockStorageData = new Map<string, any>();
jest.mock('../storage', () => ({
__esModule: true,
default: () => ({
hasData: async (key: string) => mockStorageData.has(key),
getData: async <T>(key: string) => mockStorageData.get(key) as T,
setData: async <T>(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({});
});
});
+3
View File
@@ -3,6 +3,9 @@
"src/**/*",
"../types/**/*"
],
"exclude": [
"src/tests/**/*"
],
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
+4
View File
@@ -77,6 +77,10 @@ paths:
/voting/stats:
$ref: "./paths/voting/getVotingStats.yml"
# Changelog (/api/changelogs)
/changelogs:
$ref: "./paths/changelogs/getChangelogs.yml"
# DEV endpointy (/api/dev)
/dev/generate:
$ref: "./paths/dev/generate.yml"
+21
View File
@@ -0,0 +1,21 @@
get:
operationId: getChangelogs
summary: Vrátí seznam změn (changelog). Pokud není předáno datum, vrátí všechny změny. Pokud je předáno datum, vrátí pouze změny po tomto datu.
parameters:
- in: query
name: since
required: false
schema:
type: string
description: Datum (formát YYYY-MM-DD) od kterého se mají vrátit změny (exkluzivně). Pokud není předáno, vrátí se všechny změny.
responses:
"200":
description: Slovník kde klíčem je datum (YYYY-MM-DD) a hodnotou seznam změn k danému datu. Seřazeno od nejnovějšího po nejstarší.
content:
application/json:
schema:
type: object
additionalProperties:
type: array
items:
type: string
+7 -7
View File
@@ -1,21 +1,21 @@
post:
operationId: addPizza
summary: Přidání pizzy do objednávky.
summary: Přidání pizzy nebo salátu do objednávky.
requestBody:
required: true
content:
application/json:
schema:
required:
- pizzaIndex
- pizzaSizeIndex
properties:
pizzaIndex:
description: Index pizzy v nabídce
description: Index pizzy v nabídce (pro přidání pizzy)
type: integer
pizzaSizeIndex:
description: Index velikosti pizzy v nabídce variant
description: Index velikosti pizzy v nabídce variant (pro přidání pizzy)
type: integer
salatIndex:
description: Index salátu v nabídce (pro přidání salátu)
type: integer
responses:
"200":
description: Přidání pizzy do objednávky proběhlo úspěšně.
description: Přidání pizzy nebo salátu do objednávky proběhlo úspěšně.
+34 -5
View File
@@ -53,6 +53,11 @@ ClientData:
description: Datum a čas poslední aktualizace pizz
type: string
format: date-time
salatList:
description: Seznam dostupných salátů pro předaný den
type: array
items:
$ref: "#/Salat"
pendingQrs:
description: Nevyřízené QR kódy pro platbu z předchozích pizza day
type: array
@@ -426,7 +431,7 @@ Pizza:
items:
$ref: "#/PizzaSize"
PizzaVariant:
description: Konkrétní varianta (velikost) jedné pizzy.
description: Konkrétní varianta (velikost) jedné pizzy nebo salátu.
type: object
additionalProperties: false
required:
@@ -436,16 +441,40 @@ PizzaVariant:
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
description: Unikátní identifikátor varianty
type: integer
name:
description: Název pizzy
description: Název pizzy nebo salátu
type: string
size:
description: Velikost pizzy (např. "30cm")
description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
type: string
price:
description: Cena pizzy v Kč, včetně krabice
description: Cena v Kč, včetně krabice/obalu
type: number
category:
description: Kategorie položky (pizza nebo salat)
type: string
enum: [pizza, salat]
Salat:
description: Salát z nabídky Pizza Chefie
type: object
additionalProperties: false
required:
- name
- ingredients
- price
properties:
name:
description: Název salátu
type: string
ingredients:
description: Seznam obsažených ingrediencí
type: array
items:
type: string
price:
description: Cena salátu v Kč (bez obalu)
type: number
PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele.