From 607bcd9bf50c6e529207e7713f6e8f3d43124628 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 5 Mar 2026 21:50:17 +0100 Subject: [PATCH 01/37] =?UTF-8?q?feat:=20uprava=20refresh=20menu=20hesel?= =?UTF-8?q?=20ka=C5=BEd=C3=BD=20m=C5=AF=C5=BEe=20ud=C4=9Blat=20refresh,=20?= =?UTF-8?q?jen=20ne=20tak=20=C4=8Dasto,=20bypass=20mimo=20zdrojak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/.env.template | 6 +++++- server/src/routes/foodRoutes.ts | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/server/.env.template b/server/.env.template index f78a16d..52e80be 100644 --- a/server/.env.template +++ b/server/.env.template @@ -43,4 +43,8 @@ # Vygenerovat pomocí: npx web-push generate-vapid-keys # VAPID_PUBLIC_KEY= # VAPID_PRIVATE_KEY= -# VAPID_SUBJECT=mailto:admin@example.com \ No newline at end of file +# VAPID_SUBJECT=mailto:admin@example.com + +# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin). +# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu). +# REFRESH_BYPASS_PASSWORD= \ No newline at end of file diff --git a/server/src/routes/foodRoutes.ts b/server/src/routes/foodRoutes.ts index ba4bdd4..2688d7e 100644 --- a/server/src/routes/foodRoutes.ts +++ b/server/src/routes/foodRoutes.ts @@ -191,13 +191,20 @@ router.post("/updateBuyer", async (req, res, next) => { } catch (e: any) { next(e) } }); -// /api/food/refresh?type=week&heslo=docasnyheslo +// /api/food/refresh?type=week (přihlášený uživatel, nebo ?heslo=... pro bypass rate limitu) export const refreshMetoda = async (req: Request, res: Response) => { const { type, heslo } = req.query as { type?: string; heslo?: string }; - if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") { - return res.status(403).json({ error: "Neplatné heslo" }); + const bypassPassword = process.env.REFRESH_BYPASS_PASSWORD; + const isBypass = !!bypassPassword && heslo === bypassPassword; + + if (!isBypass) { + try { + getLogin(parseToken(req)); + } catch { + return res.status(403).json({ error: "Přihlaste se prosím" }); + } } - if (!checkRateLimit("refresh") && heslo !== "tohleheslopavelnesmizjistit123") { + if (!checkRateLimit("refresh") && !isBypass) { return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" }); } if (type !== "week" && type !== "day") { From f8a65d7177b7aa4fd46654e4599414f27abd25f3 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 5 Mar 2026 22:11:45 +0100 Subject: [PATCH 02/37] =?UTF-8?q?feat:=20detekce=20star=C3=A9ho=20menu=20T?= =?UTF-8?q?echTower,=20p=C5=99=C3=ADznak=20isStale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pokud TechTower vrátí menu z jiného týdne, uloží data s příznakem isStale a zobrazí varování "Data jsou z minulého týdne" místo chybové hlášky. Odstraněno staré varování o datech starších 24 hodin. --- server/src/restaurants.ts | 17 ++++++++++++++++- server/src/service.ts | 25 +++++++++++++++++-------- types/schemas/_index.yml | 3 +++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/server/src/restaurants.ts b/server/src/restaurants.ts index 7cae72e..6680012 100644 --- a/server/src/restaurants.ts +++ b/server/src/restaurants.ts @@ -4,6 +4,10 @@ import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getM import { formatDate } from "./utils"; import { Food } from "../../types/gen/types.gen"; +export class StaleWeekError extends Error { + constructor(public food: Food[][]) { super('Data jsou z jiného týdne'); } +} + // Fráze v názvech jídel, které naznačují že se jedná o polévku const SOUP_NAMES = [ 'polévka', @@ -299,7 +303,6 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal } const result: Food[][] = []; - // TODO validovat, že v textu nalezeného je rozsah, do kterého spadá vstupní datum const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings(); let parsing = false; let currentDayIndex = 0; @@ -345,6 +348,18 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal }) } } + + // Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu + const headerText = $(font).text().trim(); + const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./); + if (dateMatch) { + const foundDay = parseInt(dateMatch[1]); + const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based + if (foundDay !== firstDayOfWeek.getDate() || foundMonth !== firstDayOfWeek.getMonth()) { + throw new StaleWeekError(result); + } + } + return result; } diff --git a/server/src/service.ts b/server/src/service.ts index ff6780b..2c02c81 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -1,6 +1,6 @@ import { InsufficientPermissions, PizzaDayConflictError, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils"; import getStorage from "./storage"; -import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants"; +import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants"; import { getTodayMock } from "./mock"; import { removeAllUserPizzas } from "./pizza"; import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; @@ -216,6 +216,7 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) { weekMenu[i][restaurant]!.food = restaurantWeekFood[i]; weekMenu[i][restaurant]!.lastUpdate = now; + weekMenu[i][restaurant]!.isStale = false; // Detekce uzavření pro každou restauraci switch (restaurant) { @@ -245,22 +246,34 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for // Uložení do storage await storage.setData(getMenuKey(usedDate), weekMenu); } catch (e: any) { - console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); + if (e instanceof StaleWeekError) { + for (let i = 0; i < e.food.length && i < weekMenu.length; i++) { + weekMenu[i][restaurant]!.food = e.food[i]; + weekMenu[i][restaurant]!.lastUpdate = now; + weekMenu[i][restaurant]!.isStale = true; + } + await storage.setData(getMenuKey(usedDate), weekMenu); + } else { + console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); + } } } const result = weekMenu[dayOfWeekIndex][restaurant]!; - result.warnings = generateMenuWarnings(result, now); + result.warnings = generateMenuWarnings(result); return result; } /** * Generuje varování o kvalitě/úplnosti dat menu restaurace. */ -function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] { +function generateMenuWarnings(menu: RestaurantDayMenu): string[] { const warnings: string[] = []; if (!menu.food?.length || menu.closed) { return warnings; } + if (menu.isStale) { + warnings.push('Data jsou z minulého týdne'); + } const hasSoup = menu.food.some(f => f.isSoup); if (!hasSoup) { warnings.push('Chybí polévka'); @@ -269,10 +282,6 @@ function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] { if (missingPrice) { warnings.push('U některých jídel chybí cena'); } - const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; - if (menu.lastUpdate && (now - menu.lastUpdate) > STALE_THRESHOLD_MS) { - warnings.push('Data jsou starší než 24 hodin'); - } return warnings; } diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 75915d4..7992b6b 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -186,6 +186,9 @@ RestaurantDayMenu: type: array items: type: string + isStale: + description: Příznak, zda data mohou pocházet z jiného týdne + type: boolean RestaurantDayMenuMap: description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu)) type: object From a1b1eed86da0620277438d5106d6e5fede028825 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 5 Mar 2026 22:13:19 +0100 Subject: [PATCH 03/37] =?UTF-8?q?docs:=20p=C5=99id=C3=A1na=20strategie=20v?= =?UTF-8?q?yhled=C3=A1v=C3=A1n=C3=AD=20k=C3=B3du=20do=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3d03f91..6f6131a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,3 +97,15 @@ 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. \ No newline at end of file From 44cf749bc91db86f1b4023acc90ecbfd586e04e5 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Sun, 8 Mar 2026 09:59:07 +0100 Subject: [PATCH 04/37] =?UTF-8?q?feat:=20nov=C3=BD=20zp=C5=AFsob=20zobrazo?= =?UTF-8?q?v=C3=A1n=C3=AD=20novinek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: oprava kopírování changelogů do Docker image fix: oprava kopírování changelogů do Docker image fix: oprava --- Dockerfile | 7 ++- Dockerfile-Woodpecker | 8 +++- client/src/components/Header.tsx | 56 ++++++++++++++++++------ server/changelogs/2025-01-07.json | 4 ++ server/changelogs/2025-01-15.json | 3 ++ server/changelogs/2025-01-29.json | 3 ++ server/changelogs/2025-02-19.json | 3 ++ server/changelogs/2025-02-27.json | 3 ++ server/changelogs/2025-03-05.json | 3 ++ server/changelogs/2025-03-20.json | 3 ++ server/changelogs/2025-03-21.json | 3 ++ server/changelogs/2025-08-01.json | 3 ++ server/changelogs/2025-10-06.json | 3 ++ server/changelogs/2025-10-11.json | 4 ++ server/changelogs/2025-11-03.json | 3 ++ server/changelogs/2026-01-09.json | 3 ++ server/changelogs/2026-01-13.json | 3 ++ server/changelogs/2026-01-30.json | 3 ++ server/changelogs/2026-02-04.json | 7 +++ server/changelogs/2026-02-10.json | 3 ++ server/changelogs/2026-02-20.json | 3 ++ server/changelogs/2026-03-04.json | 3 ++ server/changelogs/2026-03-05.json | 3 ++ server/changelogs/2026-03-08.json | 3 ++ server/src/index.ts | 2 + server/src/routes/changelogRoutes.ts | 50 +++++++++++++++++++++ types/api.yml | 4 ++ types/paths/changelogs/getChangelogs.yml | 21 +++++++++ 28 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 server/changelogs/2025-01-07.json create mode 100644 server/changelogs/2025-01-15.json create mode 100644 server/changelogs/2025-01-29.json create mode 100644 server/changelogs/2025-02-19.json create mode 100644 server/changelogs/2025-02-27.json create mode 100644 server/changelogs/2025-03-05.json create mode 100644 server/changelogs/2025-03-20.json create mode 100644 server/changelogs/2025-03-21.json create mode 100644 server/changelogs/2025-08-01.json create mode 100644 server/changelogs/2025-10-06.json create mode 100644 server/changelogs/2025-10-11.json create mode 100644 server/changelogs/2025-11-03.json create mode 100644 server/changelogs/2026-01-09.json create mode 100644 server/changelogs/2026-01-13.json create mode 100644 server/changelogs/2026-01-30.json create mode 100644 server/changelogs/2026-02-04.json create mode 100644 server/changelogs/2026-02-10.json create mode 100644 server/changelogs/2026-02-20.json create mode 100644 server/changelogs/2026-03-04.json create mode 100644 server/changelogs/2026-03-05.json create mode 100644 server/changelogs/2026-03-08.json create mode 100644 server/src/routes/changelogRoutes.ts create mode 100644 types/paths/changelogs/getChangelogs.yml diff --git a/Dockerfile b/Dockerfile index 67bfdc1..e940d6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile-Woodpecker b/Dockerfile-Woodpecker index 6291b32..3dc9ec2 100644 --- a/Dockerfile-Woodpecker +++ b/Dockerfile-Woodpecker @@ -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 diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 0af7f47..9b279c6 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -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(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState(false); const [changelogModalOpen, setChangelogModalOpen] = useState(false); + const [changelogEntries, setChangelogEntries] = useState>({}); const [qrModalOpen, setQrModalOpen] = useState(false); const [generateMockModalOpen, setGenerateMockModalOpen] = useState(false); const [clearMockModalOpen, setClearMockModalOpen] = useState(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) { setPizzaModalOpen(true)}>Pizza kalkulačka Generování QR kódů navigate(STATS_URL)}>Statistiky - setChangelogModalOpen(true)}>Novinky + { + 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 {IS_DEV && ( <> @@ -237,16 +257,24 @@ export default function Header({ choices, dayIndex }: Props) { /> )} - setChangelogModalOpen(false)}> + setChangelogModalOpen(false)} size="lg">

Novinky

-
    - {CHANGELOG.map((item, index) => ( -
  • {item}
  • - ))} -
+ {Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => ( +
+ {formatDateString(date)} +
    + {changelogEntries[date].map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ))} + {Object.keys(changelogEntries).length === 0 && ( +

Žádné novinky.

+ )}
+ } @@ -730,12 +763,8 @@ function App() { { data.pizzaDay.creator === auth.login &&
- - + +
} @@ -746,12 +775,8 @@ function App() {

Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}

{data.pizzaDay.creator === auth.login &&
- - + +
} @@ -762,12 +787,8 @@ function App() {

Pizzy byly objednány uživatelem {data.pizzaDay.creator}

{data.pizzaDay.creator === auth.login &&
- - + +
} From e9696f722cbbbaf191bf6052b7ee7a6fbe432202 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Mon, 9 Mar 2026 10:16:14 +0100 Subject: [PATCH 07/37] =?UTF-8?q?feat:=20automatick=C3=BD=20v=C3=BDb=C4=9B?= =?UTF-8?q?r=20v=C3=BDchoz=C3=ADho=20=C4=8Dasu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 18 +++++++++++++++++- server/changelogs/2026-03-09.json | 3 +++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 server/changelogs/2026-03-09.json diff --git a/client/src/App.tsx b/client/src/App.tsx index 086e72c..d642daa 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -287,6 +287,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}`); } @@ -313,6 +314,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 @@ -337,6 +342,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(); } } } @@ -468,6 +474,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; @@ -629,7 +645,7 @@ function App() { {Object.values(DepartureTime) - .filter(time => isInTheFuture(time)) + .filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time)) .map(time => )} } diff --git a/server/changelogs/2026-03-09.json b/server/changelogs/2026-03-09.json new file mode 100644 index 0000000..216ac8d --- /dev/null +++ b/server/changelogs/2026-03-09.json @@ -0,0 +1,3 @@ +[ + "Automatický výběr výchozího času preferovaného odchodu" +] \ No newline at end of file From d6729388ab9d75c6e0df9ebeb9aaa188094d03d0 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Thu, 2 Apr 2026 10:51:46 +0200 Subject: [PATCH 08/37] =?UTF-8?q?feat:=20podpora=20sal=C3=A1t=C5=AF=20z=20?= =?UTF-8?q?Pizza=20Chefie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 32 +++++++---- server/src/chefie.ts | 45 ++++++++++++++-- server/src/mock.ts | 28 ++++++++++ server/src/pizza.ts | 82 ++++++++++++++++++++++++++--- server/src/routes/pizzaDayRoutes.ts | 58 ++++++++++++-------- types/paths/pizzaDay/addPizza.yml | 14 ++--- types/schemas/_index.yml | 39 ++++++++++++-- 7 files changed, 246 insertions(+), 52 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index d642daa..698de0b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -428,31 +428,43 @@ function App() { } 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 } }); + } } } @@ -821,7 +833,7 @@ function App() { { }} onFocus={_ => { }} diff --git a/server/src/chefie.ts b/server/src/chefie.ts index fe41d40..8cd50be 100644 --- a/server/src/chefie.ts +++ b/server/src/chefie.ts @@ -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 { @@ -84,4 +89,38 @@ export async function downloadPizzy(mock: boolean): Promise { }); } 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 { + 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; } \ No newline at end of file diff --git a/server/src/mock.ts b/server/src/mock.ts index 68ecb13..6620fbf 100644 --- a/server/src/mock.ts +++ b/server/src/mock.ts @@ -1429,6 +1429,34 @@ 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 => { return [ { diff --git a/server/src/pizza.ts b/server/src/pizza.ts index a9434c8..1bd870e 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -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"; const storage = getStorage(); const PENDING_QR_PREFIX = 'pending_qr'; @@ -25,7 +25,7 @@ export async function getPizzaList(): Promise { /** * Uloží seznam dostupných pizz pro dnešní den. - * + * * @param pizzaList seznam dostupných pizz */ export async function savePizzaList(pizzaList: Pizza[]): Promise { @@ -38,6 +38,34 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise { 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 { + 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 { + 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. */ @@ -48,8 +76,8 @@ export async function createPizzaDay(creator: string): Promise { 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 } }) @@ -113,6 +141,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. @@ -269,7 +337,9 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b if (bankAccount?.length && bankAccountHolder?.length) { 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 - 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); order.hasQr = true; // Uložíme nevyřízený QR kód pro persistentní zobrazení diff --git a/server/src/routes/pizzaDayRoutes.ts b/server/src/routes/pizzaDayRoutes.ts index 82a26c5..ddf5c2e 100644 --- a/server/src/routes/pizzaDayRoutes.ts +++ b/server/src/routes/pizzaDayRoutes.ts @@ -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) => { diff --git a/types/paths/pizzaDay/addPizza.yml b/types/paths/pizzaDay/addPizza.yml index fed8511..9637580 100644 --- a/types/paths/pizzaDay/addPizza.yml +++ b/types/paths/pizzaDay/addPizza.yml @@ -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ě. diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 7992b6b..22b4a3d 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -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. From 4e7b83b66760c0e5c39612266792b7c19a3695e1 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Tue, 28 Apr 2026 12:50:19 +0200 Subject: [PATCH 09/37] =?UTF-8?q?fix:=20oprava=20parsov=C3=A1n=C3=AD=20pro?= =?UTF-8?q?=20aktu=C3=A1ln=C3=AD=20podobu=20TechTower?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/restaurants.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/server/src/restaurants.ts b/server/src/restaurants.ts index 6680012..eeb311c 100644 --- a/server/src/restaurants.ts +++ b/server/src/restaurants.ts @@ -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 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++) { From e5999852b739774832ff09ba99c1029bf9aeabf3 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Tue, 28 Apr 2026 13:40:32 +0200 Subject: [PATCH 10/37] docs: aktualizace CLAUDE.md --- CLAUDE.md | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f6131a..5d54c93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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//*.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. \ No newline at end of file +- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work \ No newline at end of file From 1e1e23df8072b2b3efda975798fc127f919b38bf Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Tue, 28 Apr 2026 22:35:15 +0200 Subject: [PATCH 11/37] =?UTF-8?q?feat:=20=C3=BAhrada=20za=20v=C5=A1echny?= =?UTF-8?q?=20jednou=20osobou=20(issue=20#29,=20SINGLE=5FPAYMENT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.tsx | 54 ++- .../src/components/modals/PayForAllModal.tsx | 308 ++++++++++++++++++ client/src/utils/parsePrice.ts | 11 + server/src/index.ts | 11 +- server/src/pizza.ts | 19 +- server/src/qr.ts | 56 ++-- server/src/routes/pizzaDayRoutes.ts | 6 +- server/src/routes/qrRoutes.ts | 5 +- types/paths/getPizzaQr.yml | 8 +- types/paths/pizzaDay/dismissQr.yml | 8 +- types/schemas/_index.yml | 10 +- 11 files changed, 430 insertions(+), 66 deletions(-) create mode 100644 client/src/components/modals/PayForAllModal.tsx create mode 100644 client/src/utils/parsePrice.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 698de0b..b148a14 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,12 +13,13 @@ import './App.scss'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { useSettings } from './context/settings'; import Footer from './components/Footer'; -import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; +import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import Loader from './components/Loader'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import NoteModal from './components/modals/NoteModal'; +import PayForAllModal from './components/modals/PayForAllModal'; import { useEasterEgg } from './context/eggs'; -import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types'; +import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types'; import { getLunchChoiceName } from './enums'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import './FallingLeaves.scss'; @@ -74,6 +75,7 @@ function App() { const [dayIndex, setDayIndex] = useState(); const [loadingPizzaDay, setLoadingPizzaDay] = useState(false); const [noteModalOpen, setNoteModalOpen] = useState(false); + const [payForAllLocationKey, setPayForAllLocationKey] = useState(null); const [eggImage, setEggImage] = useState(); const eggRef = useRef(null); // Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu @@ -679,6 +681,18 @@ function App() { {locationName} {(locationPickCount ?? 0) > 1 && ({locationPickCount})} + {locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined + && locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI + && settings?.bankAccount && settings?.holderName && ( + + setPayForAllLocationKey(locationKey)} + className='action-icon' + style={{ cursor: 'pointer' }} + /> + + )} @@ -856,11 +870,15 @@ function App() { } { - data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && -
-

QR platba

- QR kód -
+ data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => { + const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator); + return pizzaQr ? ( +
+

QR platba

+ QR kód +
+ ) : null; + })() } } @@ -870,18 +888,17 @@ function App() { {data.pendingQrs && data.pendingQrs.length > 0 &&

Nevyřízené platby

-

Máte neuhrazené platby z předchozích dní.

+

Máte neuhrazené platby.

{data.pendingQrs.map(qr => ( -
+

{formatDateString(qr.date)} — {qr.creator} ({qr.totalPrice} Kč) {qr.purpose && <>
{qr.purpose}}

- QR kód + QR kód
); } diff --git a/client/src/components/modals/PayForAllModal.tsx b/client/src/components/modals/PayForAllModal.tsx new file mode 100644 index 0000000..e1b65bb --- /dev/null +++ b/client/src/components/modals/PayForAllModal.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect, useCallback } from "react"; +import { Modal, Button, Form, Table, Alert } from "react-bootstrap"; +import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types"; +import { parsePriceCzk } from "../../utils/parsePrice"; + +type DinerEntry = { + login: string; + selectedFoods: number[]; + baseAmount: number; + baseAmountParseFailed: boolean; + surchargeText: string; + surchargeAmount: string; + included: boolean; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; + locationKey: LunchChoice; + locationName: string; + locationChoices: LocationLunchChoicesMap; + menu: RestaurantDayMenu | undefined; + payerLogin: string; + bankAccount: string; + bankAccountHolder: string; +}; + +function sanitizeAmount(value: string): string { + return value.replace(/[^0-9.,]/g, '').replace(',', '.'); +} + +function parseAmount(s: string): number | null { + if (!s || s.trim().length === 0) return null; + const n = parseFloat(s); + if (isNaN(n) || n < 0) return null; + const parts = s.split('.'); + if (parts.length === 2 && parts[1].length > 2) return null; + return Math.round(n * 100) / 100; +} + +export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly) { + const [diners, setDiners] = useState([]); + const [tipTotal, setTipTotal] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + const hasMenu = !!menu; + + useEffect(() => { + if (!isOpen) return; + const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => { + const selectedFoods = choice.selectedFoods ?? []; + let baseAmount = 0; + let baseAmountParseFailed = false; + if (menu) { + for (const idx of selectedFoods) { + const price = parsePriceCzk(menu.food?.[idx]?.price); + if (price === null) { + baseAmountParseFailed = true; + } else { + baseAmount += price; + } + } + } + return { + login, + selectedFoods, + baseAmount, + baseAmountParseFailed, + surchargeText: '', + surchargeAmount: '', + included: login !== payerLogin, + }; + }); + setDiners(entries); + setTipTotal(''); + setError(null); + setSuccess(false); + }, [isOpen, locationChoices, menu, payerLogin]); + + const includedDiners = diners.filter(d => d.included && d.login !== payerLogin); + const tipPerPerson = (() => { + if (includedDiners.length === 0) return 0; + const tip = parseAmount(tipTotal); + if (tip === null || tip === 0) return 0; + return Math.round((tip / includedDiners.length) * 100) / 100; + })(); + + const getTotal = (d: DinerEntry): number => { + const surcharge = parseAmount(d.surchargeAmount) ?? 0; + const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0; + return Math.round((d.baseAmount + surcharge + tip) * 100) / 100; + }; + + const handleInclude = useCallback((login: string, checked: boolean) => { + setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d)); + }, []); + + const handleSurchargeText = useCallback((login: string, value: string) => { + setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d)); + }, []); + + const handleSurchargeAmount = useCallback((login: string, value: string) => { + setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d)); + }, []); + + const handleGenerate = async () => { + setError(null); + const recipients: QrRecipient[] = []; + + for (const d of diners) { + if (!d.included || d.login === payerLogin) continue; + const total = getTotal(d); + if (total <= 0) { + setError(`Celková částka pro ${d.login} musí být kladná`); + return; + } + const amountStr = total.toString(); + if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) { + setError(`Částka pro ${d.login} má více než 2 desetinná místa`); + return; + } + const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', '); + const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`; + recipients.push({ + login: d.login, + purpose: purposeBase.substring(0, 60), + amount: total, + }); + } + + if (recipients.length === 0) { + setError("Nebyl vybrán žádný příjemce"); + return; + } + + setLoading(true); + try { + const response = await generateQr({ + body: { recipients, bankAccount, bankAccountHolder }, + }); + if (response.error) { + setError((response.error as any).error || 'Nastala chyba při generování QR kódů'); + } else { + setSuccess(true); + setTimeout(() => onClose(), 2000); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba při generování QR kódů'); + } finally { + setLoading(false); + } + }; + + const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included); + + return ( + + +

Zaplatit za všechny — {locationName}

+
+ + {success ? ( + + QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby". + + ) : ( + <> +

Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.

+ + {!hasMenu && ( + + Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně. + + )} + + {anyParseFailed && ( + + U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek. + + )} + + {error && ( + setError(null)} dismissible> + {error} + + )} + +
+ + + + + + + + + + + + {diners.map(d => { + const isPayer = d.login === payerLogin; + const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', '); + const total = getTotal(d); + return ( + + + + + + + + + ); + })} + +
StrávníkJídlaPříplatekDýškoCelkem
+ {isPayer ? ( + plátce + ) : ( + handleInclude(d.login, e.target.checked)} + /> + )} + {d.login} + + {foodNames || } + {hasMenu && d.baseAmount > 0 && ({d.baseAmount} Kč)} + {d.baseAmountParseFailed && } + + + {!isPayer && ( +
+ handleSurchargeText(d.login, e.target.value)} + disabled={!d.included} + size="sm" + onKeyDown={e => e.stopPropagation()} + /> + handleSurchargeAmount(d.login, e.target.value)} + disabled={!d.included} + size="sm" + style={{ width: 70 }} + onKeyDown={e => e.stopPropagation()} + /> +
+ )} +
+ {!isPayer && d.included ? `${tipPerPerson} Kč` : '—'} + + {!isPayer ? `${total} Kč` : '—'} +
+ +
+ + setTipTotal(sanitizeAmount(e.target.value))} + size="sm" + style={{ width: 100 }} + onKeyDown={e => e.stopPropagation()} + /> + + {includedDiners.length > 0 && tipPerPerson > 0 + ? `(${tipPerPerson} Kč / osoba)` + : ''} + +
+ + )} + + + {!success && ( + <> + + Příjemci: {includedDiners.length} + + + + + )} + {success && ( + + )} + +
+ ); +} diff --git a/client/src/utils/parsePrice.ts b/client/src/utils/parsePrice.ts new file mode 100644 index 0000000..4863b37 --- /dev/null +++ b/client/src/utils/parsePrice.ts @@ -0,0 +1,11 @@ +/** + * Parsuje cenu ve formátu "135 Kč", "135,50 Kč" nebo "135.50 Kč" na číslo. + * Vrátí null při selhání. + */ +export function parsePriceCzk(raw: string | undefined): number | null { + if (!raw) return null; + const m = raw.replace(',', '.').match(/(\d+(?:\.\d+)?)/); + if (!m) return null; + const n = parseFloat(m[1]); + return Number.isFinite(n) ? n : null; +} diff --git a/server/src/index.ts b/server/src/index.ts index 7cae809..59c2533 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -87,12 +87,15 @@ app.post("/api/login", (req, res) => { } }); -// TODO dočasné řešení - QR se zobrazuje přes , nemáme sem jak dostat token -app.get("/api/qr", (req, res) => { +// QR se zobrazuje přes , nemáme sem jak dostat token +app.get("/api/qr", async (req, res) => { if (!req.query?.login) { - throw Error("Nebyl předán login"); + return res.status(400).json({ error: "Nebyl předán login" }); } - const img = getQr(req.query.login as string); + if (!req.query?.id) { + return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" }); + } + const img = await getQr(req.query.login as string, req.query.id as string); res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': img.length diff --git a/server/src/pizza.ts b/server/src/pizza.ts index 1bd870e..640c8bf 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -5,6 +5,7 @@ import getStorage from "./storage"; import { downloadPizzy, downloadSalaty } from "./chefie"; import { getClientData, getToday, initIfNeeded } from "./service"; import { Pizza, Salat, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen"; +import crypto from "crypto"; const storage = getStorage(); const PENDING_QR_PREFIX = 'pending_qr'; @@ -337,13 +338,15 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b if (bankAccount?.length && bankAccountHolder?.length) { 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(item => - item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})` - ).join(', '); - await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message); + 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í await addPendingQr(order.customer, { + id, date: today, creator: login, totalPrice: order.totalPrice, @@ -430,8 +433,8 @@ function getPendingQrKey(login: string): string { export async function addPendingQr(login: string, pendingQr: PendingQr): Promise { const key = getPendingQrKey(login); const existing = await storage.getData(key) ?? []; - // Nepřidáváme duplicity pro stejný den - if (!existing.some(qr => qr.date === pendingQr.date)) { + // Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů) + if (!existing.some(qr => qr.id === pendingQr.id)) { existing.push(pendingQr); await storage.setData(key, existing); } @@ -445,11 +448,11 @@ export async function getPendingQrs(login: string): Promise { } /** - * Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených). + * Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených). */ -export async function dismissPendingQr(login: string, date: string): Promise { +export async function dismissPendingQr(login: string, id: string): Promise { const key = getPendingQrKey(login); const existing = await storage.getData(key) ?? []; - const filtered = existing.filter(qr => qr.date !== date); + const filtered = existing.filter(qr => qr.id !== id); await storage.setData(key, filtered); } \ No newline at end of file diff --git a/server/src/qr.ts b/server/src/qr.ts index 2493bc1..b9a5507 100644 --- a/server/src/qr.ts +++ b/server/src/qr.ts @@ -1,19 +1,17 @@ -import fs from "fs"; import axios from "axios"; -import os from "os"; -import path from "path"; import crypto from "crypto"; -import { formatDate } from "./utils"; +import getStorage from "./storage"; const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image'; const COUNTRY_CODE = 'CZ'; const CURRENCY_CODE = 'CZK'; const QR_PIXEL_SIZE = 256; -const tmpDir = os.tmpdir(); + +const storage = getStorage(); /** * Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice. - * + * * @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100) */ function convertBbanToIban(bankAccountNumber: string): string { @@ -41,26 +39,23 @@ function convertBbanToIban(bankAccountNumber: string): string { return iban; } -function createNameHash(customerName: string): string { - return crypto.createHash('md5').update(customerName).digest('hex'); -} - -function createFilePath(nameHash: string): string { - const fileName = `${formatDate(new Date())}_${nameHash}.png`; - return path.join(tmpDir, fileName); +function createStorageKey(customerName: string, id: string): string { + const nameHash = crypto.createHash('md5').update(customerName).digest('hex'); + return `qr_${nameHash}_${id}`; } /** - * Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry. - * + * Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON). + * Data přežijí redeploy — není třeba persistentní filesystém. + * * @param customerName jméno uživatele, pro kterého je QR kód generován * @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN * @param bankAccountHolder jméno držitele cílového bankovního účtu * @param amount částka v Kč * @param message zpráva pro příjemce - * @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek + * @param id unikátní identifikátor (UUID) tohoto QR kódu */ -export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise { +export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise { // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků if (message.indexOf('*') >= 0) { message = message.replace('*', ''); @@ -77,22 +72,23 @@ export async function generateQr(customerName: string, bankAccountNumber: string branding: false, compress: false, size: QR_PIXEL_SIZE, - } - const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } }); - // Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele - const nameHash = createNameHash(customerName); - const imgPath = createFilePath(nameHash); - response.data.pipe(fs.createWriteStream(imgPath)); - return nameHash; + }; + const response = await axios.get(QR_GENERATOR_URL, { responseType: 'arraybuffer', params: { ...payload } }); + const base64 = Buffer.from(response.data).toString('base64'); + await storage.setData(createStorageKey(customerName, id), base64); } /** - * Vrátí obrázek s QR kódem, pokud existuje. - * + * Vrátí obrázek s QR kódem ze storage. + * * @param customerName jméno uživatele + * @param id unikátní identifikátor QR kódu * @returns data obrázku */ -export function getQr(customerName: string): Buffer { - const imgPath = createFilePath(createNameHash(customerName)); - return fs.readFileSync(imgPath); -} \ No newline at end of file +export async function getQr(customerName: string, id: string): Promise { + const base64 = await storage.getData(createStorageKey(customerName, id)); + if (!base64) { + throw new Error("QR kód nebyl nalezen"); + } + return Buffer.from(base64, 'base64'); +} diff --git a/server/src/routes/pizzaDayRoutes.ts b/server/src/routes/pizzaDayRoutes.ts index ddf5c2e..e6182a5 100644 --- a/server/src/routes/pizzaDayRoutes.ts +++ b/server/src/routes/pizzaDayRoutes.ts @@ -128,11 +128,11 @@ router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData[" /** Označí QR kód jako uhrazený. */ router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => { const login = getLogin(parseToken(req)); - if (!req.body.date) { - return res.status(400).json({ error: "Nebyl předán datum" }); + if (!req.body.id) { + return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" }); } try { - await dismissPendingQr(login, req.body.date); + await dismissPendingQr(login, req.body.id); res.status(200).json({}); } catch (e: any) { next(e) } }); diff --git a/server/src/routes/qrRoutes.ts b/server/src/routes/qrRoutes.ts index e89a217..e00a69c 100644 --- a/server/src/routes/qrRoutes.ts +++ b/server/src/routes/qrRoutes.ts @@ -4,6 +4,7 @@ import { parseToken, formatDate } from "../utils"; import { generateQr } from "../qr"; import { addPendingQr } from "../pizza"; import { GenerateQrData } from "../../../types"; +import crypto from "crypto"; const router = express.Router(); @@ -44,10 +45,12 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r } // Vygenerovat QR kód - await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose); + const id = crypto.randomUUID(); + await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id); // Uložit jako nevyřízený QR kód await addPendingQr(recipient.login, { + id, date: today, creator: login, totalPrice: recipient.amount, diff --git a/types/paths/getPizzaQr.yml b/types/paths/getPizzaQr.yml index ba7ba49..81e69dc 100644 --- a/types/paths/getPizzaQr.yml +++ b/types/paths/getPizzaQr.yml @@ -1,6 +1,6 @@ get: operationId: getPizzaQr - summary: Získání QR kódu pro platbu za Pizza day + summary: Získání QR kódu pro platbu security: [] # Nevyžaduje autentizaci parameters: - in: query @@ -9,6 +9,12 @@ get: type: string required: true description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód + - in: query + name: id + schema: + type: string + required: true + description: Unikátní identifikátor QR kódu (z PendingQr.id) responses: "200": description: Vygenerovaný QR kód pro platbu diff --git a/types/paths/pizzaDay/dismissQr.yml b/types/paths/pizzaDay/dismissQr.yml index ffa95bb..0aa6fba 100644 --- a/types/paths/pizzaDay/dismissQr.yml +++ b/types/paths/pizzaDay/dismissQr.yml @@ -1,17 +1,17 @@ post: operationId: dismissQr - summary: Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených). + summary: Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených). requestBody: required: true content: application/json: schema: properties: - date: - description: Datum Pizza day, ke kterému se QR kód vztahuje + id: + description: Unikátní identifikátor QR kódu (z PendingQr.id) type: string required: - - date + - id responses: "200": description: QR kód byl označen jako uhrazený. diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 22b4a3d..f2df7d0 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -664,19 +664,23 @@ ClearMockDataRequest: # --- NEVYŘÍZENÉ QR KÓDY --- PendingQr: - description: Nevyřízený QR kód pro platbu z předchozího Pizza day + description: Nevyřízený QR kód pro platbu type: object additionalProperties: false required: + - id - date - creator - totalPrice properties: + id: + description: Unikátní identifikátor QR kódu (umožňuje více QR na strávníka na den) + type: string date: - description: Datum Pizza day, ke kterému se QR kód vztahuje + description: Datum, ke kterému se QR kód vztahuje type: string creator: - description: Jméno zakladatele Pizza day (objednávajícího) + description: Jméno uživatele, který QR vygeneroval (příjemce platby) type: string totalPrice: description: Celková cena objednávky v Kč From fe6bb3290e94c81208d3ec7a560b702f2ce8ae1b Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 00:25:10 +0200 Subject: [PATCH 12/37] =?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", From 64d85036fdc873332181a0a36a81d5479cd6b4ad Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 15:42:08 +0200 Subject: [PATCH 13/37] =?UTF-8?q?test:=20roz=C5=A1=C3=AD=C5=99en=C3=AD=20s?= =?UTF-8?q?erverov=C3=BDch=20test=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .woodpecker/workflow.yaml | 8 +- server/.gitignore | 1 + server/jest.config.js | 5 + server/package.json | 2 + server/src/qr.ts | 4 +- server/src/restaurants.ts | 8 +- server/src/storage/index.ts | 6 +- server/src/storage/memory.ts | 27 ++++ server/src/tests/auth.test.ts | 62 ++++++++ server/src/tests/chefie.test.ts | 38 +++++ .../tests/fixtures/chefie-salat-caesar.html | 16 ++ .../tests/fixtures/chefie-salat-recky.html | 16 ++ server/src/tests/fixtures/chefie-salaty.html | 13 ++ server/src/tests/fixtures/senkserikova.html | 33 ++++ server/src/tests/fixtures/sladovnicka.html | 55 +++++++ server/src/tests/fixtures/techtower.html | 29 ++++ server/src/tests/generateQr.test.ts | 47 ++++++ server/src/tests/pizza.test.ts | 144 ++++++++++++++++++ server/src/tests/qr.test.ts | 36 +++++ server/src/tests/qrRoutes.test.ts | 103 +++++++++++++ server/src/tests/restaurants-helpers.test.ts | 73 +++++++++ server/src/tests/scrapers.test.ts | 117 ++++++++++++++ server/src/tests/setupEnv.ts | 4 + server/src/tests/statsRoutes.test.ts | 60 ++++++++ server/src/tests/storage-contract.test.ts | 85 +++++++++++ server/src/tests/voting.test.ts | 63 ++++++++ server/src/tests/votingRoutes.test.ts | 76 +++++++++ server/tsconfig.json | 3 + server/yarn.lock | 126 ++++++++++++++- 29 files changed, 1250 insertions(+), 10 deletions(-) create mode 100644 server/jest.config.js create mode 100644 server/src/storage/memory.ts create mode 100644 server/src/tests/auth.test.ts create mode 100644 server/src/tests/chefie.test.ts create mode 100644 server/src/tests/fixtures/chefie-salat-caesar.html create mode 100644 server/src/tests/fixtures/chefie-salat-recky.html create mode 100644 server/src/tests/fixtures/chefie-salaty.html create mode 100644 server/src/tests/fixtures/senkserikova.html create mode 100644 server/src/tests/fixtures/sladovnicka.html create mode 100644 server/src/tests/fixtures/techtower.html create mode 100644 server/src/tests/generateQr.test.ts create mode 100644 server/src/tests/pizza.test.ts create mode 100644 server/src/tests/qr.test.ts create mode 100644 server/src/tests/qrRoutes.test.ts create mode 100644 server/src/tests/restaurants-helpers.test.ts create mode 100644 server/src/tests/scrapers.test.ts create mode 100644 server/src/tests/setupEnv.ts create mode 100644 server/src/tests/statsRoutes.test.ts create mode 100644 server/src/tests/storage-contract.test.ts create mode 100644 server/src/tests/voting.test.ts create mode 100644 server/src/tests/votingRoutes.test.ts diff --git a/.woodpecker/workflow.yaml b/.woodpecker/workflow.yaml index bfbff41..93f6f4e 100644 --- a/.woodpecker/workflow.yaml +++ b/.woodpecker/workflow.yaml @@ -25,9 +25,15 @@ steps: - cd client - yarn install --frozen-lockfile depends_on: [Generate TypeScript types] - - name: Build server + - name: Test server depends_on: [Install server dependencies] image: *node_image + commands: + - cd server + - yarn test + - name: Build server + depends_on: [Test server] + image: *node_image commands: - cd server - yarn build diff --git a/server/.gitignore b/server/.gitignore index bf2c19e..3e0190c 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -2,6 +2,7 @@ /dist /resources/easterEggs /src/gen +/coverage .env.production .env.development .easter-eggs.json diff --git a/server/jest.config.js b/server/jest.config.js new file mode 100644 index 0000000..d1aab09 --- /dev/null +++ b/server/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + testMatch: ['/src/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + setupFiles: ['/src/tests/setupEnv.ts'], +}; diff --git a/server/package.json b/server/package.json index 0b0ccfb..e40af6b 100644 --- a/server/package.json +++ b/server/package.json @@ -19,10 +19,12 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", "@types/request-promise": "^4.1.48", + "@types/supertest": "^6.0.0", "@types/web-push": "^3.6.4", "babel-jest": "^30.2.0", "jest": "^30.2.0", "nodemon": "^3.1.10", + "supertest": "^7.0.0", "ts-node": "^10.9.1", "typescript": "^5.9.3" }, diff --git a/server/src/qr.ts b/server/src/qr.ts index b9a5507..6327972 100644 --- a/server/src/qr.ts +++ b/server/src/qr.ts @@ -14,7 +14,7 @@ const storage = getStorage(); * * @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100) */ -function convertBbanToIban(bankAccountNumber: string): string { +export function convertBbanToIban(bankAccountNumber: string): string { // TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl let prefix: string = ''; let accountNumber: string = bankAccountNumber; @@ -58,7 +58,7 @@ function createStorageKey(customerName: string, id: string): string { export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise { // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků if (message.indexOf('*') >= 0) { - message = message.replace('*', ''); + message = message.replace(/\*/g, ''); } if (message.length > 60) { message = message.substring(0, 60); diff --git a/server/src/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/storage/index.ts b/server/src/storage/index.ts index 3ac8483..307c16d 100644 --- a/server/src/storage/index.ts +++ b/server/src/storage/index.ts @@ -3,20 +3,24 @@ import path from 'path'; import { StorageInterface } from "./StorageInterface"; import JsonStorage from "./json"; import RedisStorage from "./redis"; +import MemoryStorage from "./memory"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) }); const JSON_KEY = 'json'; const REDIS_KEY = 'redis'; +const MEMORY_KEY = 'memory'; let storage: StorageInterface; if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) { storage = new JsonStorage(); } else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) { storage = new RedisStorage(); +} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) { + storage = new MemoryStorage(); } else { - throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'"); + throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'"); } (async () => { diff --git a/server/src/storage/memory.ts b/server/src/storage/memory.ts new file mode 100644 index 0000000..b75a41f --- /dev/null +++ b/server/src/storage/memory.ts @@ -0,0 +1,27 @@ +import { StorageInterface } from "./StorageInterface"; + +const store = new Map(); + +/** Vymaže všechna data z in-memory úložiště. Slouží k resetu mezi testy. */ +export function resetMemoryStorage(): void { + store.clear(); +} + +/** + * In-memory implementace úložiště. Používá se výhradně v testovacím prostředí. + */ +export default class MemoryStorage implements StorageInterface { + + hasData(key: string): Promise { + return Promise.resolve(store.has(key)); + } + + getData(key: string): Promise { + return Promise.resolve(store.get(key) as Type | undefined); + } + + setData(key: string, data: Type): Promise { + store.set(key, data); + return Promise.resolve(); + } +} diff --git a/server/src/tests/auth.test.ts b/server/src/tests/auth.test.ts new file mode 100644 index 0000000..f63def1 --- /dev/null +++ b/server/src/tests/auth.test.ts @@ -0,0 +1,62 @@ +import { generateToken, verify, getLogin, getTrusted } from '../auth'; + +const VALID_SECRET = 'test-jwt-secret-ktery-ma-alespon-32-znaku'; +const SHORT_SECRET = 'kratky'; + +beforeEach(() => { + process.env.JWT_SECRET = VALID_SECRET; +}); + +test('generateToken → getLogin vrátí stejný login', () => { + const token = generateToken('jannovak'); + expect(getLogin(token)).toBe('jannovak'); +}); + +test('getTrusted vrátí false, pokud nebyl příznak předán', () => { + const token = generateToken('jannovak'); + expect(getTrusted(token)).toBe(false); +}); + +test('getTrusted vrátí true, pokud byl příznak předán jako true', () => { + const token = generateToken('jannovak', true); + expect(getTrusted(token)).toBe(true); +}); + +test('verify vrátí true pro platný token', () => { + const token = generateToken('jannovak'); + expect(verify(token)).toBe(true); +}); + +test('verify vrátí false pro token podepsaný jiným secretem', () => { + const token = generateToken('jannovak'); + process.env.JWT_SECRET = 'uplne-jiny-secret-ktery-ma-take-32-znaku'; + expect(verify(token)).toBe(false); +}); + +test('verify vrátí false pro pozměněný token', () => { + const token = generateToken('jannovak'); + const tampered = token.slice(0, -5) + 'xxxxx'; + expect(verify(tampered)).toBe(false); +}); + +test('generateToken vyhodí chybu pro chybějící JWT_SECRET', () => { + delete process.env.JWT_SECRET; + expect(() => generateToken('jannovak')).toThrow('JWT_SECRET'); +}); + +test('generateToken vyhodí chybu pro příliš krátký JWT_SECRET', () => { + process.env.JWT_SECRET = SHORT_SECRET; + expect(() => generateToken('jannovak')).toThrow('32'); +}); + +test('generateToken vyhodí chybu pro prázdný login', () => { + expect(() => generateToken('')).toThrow(); +}); + +test('generateToken vyhodí chybu pro login obsahující jen mezery', () => { + expect(() => generateToken(' ')).toThrow(); +}); + +test('getLogin vyhodí chybu pro chybějící token', () => { + expect(() => getLogin(undefined)).toThrow(); +}); diff --git a/server/src/tests/chefie.test.ts b/server/src/tests/chefie.test.ts new file mode 100644 index 0000000..5637a06 --- /dev/null +++ b/server/src/tests/chefie.test.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { downloadSalaty } from '../chefie'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const fixturesDir = path.join(__dirname, 'fixtures'); +const loadFixture = (name: string) => fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + +beforeEach(() => { + jest.resetAllMocks(); + // První volání = stránka se seznamem salátů, následující volání = jednotlivé stránky salátů + mockedAxios.get = jest.fn() + .mockResolvedValueOnce({ data: loadFixture('chefie-salaty.html') }) + .mockResolvedValueOnce({ data: loadFixture('chefie-salat-caesar.html') }) + .mockResolvedValueOnce({ data: loadFixture('chefie-salat-recky.html') }); +}); + +test('downloadSalaty vrátí seznam salátů', async () => { + const salaty = await downloadSalaty(false); + expect(salaty).toHaveLength(2); +}); + +test('saláty mají name a ingredients', async () => { + const salaty = await downloadSalaty(false); + expect(salaty[0].name).toBe('Caesar salát'); + expect(salaty[0].ingredients).toContain('Kuřecí maso'); +}); + +test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => { + const salaty = await downloadSalaty(false); + // Caesar sticker price = 129, box = 13 + expect(salaty[0].price).toBe(129 + 13); + // Řecký sticker price = 119, box = 13 + expect(salaty[1].price).toBe(119 + 13); +}); diff --git a/server/src/tests/fixtures/chefie-salat-caesar.html b/server/src/tests/fixtures/chefie-salat-caesar.html new file mode 100644 index 0000000..c2c7973 --- /dev/null +++ b/server/src/tests/fixtures/chefie-salat-caesar.html @@ -0,0 +1,16 @@ + + + +
+

Caesar salát

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

Řecký salát

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

+ Obědy 12.5.-16.5.2025 +

+
+ +

Pondělí

+

• Polévka dne 1

+

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

+

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

+

Úterý

+

• Česnečka 1

+

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

+

Středa

+

• Hovězí vývar s nudlemi 1

+

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

+

Čtvrtek

+

• Dršťková polévka 1

+

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

+

Pátek

+

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

+

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

+
+ + diff --git a/server/src/tests/generateQr.test.ts b/server/src/tests/generateQr.test.ts new file mode 100644 index 0000000..5648907 --- /dev/null +++ b/server/src/tests/generateQr.test.ts @@ -0,0 +1,47 @@ +import axios from 'axios'; +import { generateQr, getQr } from '../qr'; +import { resetMemoryStorage } from '../storage/memory'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const FAKE_IMAGE = Buffer.from('fake-png-data'); + +beforeEach(() => { + resetMemoryStorage(); + jest.resetAllMocks(); + mockedAxios.get = jest.fn().mockResolvedValue({ data: FAKE_IMAGE }); +}); + +test('generateQr zavolá Paylibo API se správnými parametry', async () => { + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, 'Pizza Margherita', 'test-uuid-1'); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + const [url, config] = (mockedAxios.get as jest.Mock).mock.calls[0]; + expect(url).toContain('paylibo.com'); + expect(config.params.amount).toBe(149); + expect(config.params.iban).toBeDefined(); +}); + +test('generateQr uloží base64 obrázek do storage', async () => { + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, 'Pizza', 'test-uuid-2'); + const img = await getQr('jannovak', 'test-uuid-2'); + expect(Buffer.isBuffer(img)).toBe(true); + expect(img).toEqual(FAKE_IMAGE); +}); + +test('generateQr ořeže zprávu delší než 60 znaků', async () => { + const dlouhaZprava = 'Pizza ' + 'x'.repeat(60); + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, dlouhaZprava, 'test-uuid-3'); + const [, config] = (mockedAxios.get as jest.Mock).mock.calls[0]; + expect(config.params.message.length).toBeLessThanOrEqual(60); +}); + +test('generateQr odstraní hvězdičku ze zprávy', async () => { + await generateQr('jannovak', '19-2000145399/0800', 'Jan Novák', 149, 'Pizza *Margherita*', 'test-uuid-4'); + const [, config] = (mockedAxios.get as jest.Mock).mock.calls[0]; + expect(config.params.message).not.toContain('*'); +}); + +test('getQr hodí chybu pro neexistující ID', async () => { + await expect(getQr('jannovak', 'neexistuje')).rejects.toThrow('nebyl nalezen'); +}); diff --git a/server/src/tests/pizza.test.ts b/server/src/tests/pizza.test.ts new file mode 100644 index 0000000..1c444b2 --- /dev/null +++ b/server/src/tests/pizza.test.ts @@ -0,0 +1,144 @@ +import { resetMemoryStorage } from '../storage/memory'; +import getStorage from '../storage'; +import { formatDate } from '../utils'; +import { + createPizzaDay, + addPizzaOrder, + removePizzaOrder, + updatePizzaFee, + lockPizzaDay, +} from '../pizza'; +import { ClientData, PizzaDayState } from '../../../types/gen/types.gen'; + +jest.mock('../notifikace', () => ({ + callNotifikace: jest.fn().mockResolvedValue([]), +})); +jest.mock('../qr', () => ({ + generateQr: jest.fn().mockResolvedValue(undefined), +})); +// downloadPizzy/downloadSalaty voláme jen když pizzaList/salatList chybí – vyhneme se reálnému HTTP +jest.mock('../chefie', () => ({ + downloadPizzy: jest.fn().mockResolvedValue([]), + downloadSalaty: jest.fn().mockResolvedValue([]), +})); + +const today = formatDate(new Date()); +const CREATOR = 'kreator'; +const USER = 'uzivatel'; + +const PIZZA = { name: 'Margherita', ingredients: [], sizes: [] } as any; +const SIZE_M = { varId: 1, size: '30cm', price: 179, boxPrice: 13, pizzaPrice: 166 }; +const SIZE_L = { varId: 2, size: '35cm', price: 219, boxPrice: 15, pizzaPrice: 204 }; + +async function seedPizzaDay(state: PizzaDayState = PizzaDayState.CREATED): Promise { + const storage = getStorage(); + const data: ClientData = { + todayDayIndex: 0, + date: today, + isWeekend: false, + dayIndex: 0, + choices: {}, + pizzaDay: { + state, + creator: CREATOR, + orders: [], + }, + pizzaList: [], + salatList: [], + }; + await storage.setData(today, data); +} + +beforeEach(async () => { + resetMemoryStorage(); +}); + +test('createPizzaDay vytvoří pizza day ve stavu CREATED', async () => { + const data = await createPizzaDay(CREATOR); + expect(data.pizzaDay?.state).toBe(PizzaDayState.CREATED); + expect(data.pizzaDay?.creator).toBe(CREATOR); +}); + +test('createPizzaDay vyhodí chybu, pokud pizza day pro dnešek již existuje', async () => { + await seedPizzaDay(); + await expect(createPizzaDay(CREATOR)).rejects.toThrow('již existuje'); +}); + +test('addPizzaOrder přičte cenu pizzy k totalPrice objednávky', async () => { + await seedPizzaDay(); + const data = await addPizzaOrder(USER, PIZZA, SIZE_M); + const order = data.pizzaDay!.orders!.find(o => o.customer === USER); + expect(order?.totalPrice).toBe(SIZE_M.price); + expect(order?.pizzaList).toHaveLength(1); +}); + +test('addPizzaOrder sečte více pizz ve stejné objednávce', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + const data = await addPizzaOrder(USER, { ...PIZZA, name: 'Quattro' }, SIZE_L); + const order = data.pizzaDay!.orders!.find(o => o.customer === USER); + expect(order?.totalPrice).toBe(SIZE_M.price + SIZE_L.price); + expect(order?.pizzaList).toHaveLength(2); +}); + +test('addPizzaOrder vyhodí chybu pro pizza day ve stavu LOCKED', async () => { + await seedPizzaDay(PizzaDayState.LOCKED); + await expect(addPizzaOrder(USER, PIZZA, SIZE_M)).rejects.toThrow(PizzaDayState.CREATED); +}); + +test('removePizzaOrder odečte cenu a odstraní položku z objednávky', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + await addPizzaOrder(USER, { ...PIZZA, name: 'Diavola' }, SIZE_L); + const variant = { varId: SIZE_M.varId, name: PIZZA.name, size: SIZE_M.size, price: SIZE_M.price }; + const data = await removePizzaOrder(USER, variant); + const order = data.pizzaDay!.orders!.find(o => o.customer === USER); + expect(order?.totalPrice).toBe(SIZE_L.price); + expect(order?.pizzaList).toHaveLength(1); +}); + +test('removePizzaOrder odstraní celou objednávku, pokud je prázdná', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + const variant = { varId: SIZE_M.varId, name: PIZZA.name, size: SIZE_M.size, price: SIZE_M.price }; + const data = await removePizzaOrder(USER, variant); + const order = data.pizzaDay!.orders!.find(o => o.customer === USER); + expect(order).toBeUndefined(); +}); + +test('updatePizzaFee přidá příplatek a přepočítá celkovou cenu', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + const data = await updatePizzaFee(CREATOR, USER, 'Balné', 20); + const order = data.pizzaDay!.orders!.find(o => o.customer === USER); + expect(order?.fee).toEqual({ text: 'Balné', price: 20 }); + expect(order?.totalPrice).toBe(SIZE_M.price + 20); +}); + +test('updatePizzaFee s cenou undefined odstraní příplatek', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + await updatePizzaFee(CREATOR, USER, 'Balné', 20); + const data = await updatePizzaFee(CREATOR, USER, undefined, undefined); + const order = data.pizzaDay!.orders!.find(o => o.customer === USER); + expect(order?.fee).toBeUndefined(); + expect(order?.totalPrice).toBe(SIZE_M.price); +}); + +test('updatePizzaFee vyhodí chybu, pokud volá jiný uživatel než tvůrce', async () => { + await seedPizzaDay(); + await addPizzaOrder(USER, PIZZA, SIZE_M); + await expect(updatePizzaFee(USER, USER, 'Balné', 20)).rejects.toThrow('zakladatel'); +}); + +test('lockPizzaDay přepne stav na LOCKED', async () => { + await seedPizzaDay(); + const data = await lockPizzaDay(CREATOR); + expect(data.pizzaDay?.state).toBe(PizzaDayState.LOCKED); +}); + +test('lockPizzaDay vyhodí chybu pro jiného uživatele než tvůrce', async () => { + await seedPizzaDay(); + // Chybová zpráva obsahuje login volajícího (USER), ne tvůrce + await expect(lockPizzaDay(USER)).rejects.toThrow(USER); +}); diff --git a/server/src/tests/qr.test.ts b/server/src/tests/qr.test.ts new file mode 100644 index 0000000..34d50a9 --- /dev/null +++ b/server/src/tests/qr.test.ts @@ -0,0 +1,36 @@ +import { convertBbanToIban } from '../qr'; + +test('konverze BBAN s prefixem na IBAN', () => { + // Číslo účtu 19-2000145399/0800 (Česká spořitelna) → CZ6508000000192000145399 + const iban = convertBbanToIban('19-2000145399/0800'); + expect(iban).toBe('CZ6508000000192000145399'); + expect(iban).toHaveLength(24); +}); + +test('konverze BBAN bez prefixu na IBAN', () => { + // Číslo účtu 2000145399/0800 (bez prefixu) → prefix se doplní jako 000000 + const iban = convertBbanToIban('2000145399/0800'); + expect(iban).toBe('CZ7908000000002000145399'); + expect(iban).toHaveLength(24); +}); + +test('konverze BBAN s krátkým číslem účtu – zero-padding', () => { + // Krátké číslo účtu 123456/0100 → prefix 000000, account 0000123456 + const iban = convertBbanToIban('123456/0100'); + expect(iban).toHaveLength(24); + // bankCode(4) + prefix(6) + account(10) = 20 číslic za CZ+checkdigits + expect(iban).toMatch(/^CZ\d{2}01000000000000123456$/); +}); + +test('kontrolní číslice jsou platné (mod 97)', () => { + const iban = convertBbanToIban('19-2000145399/0800'); + // Přesuneme první 4 znaky na konec, nahradíme písmena čísly a mod 97 musí dát 1 + const rearranged = iban.slice(4) + iban.slice(0, 4); + const numeric = rearranged.replace(/[A-Z]/g, (c) => (c.charCodeAt(0) - 55).toString()); + expect(BigInt(numeric) % BigInt(97)).toBe(BigInt(1)); +}); + +test('výsledek vždy začíná CZ', () => { + expect(convertBbanToIban('100-2000145399/0300')).toMatch(/^CZ/); + expect(convertBbanToIban('2000145399/0100')).toMatch(/^CZ/); +}); diff --git a/server/src/tests/qrRoutes.test.ts b/server/src/tests/qrRoutes.test.ts new file mode 100644 index 0000000..a558111 --- /dev/null +++ b/server/src/tests/qrRoutes.test.ts @@ -0,0 +1,103 @@ +import express from 'express'; +import request from 'supertest'; +import bodyParser from 'body-parser'; +import axios from 'axios'; +import { generateToken } from '../auth'; +import { resetMemoryStorage } from '../storage/memory'; +import qrRouter from '../routes/qrRoutes'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use('/api/qr', qrRouter); + app.use((err: any, _req: any, res: any, _next: any) => { + res.status(400).json({ error: err.message }); + }); + return app; +} + +const TOKEN = `Bearer ${generateToken('kreator')}`; + +beforeEach(() => { + resetMemoryStorage(); + mockedAxios.get = jest.fn().mockResolvedValue({ data: Buffer.from('fake-png') }); +}); + +const VALID_BODY = { + recipients: [ + { login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 149 }, + { login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 179 }, + ], + bankAccount: '19-2000145399/0800', + bankAccountHolder: 'Jan Novák', +}; + +test('POST /generate vrátí 200 s počtem vygenerovaných QR kódů', async () => { + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(VALID_BODY); + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); +}); + +test('POST /generate vrátí 400 pro prázdné recipients', async () => { + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send({ ...VALID_BODY, recipients: [] }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('příjemců'); +}); + +test('POST /generate vrátí 400 pro chybějící bankAccount', async () => { + const { bankAccount: _, ...body } = VALID_BODY; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('účtu'); +}); + +test('POST /generate vrátí 400 pro zápornou částku', async () => { + const body = { + ...VALID_BODY, + recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: -1 }], + }; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('částku'); +}); + +test('POST /generate vrátí 400 pro částku s více než 2 desetinnými místy', async () => { + const body = { + ...VALID_BODY, + recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.999 }], + }; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('desetinná'); +}); + +test('POST /generate vrátí 400 pro příjemce bez login', async () => { + const body = { + ...VALID_BODY, + recipients: [{ purpose: 'Pizza', amount: 149 }], + }; + const res = await request(buildApp()) + .post('/api/qr/generate') + .set('Authorization', TOKEN) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toContain('login'); +}); diff --git a/server/src/tests/restaurants-helpers.test.ts b/server/src/tests/restaurants-helpers.test.ts new file mode 100644 index 0000000..85546ff --- /dev/null +++ b/server/src/tests/restaurants-helpers.test.ts @@ -0,0 +1,73 @@ +import { parseAllergens, isTextSoupName, sanitizeText, capitalize } from '../restaurants'; + +// parseAllergens +test('parseAllergens rozpozná alergeny na konci názvu', () => { + const result = parseAllergens('Svíčková na smetaně 1, 3, 7'); + expect(result.cleanName).toBe('Svíčková na smetaně'); + expect(result.allergens).toEqual([1, 3, 7]); +}); + +test('parseAllergens vrátí prázdné pole alergenů, pokud žádné nejsou', () => { + const result = parseAllergens('Svíčková na smetaně'); + expect(result.cleanName).toBe('Svíčková na smetaně'); + expect(result.allergens).toEqual([]); +}); + +test('parseAllergens zpracuje jednočíselný alergen', () => { + const result = parseAllergens('Polévka dne 1'); + expect(result.cleanName).toBe('Polévka dne'); + expect(result.allergens).toEqual([1]); +}); + +test('parseAllergens neodstraní čísla uvnitř názvu', () => { + const result = parseAllergens('Pizza č. 4 Quattro formaggi 1, 7'); + expect(result.allergens).toEqual([1, 7]); + expect(result.cleanName).toContain('4'); +}); + +// isTextSoupName +test('isTextSoupName vrátí true pro "polévka"', () => { + expect(isTextSoupName('Polévka dne')).toBe(true); +}); + +test('isTextSoupName vrátí true pro "česnečka"', () => { + expect(isTextSoupName('Česnečka se sýrem')).toBe(true); +}); + +test('isTextSoupName vrátí true pro "vývar"', () => { + expect(isTextSoupName('Hovězí vývar s nudlemi')).toBe(true); +}); + +test('isTextSoupName vrátí false pro hlavní jídlo', () => { + expect(isTextSoupName('Svíčková na smetaně s knedlíkem')).toBe(false); +}); + +test('isTextSoupName není case-sensitive', () => { + expect(isTextSoupName('POLÉVKA DNE')).toBe(true); +}); + +// sanitizeText +test('sanitizeText odstraní tabulátor (nenahradí mezerou)', () => { + expect(sanitizeText('\ttext')).toBe('text'); +}); + +test('sanitizeText opraví mezery kolem čárky', () => { + expect(sanitizeText('jídlo , příloha')).toBe('jídlo, příloha'); +}); + +test('sanitizeText ořeže mezery ze začátku a konce', () => { + expect(sanitizeText(' text ')).toBe('text'); +}); + +// capitalize +test('capitalize převede první písmeno na velké', () => { + expect(capitalize('pondělí')).toBe('Pondělí'); +}); + +test('capitalize nezmění zbytek řetězce', () => { + expect(capitalize('pÁTEK')).toBe('PÁTEK'); +}); + +test('capitalize vrátí prázdný řetězec pro prázdný vstup', () => { + expect(capitalize('')).toBe(''); +}); diff --git a/server/src/tests/scrapers.test.ts b/server/src/tests/scrapers.test.ts new file mode 100644 index 0000000..06c0669 --- /dev/null +++ b/server/src/tests/scrapers.test.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { getMenuSladovnicka, getMenuTechTower, getMenuSenkSerikova, StaleWeekError } from '../restaurants'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const fixturesDir = path.join(__dirname, 'fixtures'); +const loadFixture = (name: string) => fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + +// Pondělí 12.5.2025 +const MONDAY = new Date('2025-05-12'); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('Sladovnicka parser', () => { + beforeEach(() => { + mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('sladovnicka.html') }); + }); + + test('vrátí pole o délce 5 (jeden záznam na každý pracovní den)', async () => { + const menu = await getMenuSladovnicka(MONDAY); + expect(menu).toHaveLength(5); + }); + + test('pondělní menu obsahuje aspoň jedno jídlo', async () => { + const menu = await getMenuSladovnicka(MONDAY); + expect(menu[0].length).toBeGreaterThan(0); + }); + + test('první položka pondělního dne je polévka (isSoup=true)', async () => { + const menu = await getMenuSladovnicka(MONDAY); + expect(menu[0][0].isSoup).toBe(true); + }); + + test('jídla mají name, price a amount', async () => { + const menu = await getMenuSladovnicka(MONDAY); + const jidlo = menu[0][1]; + expect(jidlo.name).toBeTruthy(); + expect(jidlo.price).toBeTruthy(); + expect(jidlo.amount).toBeTruthy(); + }); + + test('alergeny jsou naparsovány jako čísla', async () => { + const menu = await getMenuSladovnicka(MONDAY); + const polievka = menu[0][0]; + expect(Array.isArray(polievka.allergens)).toBe(true); + expect(polievka.allergens!.length).toBeGreaterThan(0); + expect(typeof polievka.allergens![0]).toBe('number'); + }); +}); + +describe('TechTower parser', () => { + beforeEach(() => { + mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('techtower.html') }); + }); + + test('vrátí pole o délce 5', async () => { + const menu = await getMenuTechTower(MONDAY); + expect(menu).toHaveLength(5); + }); + + test('pondělní menu obsahuje polévku a hlavní jídla', async () => { + const menu = await getMenuTechTower(MONDAY); + expect(menu[0].some(f => f.isSoup)).toBe(true); + expect(menu[0].some(f => !f.isSoup)).toBe(true); + }); + + test('TechTower hodí StaleWeekError, pokud datum v hlavičce neodpovídá', async () => { + // Fixture obsahuje "12.5.-16.5.2025" – jiný týden = stale + const jinaStreda = new Date('2025-04-14'); + await expect(getMenuTechTower(jinaStreda)).rejects.toBeInstanceOf(StaleWeekError); + }); + + test('StaleWeekError obsahuje naparsovaná data', async () => { + const jinaStreda = new Date('2025-04-14'); + try { + await getMenuTechTower(jinaStreda); + } catch (e) { + expect(e).toBeInstanceOf(StaleWeekError); + const err = e as StaleWeekError; + expect(err.food).toHaveLength(5); + } + }); +}); + +describe('SenkSerikova parser', () => { + beforeEach(() => { + // SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string + mockedAxios.get = jest.fn().mockResolvedValue({ + data: Buffer.from(loadFixture('senkserikova.html')), + headers: {} + }); + }); + + test('parser provede HTTP request a vrátí pole', async () => { + const menu = await getMenuSenkSerikova(MONDAY); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(Array.isArray(menu)).toBe(true); + }); + + test('výsledné dny s obsahem mají správnou strukturu (name, price, isSoup)', async () => { + const menu = await getMenuSenkSerikova(MONDAY); + // Protože MONDAY je v minulosti, parser vrátí placeholdery pro všechny pracovní + // dny a .menicka elementy přidá za ně – hledáme aspoň jeden den s reálnými daty + const denSJidlem = menu.find(den => + den.length > 0 && den[0].name !== 'Pro tento den není uveřejněna nabídka jídel' + ); + if (denSJidlem) { + expect(typeof denSJidlem[0].name).toBe('string'); + expect(typeof denSJidlem[0].isSoup).toBe('boolean'); + } + }); +}); diff --git a/server/src/tests/setupEnv.ts b/server/src/tests/setupEnv.ts new file mode 100644 index 0000000..08a9775 --- /dev/null +++ b/server/src/tests/setupEnv.ts @@ -0,0 +1,4 @@ +process.env.NODE_ENV = 'test'; +process.env.STORAGE = 'memory'; +process.env.JWT_SECRET = 'test-jwt-secret-ktery-ma-alespon-32-znaku'; +process.env.LOGOUT_URL = 'http://localhost/logout'; diff --git a/server/src/tests/statsRoutes.test.ts b/server/src/tests/statsRoutes.test.ts new file mode 100644 index 0000000..23f338c --- /dev/null +++ b/server/src/tests/statsRoutes.test.ts @@ -0,0 +1,60 @@ +import express from 'express'; +import request from 'supertest'; +import bodyParser from 'body-parser'; +import { generateToken } from '../auth'; +import { resetMemoryStorage } from '../storage/memory'; +import statsRouter from '../routes/statsRoutes'; + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use('/api/stats', statsRouter); + return app; +} + +const TOKEN = `Bearer ${generateToken('testuser')}`; + +beforeEach(() => { + resetMemoryStorage(); +}); + +test('GET /stats bez parametrů vrátí 400', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .set('Authorization', TOKEN); + expect(res.status).toBe(400); +}); + +test('GET /stats s rozsahem 4 dní vrátí 200', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: '2024-01-08', endDate: '2024-01-12' }) + .set('Authorization', TOKEN); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); +}); + +test('GET /stats s rozsahem nad 4 dní vrátí 400', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: '2024-01-01', endDate: '2024-01-10' }) + .set('Authorization', TOKEN); + expect(res.status).toBe(400); +}); + +test('GET /stats s budoucím datem vrátí 400', async () => { + const futureStart = '2099-01-01'; + const futureEnd = '2099-01-05'; + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: futureStart, endDate: futureEnd }) + .set('Authorization', TOKEN); + expect(res.status).toBe(400); +}); + +test('GET /stats bez tokenu vrátí chybu', async () => { + const res = await request(buildApp()) + .get('/api/stats') + .query({ startDate: '2024-01-08', endDate: '2024-01-12' }); + expect(res.status).toBeGreaterThanOrEqual(400); +}); diff --git a/server/src/tests/storage-contract.test.ts b/server/src/tests/storage-contract.test.ts new file mode 100644 index 0000000..cf8717b --- /dev/null +++ b/server/src/tests/storage-contract.test.ts @@ -0,0 +1,85 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import { StorageInterface } from '../storage/StorageInterface'; +import { resetMemoryStorage } from '../storage/memory'; +import MemoryStorage from '../storage/memory'; +import JsonStorage from '../storage/json'; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'luncher-test-')); +const tempDbPath = path.join(tempDir, 'test-db.json'); + +// Parametrické spuštění stejné sady testů pro obě implementace +const implementations: [string, () => StorageInterface, () => void][] = [ + ['MemoryStorage', () => new MemoryStorage(), resetMemoryStorage], + ['JsonStorage', () => { + // Zajistíme čistý stav souboru před každým testem + if (fs.existsSync(tempDbPath)) { + fs.unlinkSync(tempDbPath); + } + // JsonStorage načte/vytvoří soubor při inicializaci, musíme obalit + const JsonStorageDynamic = require('../storage/json').default; + // Přepíšeme dbPath přes prototyp – pro testy použijeme tmpdir + const inst = Object.create(JsonStorageDynamic.prototype); + const JSONdb = require('simple-json-db'); + (inst as any).db = new JSONdb(tempDbPath); + inst.hasData = async (key: string) => Promise.resolve((inst as any).db.has(key)); + inst.getData = async (key: string) => (inst as any).db.get(key); + inst.setData = async (key: string, data: any) => { (inst as any).db.set(key, data); return Promise.resolve(); }; + return inst; + }, () => { + if (fs.existsSync(tempDbPath)) { + fs.unlinkSync(tempDbPath); + } + }], +]; + +describe.each(implementations)('%s splňuje StorageInterface kontrakt', (name, factory, reset) => { + let storage: StorageInterface; + + beforeEach(() => { + reset(); + storage = factory(); + }); + + test('hasData vrátí false pro neexistující klíč', async () => { + expect(await storage.hasData('neexistujici')).toBe(false); + }); + + test('setData + hasData vrátí true', async () => { + await storage.setData('klic', { value: 1 }); + expect(await storage.hasData('klic')).toBe(true); + }); + + test('setData + getData vrátí uložená data', async () => { + const data = { name: 'Jan', score: 42 }; + await storage.setData('testkey', data); + const result = await storage.getData('testkey'); + expect(result).toEqual(data); + }); + + test('getData pro neexistující klíč vrátí undefined', async () => { + const result = await storage.getData('neexistujici'); + expect(result).toBeUndefined(); + }); + + test('setData přepíše existující data', async () => { + await storage.setData('klic', { version: 1 }); + await storage.setData('klic', { version: 2 }); + const result = await storage.getData<{ version: number }>('klic'); + expect(result?.version).toBe(2); + }); + + test('různé klíče jsou nezávislé', async () => { + await storage.setData('a', { val: 'A' }); + await storage.setData('b', { val: 'B' }); + expect((await storage.getData<{ val: string }>('a'))?.val).toBe('A'); + expect((await storage.getData<{ val: string }>('b'))?.val).toBe('B'); + }); +}); + +afterAll(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } +}); diff --git a/server/src/tests/voting.test.ts b/server/src/tests/voting.test.ts new file mode 100644 index 0000000..ba9109a --- /dev/null +++ b/server/src/tests/voting.test.ts @@ -0,0 +1,63 @@ +import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting'; +import { resetMemoryStorage } from '../storage/memory'; +import { FeatureRequest } from '../../../types/gen/types.gen'; + +const OPT_A = FeatureRequest.STATISTICS; +const OPT_B = FeatureRequest.UI; + +beforeEach(() => { + resetMemoryStorage(); +}); + +test('přidání hlasu a přečtení přes getUserVotes', async () => { + await updateFeatureVote('jannovak', OPT_A, true); + const votes = await getUserVotes('jannovak'); + expect(votes).toContain(OPT_A); +}); + +test('opakované přidání stejného hlasu vyhodí chybu', async () => { + await updateFeatureVote('jannovak', OPT_A, true); + await expect(updateFeatureVote('jannovak', OPT_A, true)) + .rejects.toThrow('Pro tuto možnost jste již hlasovali'); +}); + +test('překročení limitu 4 hlasů vyhodí chybu', async () => { + const options = Object.values(FeatureRequest); + for (let i = 0; i < 4; i++) { + await updateFeatureVote('jannovak', options[i], true); + } + await expect(updateFeatureVote('jannovak', options[4], true)) + .rejects.toThrow('maximálně 4 možnosti'); +}); + +test('odebrání hlasu funguje správně', async () => { + await updateFeatureVote('jannovak', OPT_A, true); + await updateFeatureVote('jannovak', OPT_A, false); + const votes = await getUserVotes('jannovak'); + expect(votes).not.toContain(OPT_A); +}); + +test('odebrání posledního hlasu odstraní login ze storage', async () => { + await updateFeatureVote('jannovak', OPT_A, true); + const data = await updateFeatureVote('jannovak', OPT_A, false); + expect('jannovak' in data).toBe(false); +}); + +test('getVotingStats vrátí prázdný objekt, pokud nikdo nehlasoval', async () => { + const stats = await getVotingStats(); + expect(stats).toEqual({}); +}); + +test('getVotingStats správně agreguje hlasy více uživatelů', async () => { + await updateFeatureVote('jannovak', OPT_A, true); + await updateFeatureVote('jannovak', OPT_B, true); + await updateFeatureVote('petrfree', OPT_A, true); + const stats = await getVotingStats(); + expect(stats[OPT_A]).toBe(2); + expect(stats[OPT_B]).toBe(1); +}); + +test('getUserVotes vrátí prázdné pole pro uživatele bez hlasů', async () => { + const votes = await getUserVotes('neexistujici'); + expect(votes).toEqual([]); +}); diff --git a/server/src/tests/votingRoutes.test.ts b/server/src/tests/votingRoutes.test.ts new file mode 100644 index 0000000..46cad73 --- /dev/null +++ b/server/src/tests/votingRoutes.test.ts @@ -0,0 +1,76 @@ +import express from 'express'; +import request from 'supertest'; +import bodyParser from 'body-parser'; +import { generateToken } from '../auth'; +import { resetMemoryStorage } from '../storage/memory'; +import { FeatureRequest } from '../../../types/gen/types.gen'; +import votingRouter from '../routes/votingRoutes'; + +const VALID_OPTION = FeatureRequest.STATISTICS; + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use('/api/voting', votingRouter); + app.use((err: any, _req: any, res: any, _next: any) => { + res.status(400).json({ error: err.message }); + }); + return app; +} + +const TOKEN = `Bearer ${generateToken('testuser')}`; + +beforeEach(() => { + resetMemoryStorage(); +}); + +test('GET /getVotes vrátí 200 s prázdným polem pro nového uživatele', async () => { + const res = await request(buildApp()) + .get('/api/voting/getVotes') + .set('Authorization', TOKEN); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); +}); + +test('GET /getVotes vrátí 401 bez tokenu', async () => { + const res = await request(buildApp()).get('/api/voting/getVotes'); + expect(res.status).toBeGreaterThanOrEqual(400); +}); + +test('POST /updateVote přidá hlas a vrátí 200', async () => { + const res = await request(buildApp()) + .post('/api/voting/updateVote') + .set('Authorization', TOKEN) + .send({ option: VALID_OPTION, active: true }); + expect(res.status).toBe(200); +}); + +test('POST /updateVote vrátí 400 pro chybějící parametry', async () => { + const res = await request(buildApp()) + .post('/api/voting/updateVote') + .set('Authorization', TOKEN) + .send({}); + expect(res.status).toBe(400); +}); + +test('POST /updateVote vrátí 400 při duplicitním hlasu', async () => { + const app = buildApp(); + await request(app) + .post('/api/voting/updateVote') + .set('Authorization', TOKEN) + .send({ option: VALID_OPTION, active: true }); + const res = await request(app) + .post('/api/voting/updateVote') + .set('Authorization', TOKEN) + .send({ option: VALID_OPTION, active: true }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('hlasovali'); +}); + +test('GET /stats vrátí 200 s objektem', async () => { + const res = await request(buildApp()) + .get('/api/voting/stats') + .set('Authorization', TOKEN); + expect(res.status).toBe(200); + expect(typeof res.body).toBe('object'); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 49e80bb..1b953d5 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,6 +3,9 @@ "src/**/*", "../types/**/*" ], + "exclude": [ + "src/tests" + ], "compilerOptions": { "target": "ES2022", "module": "Node16", diff --git a/server/yarn.lock b/server/yarn.lock index d4e1fad..719efc3 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1448,6 +1448,18 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@noble/hashes@^1.1.5": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@paralleldrive/cuid2@^2.2.2": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz#3d62ea9e7be867d3fa94b9897fab5b0ae187d784" + integrity sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== + dependencies: + "@noble/hashes" "^1.1.5" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -1594,6 +1606,11 @@ dependencies: "@types/node" "*" +"@types/cookiejar@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" + integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== + "@types/cors@^2.8.12": version "2.8.19" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" @@ -1660,6 +1677,11 @@ "@types/ms" "*" "@types/node" "*" +"@types/methods@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" + integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ== + "@types/ms@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" @@ -1727,6 +1749,24 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/superagent@^8.1.0": + version "8.1.9" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-8.1.9.tgz#28bfe4658e469838ed0bf66d898354bcab21f49f" + integrity sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ== + dependencies: + "@types/cookiejar" "^2.1.5" + "@types/methods" "^1.1.4" + "@types/node" "*" + form-data "^4.0.0" + +"@types/supertest@^6.0.0": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-6.0.3.tgz#d736f0e994b195b63e1c93e80271a2faf927388c" + integrity sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w== + dependencies: + "@types/methods" "^1.1.4" + "@types/superagent" "^8.1.0" + "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -1940,6 +1980,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + asn1.js@^5.3.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -2294,6 +2339,11 @@ combined-stream@^1.0.6, combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +component-emitter@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2314,7 +2364,7 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie-signature@^1.2.1: +cookie-signature@^1.2.1, cookie-signature@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== @@ -2324,6 +2374,11 @@ cookie@^0.7.1, cookie@~0.7.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + core-js-compat@^3.43.0: version "3.47.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.47.0.tgz#698224bbdbb6f2e3f39decdda4147b161e3772a3" @@ -2369,7 +2424,7 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== -debug@4, debug@^4, debug@^4.1.0, debug@^4.3.1, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: +debug@4, debug@^4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2408,6 +2463,14 @@ detect-newline@^3.1.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -2681,6 +2744,11 @@ fast-json-stable-stringify@^2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -2738,6 +2806,17 @@ form-data@^2.5.0: mime-types "^2.1.12" safe-buffer "^5.2.1" +form-data@^4.0.0, form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" @@ -2749,6 +2828,15 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +formidable@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.4.tgz#ac9a593b951e829b3298f21aa9a2243932f32ed9" + integrity sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== + dependencies: + "@paralleldrive/cuid2" "^2.2.2" + dezalgo "^1.0.4" + once "^1.4.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -3624,6 +3712,11 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== +methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" @@ -3656,6 +3749,11 @@ mime-types@^3.0.0, mime-types@^3.0.2: dependencies: mime-db "^1.54.0" +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -4337,6 +4435,30 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +superagent@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-10.3.0.tgz#ff1e39e7976b63f8084291d65f5bfbbbbd156989" + integrity sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ== + dependencies: + component-emitter "^1.3.1" + cookiejar "^2.1.4" + debug "^4.3.7" + fast-safe-stringify "^2.1.1" + form-data "^4.0.5" + formidable "^3.5.4" + methods "^1.1.2" + mime "2.6.0" + qs "^6.14.1" + +supertest@^7.0.0: + version "7.2.2" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.2.2.tgz#dac3ee25a2aa59942a7f641e50c838a7c8819204" + integrity sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA== + dependencies: + cookie-signature "^1.2.2" + methods "^1.1.2" + superagent "^10.3.0" + supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" From d3224a36d51a913d545bba297c11b6885b204225 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 18:33:28 +0200 Subject: [PATCH 14/37] 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 From 467e3c155a55de1c7b8cd409d3da1cdb08858938 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 18:45:26 +0200 Subject: [PATCH 15/37] =?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" From bfe819020d1f62ba8b2b6de2afda2c0ba41deb46 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:07:52 +0200 Subject: [PATCH 16/37] =?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" From 091294f7f39a4c17743ad3a6a636e835266ddcd2 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:31:20 +0200 Subject: [PATCH 17/37] 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}}" From 99260a325024f6cbe3f1bc1ccbe30cbe9e4da7fd Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:34:25 +0200 Subject: [PATCH 18/37] =?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" \ From 2067c21a292b16cd8928d3e9cc417bc4309d41f6 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:39:51 +0200 Subject: [PATCH 19/37] =?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: From e83cf145948dea2ac8dfcf954fbc634a9a0c891f Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:43:14 +0200 Subject: [PATCH 20/37] =?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 From d91c48c5991054e9c34169373414208c5179c58a Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 29 Apr 2026 19:46:24 +0200 Subject: [PATCH 21/37] =?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 From 85cda34881c6c17418096326e566ef3899f32171 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 19:56:17 +0200 Subject: [PATCH 22/37] =?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 From ec6df8700b01b4778a5e46e5534cb87240adcbb6 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:06:17 +0200 Subject: [PATCH 23/37] =?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 From f400d1c5f2d73bc6679180c9948b5cbd0babb253 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:16:10 +0200 Subject: [PATCH 24/37] =?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}" From e9c570b3d554492615f4823488928b6aac3d4850 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:29:25 +0200 Subject: [PATCH 25/37] =?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')); From ecbbeb2cecdb0a7541f4554419b69cb05f5f6b56 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:41:57 +0200 Subject: [PATCH 26/37] =?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'); }); From d7c8a4663d8c24550ad5f43c87a0cbe42d579d77 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:48:05 +0200 Subject: [PATCH 27/37] =?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 }); From db1fe473cd554307aa6fd528561f98fb3c459b26 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 20:56:48 +0200 Subject: [PATCH 28/37] =?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 }, }); From 9383cd7d4c5bf71fa82cbe9556038c76256fe171 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 21:12:50 +0200 Subject: [PATCH 29/37] =?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: From ace4130171000d7570653576bb899134fc247220 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 21:27:16 +0200 Subject: [PATCH 30/37] =?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(); From 6b2deff215d0d3898c10897c5c053e9b70a199e3 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 21:40:32 +0200 Subject: [PATCH 31/37] =?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 }); From 70ed59ab9db52da11eda58f46982e35ed8ad5c02 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 22:06:46 +0200 Subject: [PATCH 32/37] =?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); From 3ed781d0cf15b428205719370f79675fd94660c7 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Wed, 29 Apr 2026 22:55:23 +0200 Subject: [PATCH 33/37] =?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/helpers.ts | 13 ++++++++----- e2e/tests/pick-food.spec.ts | 5 ++--- e2e/tests/pizza-day.spec.ts | 3 ++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 5835a83..5f05e21 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -11,11 +11,14 @@ export async function loginViaApi(page: Page, login: string): Promise { 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 +/** Vyčistí stav dne pro zadaný dayIndex (0=pondělí…4=pátek) přes dev API. + * /api/dev/* vyžaduje JWT – nejdřív získáme token přes /api/login. + */ +export async function clearDay(request: APIRequestContext, dayIndex = 4): Promise { + const loginResp = await request.post('/api/login', { data: {} }); + const token = await loginResp.json() as string; await request.post('/api/dev/clear', { - headers: { 'Content-Type': 'application/json', 'remote-user': 'e2e-user' }, - data: { dayIndex: 4 }, + headers: { Authorization: `Bearer ${token}` }, + data: { dayIndex }, }); } diff --git a/e2e/tests/pick-food.spec.ts b/e2e/tests/pick-food.spec.ts index 3860263..64cbd91 100644 --- a/e2e/tests/pick-food.spec.ts +++ b/e2e/tests/pick-food.spec.ts @@ -1,10 +1,9 @@ import { test, expect } from '@playwright/test'; +import { clearDay } 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 clearDay(request); await page.goto('/'); await page.waitForLoadState('networkidle'); // Počkáme, až se zobrazí volba stravování diff --git a/e2e/tests/pizza-day.spec.ts b/e2e/tests/pizza-day.spec.ts index 9325048..e3e4a23 100644 --- a/e2e/tests/pizza-day.spec.ts +++ b/e2e/tests/pizza-day.spec.ts @@ -1,11 +1,12 @@ import { test, expect } from '@playwright/test'; +import { clearDay } from './helpers'; // 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 } }); + await clearDay(request); }); test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => { From a0d4921d87286636f65bf770df5d1c1c7ac101c0 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 30 Apr 2026 00:45:15 +0200 Subject: [PATCH 34/37] =?UTF-8?q?fix:=20unit=20testy=20selh=C3=A1valy=20v?= =?UTF-8?q?=20CI=20kv=C5=AFli=20MOCK=5FDATA=3Dtrue=20z=20workflow=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setupEnv.ts nyní explicitně ruší MOCK_DATA, aby getToday() vracelo skutečné datum i když CI job nastavuje MOCK_DATA=true. seedPizzaDay používá getToday() místo new Date() pro konzistenci s pizza funkcemi. --- server/src/tests/pizza.test.ts | 3 ++- server/src/tests/setupEnv.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/tests/pizza.test.ts b/server/src/tests/pizza.test.ts index 777775d..34f4519 100644 --- a/server/src/tests/pizza.test.ts +++ b/server/src/tests/pizza.test.ts @@ -1,6 +1,7 @@ import { resetMemoryStorage } from '../storage/memory'; import getStorage from '../storage'; import { formatDate } from '../utils'; +import { getToday } from '../service'; import { createPizzaDay, deletePizzaDay, @@ -28,7 +29,6 @@ jest.mock('../chefie', () => ({ downloadSalaty: jest.fn().mockResolvedValue([]), })); -const today = formatDate(new Date()); const CREATOR = 'kreator'; const USER = 'uzivatel'; @@ -38,6 +38,7 @@ const SIZE_L = { varId: 2, size: '35cm', price: 219, boxPrice: 15, pizzaPrice: 2 const SIZE: any = { varId: 10, size: 'střední', price: 150 }; async function seedPizzaDay(state: PizzaDayState = PizzaDayState.CREATED): Promise { + const today = formatDate(getToday()); const storage = getStorage(); const data: ClientData = { todayDayIndex: 0, diff --git a/server/src/tests/setupEnv.ts b/server/src/tests/setupEnv.ts index 08a9775..4b6d0c9 100644 --- a/server/src/tests/setupEnv.ts +++ b/server/src/tests/setupEnv.ts @@ -2,3 +2,4 @@ process.env.NODE_ENV = 'test'; process.env.STORAGE = 'memory'; process.env.JWT_SECRET = 'test-jwt-secret-ktery-ma-alespon-32-znaku'; process.env.LOGOUT_URL = 'http://localhost/logout'; +delete process.env.MOCK_DATA; From 4da3ce3b1056af008ae05791929e1af414cfccb8 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 30 Apr 2026 00:59:17 +0200 Subject: [PATCH 35/37] =?UTF-8?q?fix:=20Dockerfile=20cp=20recursion=20?= =?UTF-8?q?=E2=80=93=20changelogs=20already=20COPYd=20in=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile-Woodpecker | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile-Woodpecker b/Dockerfile-Woodpecker index 3dc9ec2..7b4a91a 100644 --- a/Dockerfile-Woodpecker +++ b/Dockerfile-Woodpecker @@ -21,9 +21,8 @@ COPY ./client/dist ./public # 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 +# Zkopírování konfigurace easter eggů +RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi EXPOSE 3000 From a2d45ad7e71d2e0198323d470e9b66ff9d1d225f Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Thu, 30 Apr 2026 01:24:00 +0200 Subject: [PATCH 36/37] docs: sync CLAUDE.md with current repo state Add e2e/ package, Playwright commands, Gitea CI pipeline, changelog route, memory storage backend, client hooks/utils folders, and correct context filenames. --- CLAUDE.md | 48 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d54c93..f7d200d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,12 @@ Luncher is a lunch management app for teams — daily restaurant menus, food ord types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml) server/ → Express 5 backend (Node.js 22, ts-node) client/ → React 19 frontend (Vite 7, React Bootstrap) +e2e/ → Playwright E2E tests (separate package) ``` -Each directory has its own `package.json` and `tsconfig.json`. Package manager: **Yarn Classic**. +Each of the four directories has its own `package.json`. Package manager: **Yarn Classic**. + +Deployment files at repo root: `Dockerfile`, `Dockerfile-Woodpecker`, `compose.yml`, `compose-traefik.yml`. ## Development Commands @@ -23,6 +26,7 @@ Each directory has its own `package.json` and `tsconfig.json`. Package manager: cd types && yarn install && yarn openapi-ts # Generate API types first cd ../server && yarn install cd ../client && yarn install +cd ../e2e && yarn install ``` ### Running dev environment @@ -44,11 +48,30 @@ 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 +# Server unit tests (Jest) +cd server && yarn test # All tests in server/src/tests/ +cd server && yarn test dates # Run one file by name +cd server && yarn test -t "name" # Run by test name pattern + +# E2E (Playwright) — requires prebuilt server +cd server && yarn build +cd e2e && yarn test # chromium + firefox, baseURL 127.0.0.1:3001 +cd e2e && yarn test:ui # interactive UI mode +cd e2e && yarn report # open last HTML report ``` +Jest setup (`server/src/tests/setupEnv.ts`) forces `STORAGE=memory`, deletes `MOCK_DATA`, and sets a fixed `JWT_SECRET`. Playwright auto-starts the prebuilt server and authenticates via the `remote-user: e2e-user` trusted-header path; locally uses `STORAGE=json` + `MOCK_DATA=true`, CI uses `STORAGE=redis`. + +### CI pipeline +Gitea Actions — `.gitea/workflows/ci.yaml` (no `.github/` equivalent): + +1. `generate-types` — runs `yarn openapi-ts`, uploads artifact +2. `server-test` — Jest +3. `server-build` + `client-build` — parallel tsc/vite builds +4. `e2e` — Playwright in `mcr.microsoft.com/playwright:v1.59.1-jammy` with a Redis service container; only Firefox installed in CI +5. `docker-build` — master branch only, uses `Dockerfile-Woodpecker` +6. `notify` — Discord + ntfy webhooks + ### Formatting Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier ` with defaults. @@ -63,10 +86,11 @@ Prettier is installed in `client/` (devDependency only, no script or config) — ### Server (server/src/) - **Entry:** `index.ts` — Express app + Socket.io setup -- **Routes:** `routes/` — modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev) +- **Routes:** `routes/` — 9 modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev, changelog) - **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`) +- **Helpers:** `mock.ts` (fake menu data for `MOCK_DATA=true`), `pushReminder.ts` (push notification reminders), `utils.ts` (shared utilities) - **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). +- **Storage:** `storage/index.ts` factory selects implementation; backends: `json.ts` (file-based, dev), `redis.ts` (production), `memory.ts` (tests). 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 @@ -74,10 +98,12 @@ Prettier is installed in `client/` (devDependency only, no script or config) — - **Config:** `.env.development` / `.env.production` (see `.env.template` for all options) ### Client (client/src/) -- **Entry:** `index.tsx` → `App.tsx` → `AppRoutes.tsx` +- **Entry:** `index.tsx` → `App.tsx` → `AppRoutes.tsx`; `Login.tsx` is the auth screen; `FallingLeaves.tsx` is a seasonal visual effect - **Pages:** `pages/` (StatsPage) -- **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow) -- **Context providers:** `context/` — AuthContext, SettingsContext, SocketContext, EasterEggContext +- **Components:** `components/` (Header, Footer, Loader, PizzaOrderList, PizzaOrderRow, and modals in `components/modals/`) +- **Context providers:** `context/` — `auth.tsx`, `settings.tsx`, `socket.js`, `eggs.tsx` (note: `socket.js` is the only non-TSX context file) +- **Hooks:** `hooks/` (`usePushReminder.ts`) +- **Utils:** `utils/` (`parsePrice.ts`) - **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components) - **API calls:** use OpenAPI-generated SDK from `types/gen/` - **Routing:** React Router DOM v7 @@ -91,7 +117,7 @@ Prettier is installed in `client/` (devDependency only, no script or config) — ## Environment - **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`) -- Key vars: `JWT_SECRET`, `STORAGE` (json/redis), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT` +- Key vars: `JWT_SECRET`, `STORAGE` (json/redis/memory), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT` - **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague. ## Conventions @@ -99,4 +125,4 @@ Prettier is installed in `client/` (devDependency only, no script or config) — - 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 -- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work \ No newline at end of file +- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work From 5f903797f181ac70ac13c0acc78f839aadddb70c Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Tue, 5 May 2026 21:57:41 +0200 Subject: [PATCH 37/37] =?UTF-8?q?build:=20sjednocen=C3=AD=20Dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 3 ++- CLAUDE.md | 4 ++-- Dockerfile | 41 ++++++++++++++++++++++++++++++++-------- Dockerfile-Woodpecker | 29 ---------------------------- 4 files changed, 37 insertions(+), 40 deletions(-) delete mode 100644 Dockerfile-Woodpecker diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ab228b5..2ab591e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -215,7 +215,8 @@ jobs: - uses: docker/build-push-action@v5 with: context: . - file: Dockerfile-Woodpecker + file: Dockerfile + target: runner-prebuilt platforms: linux/amd64 push: true tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest diff --git a/CLAUDE.md b/CLAUDE.md index f7d200d..c7736cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ e2e/ → Playwright E2E tests (separate package) Each of the four directories has its own `package.json`. Package manager: **Yarn Classic**. -Deployment files at repo root: `Dockerfile`, `Dockerfile-Woodpecker`, `compose.yml`, `compose-traefik.yml`. +Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: výchozí `runner` pro lokální build, `runner-prebuilt` pro CI s předem sestavenými artefakty), `compose.yml`, `compose-traefik.yml`. ## Development Commands @@ -69,7 +69,7 @@ Gitea Actions — `.gitea/workflows/ci.yaml` (no `.github/` equivalent): 2. `server-test` — Jest 3. `server-build` + `client-build` — parallel tsc/vite builds 4. `e2e` — Playwright in `mcr.microsoft.com/playwright:v1.59.1-jammy` with a Redis service container; only Firefox installed in CI -5. `docker-build` — master branch only, uses `Dockerfile-Woodpecker` +5. `docker-build` — master branch only, uses `Dockerfile` with `--target runner-prebuilt` (skládá image z artefaktů `server-build` + `client-build`) 6. `notify` — Discord + ntfy webhooks ### Formatting diff --git a/Dockerfile b/Dockerfile index e940d6c..5f673bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG NODE_VERSION="node:22-alpine" -# Builder +# ─── Builder ────────────────────────────────────────────────────────────────── FROM ${NODE_VERSION} AS builder WORKDIR /build @@ -62,8 +62,9 @@ RUN yarn build WORKDIR /build/client RUN yarn build -# Runner -FROM ${NODE_VERSION} +# ─── Runner base ────────────────────────────────────────────────────────────── +# Společný základ pro oba runner targety – nastaví prostředí a metadata běhu. +FROM ${NODE_VERSION} AS runner-base RUN apk add --no-cache tzdata ENV TZ=Europe/Prague \ @@ -72,6 +73,17 @@ ENV TZ=Europe/Prague \ WORKDIR /app +# Export /data/db.json do složky /data +VOLUME ["/data"] + +EXPOSE 3000 + +CMD [ "node", "./server/src/index.js" ] + +# ─── Runner (default) ───────────────────────────────────────────────────────── +# Použití: docker build . (lokální sestavení – vše se buildí uvnitř image) +FROM runner-base AS runner + # Vykopírování sestaveného serveru COPY --from=builder /build/server/node_modules ./server/node_modules COPY --from=builder /build/server/dist ./ @@ -85,12 +97,25 @@ COPY /server/.env.production ./server # Zkopírování changelogů (seznamu novinek) COPY /server/changelogs ./server/changelogs -# Zkopírování konfigurace easter eggů a changelogů +# Zkopírování konfigurace easter eggů 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"] +# ─── Runner (prebuilt) ──────────────────────────────────────────────────────── +# Použití: docker build --target runner-prebuilt . +# Očekává předem sestavené artefakty v build kontextu (server/dist, +# client/dist, server/node_modules) – využívá Gitea Actions, kde se +# server i klient buildí v separátních jobech a sem se jen kopírují. +FROM runner-base AS runner-prebuilt -EXPOSE 3000 +# Vykopírování sestaveného serveru +COPY ./server/node_modules ./server/node_modules +COPY ./server/dist ./ -CMD [ "node", "./server/src/index.js" ] \ No newline at end of file +# Vykopírování sestaveného klienta +COPY ./client/dist ./public + +# Zkopírování changelogů (seznamu novinek) +COPY ./server/changelogs ./server/changelogs + +# Zkopírování konfigurace easter eggů +RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi diff --git a/Dockerfile-Woodpecker b/Dockerfile-Woodpecker deleted file mode 100644 index 7b4a91a..0000000 --- a/Dockerfile-Woodpecker +++ /dev/null @@ -1,29 +0,0 @@ -ARG NODE_VERSION="node:22-alpine" - -FROM ${NODE_VERSION} - -RUN apk add --no-cache tzdata -ENV TZ=Europe/Prague \ - LC_ALL=cs_CZ.UTF-8 \ - NODE_ENV=production - -WORKDIR /app - -# Vykopírování sestaveného serveru -COPY ./server/node_modules ./server/node_modules -COPY ./server/dist ./ -# TODO tohle není dobře, má to být součástí serveru -# COPY ./server/resources ./resources - -# Vykopírování sestaveného klienta -COPY ./client/dist ./public - -# Zkopírování changelogů (seznamu novinek) -COPY ./server/changelogs ./server/changelogs - -# Zkopírování konfigurace easter eggů -RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi - -EXPOSE 3000 - -CMD [ "node", "./server/src/index.js" ]