1 Commits

Author SHA1 Message Date
batmanisko 2e8db88f07 feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

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

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

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

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

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
2026-04-28 22:35:15 +02:00
66 changed files with 121 additions and 1782 deletions
+1 -7
View File
@@ -25,14 +25,8 @@ steps:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Test server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn test
- name: Build server
depends_on: [Test server]
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
+15 -6
View File
@@ -45,18 +45,17 @@ 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
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
```bash
# Prettier available in client (no config file — uses defaults)
```
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
@@ -68,7 +67,6 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
- **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)
@@ -99,4 +97,15 @@ 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
## 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.
+2 -5
View File
@@ -82,11 +82,8 @@ COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
# 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
# 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"]
+2 -6
View File
@@ -18,12 +18,8 @@ COPY ./server/dist ./
# 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ů 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
+34 -83
View File
@@ -289,7 +289,6 @@ 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}`);
}
@@ -316,10 +315,6 @@ 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
@@ -344,7 +339,6 @@ function App() {
const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await tryAutoSelectDepartureTime();
}
}
}
@@ -393,80 +387,32 @@ function App() {
}
}
const handleCreatePizzaDay = async () => {
if (!window.confirm('Opravdu chcete založit Pizza day?')) return;
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}
const handleDeletePizzaDay = async () => {
if (!window.confirm('Opravdu chcete smazat Pizza day? Budou smazány i všechny dosud zadané objednávky.')) return;
await deletePizzaDay();
}
const handleLockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete uzamknout objednávky? Po uzamčení nebude možné přidávat ani odebírat objednávky.')) return;
await lockPizzaDay();
}
const handleUnlockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete odemknout objednávky? Uživatelé budou moci opět upravovat své objednávky.')) return;
await unlockPizzaDay();
}
const handleFinishOrder = async () => {
if (!window.confirm('Opravdu chcete označit objednávky jako objednané? Objednávky zůstanou zamčeny.')) return;
await finishOrder();
}
const handleReturnToLocked = async () => {
if (!window.confirm('Opravdu chcete vrátit stav zpět do "uzamčeno" (před objednáním)?')) return;
await lockPizzaDay();
}
const handleFinishDelivery = async () => {
if (!window.confirm(`Opravdu chcete označit pizzy jako doručené?${settings?.bankAccount && settings?.holderName ? ' Uživatelům bude vygenerován QR kód pro platbu.' : ''}`)) return;
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}
const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList && !data?.salatList) {
if (!data?.pizzaList) {
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 = `pizza|${index}|${sizeIndex}`;
const value = `${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?.salatList]);
}, [data?.pizzaList]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login) {
if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') {
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
}
const s = value.split('|');
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 } });
}
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
}
}
@@ -488,16 +434,6 @@ 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;
@@ -648,7 +584,7 @@ function App() {
</Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p className="mt-3">Na co dobrého?</p>
<p className="mt-3">Na co dobrého? <small style={{ color: 'var(--luncher-text-muted)' }}>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
@@ -659,7 +595,7 @@ function App() {
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option value="">Vyber čas...</option>
{Object.values(DepartureTime)
.filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time))
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
@@ -786,7 +722,10 @@ function App() {
</span>
:
<div>
<Button onClick={handleCreatePizzaDay}>Založit Pizza day</Button>
<Button onClick={async () => {
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button>
<Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</div>
}
@@ -805,8 +744,12 @@ function App() {
{
data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={handleDeletePizzaDay}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={handleLockPizzaDay}>Uzamknout</Button>
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay();
}}>Uzamknout</Button>
</div>
}
</>
@@ -817,8 +760,12 @@ function App() {
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={handleUnlockPizzaDay}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={handleFinishOrder}>Objednáno</Button>
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder();
}}>Objednáno</Button>
</div>
}
</>
@@ -829,8 +776,12 @@ function App() {
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={handleReturnToLocked}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={handleFinishDelivery}>Doručeno</Button>
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}}>Doručeno</Button>
</div>
}
</>
@@ -847,7 +798,7 @@ function App() {
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu nebo salát...'
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
+14 -42
View File
@@ -11,12 +11,16 @@ 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, getChangelogs } from "../../../types";
import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
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 IS_DEV = process.env.NODE_ENV === 'development';
@@ -34,7 +38,6 @@ export default function Header({ choices, dayIndex }: Props) {
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
@@ -68,19 +71,6 @@ 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);
}
@@ -207,17 +197,7 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</NavDropdown.Item>
{IS_DEV && (
<>
<NavDropdown.Divider />
@@ -257,24 +237,16 @@ export default function Header({ choices, dayIndex }: Props) {
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}>
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
<ul>
{CHANGELOG.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
-1
View File
@@ -2,7 +2,6 @@
/dist
/resources/easterEggs
/src/gen
/coverage
.env.production
.env.development
.easter-eggs.json
-4
View File
@@ -1,4 +0,0 @@
[
"Zimní atmosféra",
"Skrytí podniku U Motlíků"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Přidání restaurace Zastávka u Michala"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Přidání restaurace Pivovarský šenk Šeříková"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost výběru podniku/jídla kliknutím"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Stránka se statistikami nejoblíbenějších voleb"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zobrazení počtu osob u každé volby"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Migrace na generované OpenApi"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Odebrání zimní atmosféry"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost ručního přenačtení menu"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Parsování a zobrazení alergenů"
]
-4
View File
@@ -1,4 +0,0 @@
[
"Oddělení přenačtení menu do vlastního dialogu",
"Podzimní atmosféra"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost převzetí poznámky ostatních uživatelů"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zimní atmosféra"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost označit se jako objednávající u volby \"Budu objednávat\""
]
-3
View File
@@ -1,3 +0,0 @@
[
"Podpora dark mode"
]
-7
View File
@@ -1,7 +0,0 @@
[
"Redesign aplikace pomocí Claude Code",
"Zobrazení uplynulého týdne i o víkendu",
"Podpora Discord, ntfy a Teams notifikací (v Nastavení)",
"Trvalé zobrazení QR kódů do ručního zavření",
"Zobrazení nejvíce požadovaných funkcí (na stránce Statistiky)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zobrazení sekce Pizza day pouze při volbě \"Pizza day\""
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost generování obecných QR kódů pro platby i mimo Pizza day (v uživatelském menu)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Podpora push notifikací pro připomenutí výběru oběda (v nastavení)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Oprava detekce zastaralého menu"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Automatické zobrazení dialogu s dosud nezobrazenými novinkami"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Automatický výběr výchozího času preferovaného odchodu"
]
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
testMatch: ['<rootDir>/src/**/*.test.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
setupFiles: ['<rootDir>/src/tests/setupEnv.ts'],
};
-2
View File
@@ -19,12 +19,10 @@
"@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"
},
+3 -42
View File
@@ -1,7 +1,6 @@
import axios from 'axios';
import { load } from 'cheerio';
import { getPizzaListMock, getSalatListMock } from './mock';
import { Salat } from '../../types/gen/types.gen';
import { getPizzaListMock } from './mock';
// TODO přesunout do types
type PizzaSize = {
@@ -21,8 +20,7 @@ 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`;
const salayUrl = `${baseUrl}/salaty.html`;
const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`;
const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`;
@@ -36,12 +34,9 @@ const boxPrices: { [key: string]: number } = {
"50cm": 25
}
// Cena obalu pro salát
const SALAT_BOX_PRICE = 13;
/**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
*
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
@@ -89,38 +84,4 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
});
}
return result;
}
/**
* Stáhne a scrapne aktuální saláty ze stránek Pizza Chefie.
* Příplatek za obal je pro každý salát pevně 13 Kč.
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
if (mock) {
return new Promise((resolve) => setTimeout(() => resolve(getSalatListMock()), 1000));
}
const html = await axios.get(salayUrl).then(res => res.data);
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
urls.push(buildPizzaUrl(element.attribs.href));
}
}
const result: Salat[] = [];
for (const url of urls) {
const salatHtml = await axios.get(url).then(res => res.data);
const name = $('.produkt > h2', salatHtml).first().text().trim();
const ingredients: string[] = [];
$('.prisady > li', salatHtml).each((i, elm) => {
ingredients.push($(elm).text());
});
const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]);
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
}
return result;
}
-2
View File
@@ -18,7 +18,6 @@ import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -169,7 +168,6 @@ app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
-28
View File
@@ -1429,34 +1429,6 @@ 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 [
{
+6 -76
View File
@@ -2,9 +2,9 @@ import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import getStorage from "./storage";
import { downloadPizzy, downloadSalaty } from "./chefie";
import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, Salat, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import crypto from "crypto";
const storage = getStorage();
@@ -26,7 +26,7 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
/**
* Uloží seznam dostupných pizz pro dnešní den.
*
*
* @param pizzaList seznam dostupných pizz
*/
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
@@ -39,34 +39,6 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
return clientData;
}
/**
* Vrátí seznam dostupných salátů pro dnešní den.
* Stáhne je, pokud je pro dnešní den nemá.
*/
export async function getSalatList(): Promise<Salat[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
if (!clientData.salatList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await saveSalatList(await downloadSalaty(mock));
}
return Promise.resolve(clientData.salatList);
}
/**
* Uloží seznam dostupných salátů pro dnešní den.
*
* @param salatList seznam dostupných salátů
*/
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.salatList = salatList;
await storage.setData(today, clientData);
return clientData;
}
/**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/
@@ -77,8 +49,8 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
throw Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData };
const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
@@ -142,46 +114,6 @@ 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.
@@ -339,9 +271,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
const id = crypto.randomUUID();
let message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', ');
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.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í
+2 -2
View File
@@ -14,7 +14,7 @@ const storage = getStorage();
*
* @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
*/
export function convertBbanToIban(bankAccountNumber: string): string {
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<void> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) {
message = message.replace(/\*/g, '');
message = message.replace('*', '');
}
if (message.length > 60) {
message = message.substring(0, 60);
+6 -21
View File
@@ -40,7 +40,7 @@ const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.h
* @param text vstupní text
* @returns true, pokud text představuje polévku
*/
export const isTextSoupName = (text: string): boolean => {
const isTextSoupName = (text: string): boolean => {
for (const name of SOUP_NAMES) {
if (text.toLowerCase().includes(name)) {
return true;
@@ -49,11 +49,11 @@ export const isTextSoupName = (text: string): boolean => {
return false;
}
export const capitalize = (word: string): string => {
const capitalize = (word: string): string => {
return word.charAt(0).toUpperCase() + word.slice(1);
}
export const sanitizeText = (text: string): string => {
const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim();
}
@@ -64,7 +64,7 @@ export 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ů
*/
export const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => {
const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => {
// Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami
const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/;
const match = regex.exec(name);
@@ -280,7 +280,6 @@ 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;
@@ -289,7 +288,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
font = f;
}
})
// Druhý pokus - varianta "Jídelní lístek" (starší formát)
// Druhý pokus - varianta "Jídelní lístek"
if (!font) {
fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => {
@@ -299,26 +298,12 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}
})
}
// Třetí pokus - nový formát: font.wsw-41 s textem "Jídelní lístek" (vše v jednom bloku)
if (!font) {
fonts = $('font.wsw-41');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
thirdTry = true;
}
})
}
if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
const siblings = thirdTry
? $(font).parent().siblings('p')
: secondTry
? $(font).parent().parent().parent().siblings('p')
: $(font).parent().parent().siblings();
const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
-50
View File
@@ -1,50 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs";
import path from "path";
const router = express.Router();
const CHANGELOGS_DIR = path.resolve(__dirname, "../../changelogs");
// In-memory cache: datum → seznam změn
const cache: Record<string, string[]> = {};
function loadAllChangelogs(): Record<string, string[]> {
let files: string[];
try {
files = fs.readdirSync(CHANGELOGS_DIR).filter(f => f.endsWith(".json"));
} catch {
return {};
}
for (const file of files) {
const date = file.replace(".json", "");
if (!cache[date]) {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8");
cache[date] = JSON.parse(content);
}
}
return cache;
}
router.get("/", (req: Request, res: Response) => {
const all = loadAllChangelogs();
const since = typeof req.query.since === "string" ? req.query.since : undefined;
// Seřazení od nejnovějšího po nejstarší
const sortedDates = Object.keys(all).sort((a, b) => b.localeCompare(a));
const filteredDates = since
? sortedDates.filter(date => date > since)
: sortedDates;
const result: Record<string, string[]> = {};
for (const date of filteredDates) {
result[date] = all[date];
}
res.status(200).json(result);
});
export default router;
+21 -37
View File
@@ -1,6 +1,6 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, 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,43 +24,27 @@ 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 (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({});
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
}
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) => {
+1 -5
View File
@@ -3,24 +3,20 @@ 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', 'redis' nebo 'memory'");
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'");
}
(async () => {
-27
View File
@@ -1,27 +0,0 @@
import { StorageInterface } from "./StorageInterface";
const store = new Map<string, unknown>();
/** 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<boolean> {
return Promise.resolve(store.has(key));
}
getData<Type>(key: string): Promise<Type | undefined> {
return Promise.resolve(store.get(key) as Type | undefined);
}
setData<Type>(key: string, data: Type): Promise<void> {
store.set(key, data);
return Promise.resolve();
}
}
-62
View File
@@ -1,62 +0,0 @@
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();
});
-38
View File
@@ -1,38 +0,0 @@
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<typeof axios>;
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);
});
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div class="produkt">
<h2>Caesar salát</h2>
</div>
<ul class="prisady">
<li>Ledový salát</li>
<li>Kuřecí maso</li>
<li>Parmazán</li>
</ul>
<div class="cena">
<span>129 Kč</span>
</div>
</body>
</html>
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div class="produkt">
<h2>Řecký salát</h2>
</div>
<ul class="prisady">
<li>Rajčata</li>
<li>Okurka</li>
<li>Feta sýr</li>
</ul>
<div class="cena">
<span>119 Kč</span>
</div>
</body>
</html>
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div class="vypisproduktu">
<div>
<h4><a href="salat-caesar.html">Caesar salát</a></h4>
</div>
<div>
<h4><a href="salat-recky.html">Řecký salát</a></h4>
</div>
</div>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div class="menicka">
<ul class="popup-gallery">
<li class="polevka">
<div class="polozka">Polévka dne</div>
<div class="cena">35&nbsp;</div>
</li>
<li>
<div class="polozka">1. Svíčková na smetaně s knedlíkem</div>
<div class="cena">149&nbsp;</div>
</li>
<li>
<div class="polozka">2. Smažený sýr s bramborovým salátem</div>
<div class="cena">135&nbsp;</div>
</li>
</ul>
</div>
<div class="menicka">
<ul class="popup-gallery">
<li class="polevka">
<div class="polozka">Česnečka se smetanou</div>
<div class="cena">35&nbsp;</div>
</li>
<li>
<div class="polozka">1. Vepřový guláš s knedlíkem</div>
<div class="cena">145&nbsp;</div>
</li>
</ul>
</div>
</body>
</html>
-55
View File
@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<ul id="daily-menu-tab-list">
<button id="daily-menu-tab-0"><span class="daily-menu-tab__day">pondělí</span></button>
<button id="daily-menu-tab-1"><span class="daily-menu-tab__day">úterý</span></button>
<button id="daily-menu-tab-2"><span class="daily-menu-tab__day">středa</span></button>
<button id="daily-menu-tab-3"><span class="daily-menu-tab__day">čtvrtek</span></button>
<button id="daily-menu-tab-4"><span class="daily-menu-tab__day">pátek</span></button>
</ul>
<ul id="daily-menu-content-list">
<div class="daily-menu-content__content">
<div class="daily-menu-content__item">
<table class="daily-menu-content__table"><tbody>
<tr><td>250ml</td><td>Polévka dne 1, 9</td><td>35&nbsp;</td></tr>
<tr><td>150g</td><td>Svíčková na smetaně s knedlíkem 1, 3, 7</td><td>149&nbsp;</td></tr>
<tr><td>120g</td><td>Kuřecí řízek s bramborami 1</td><td>139&nbsp;</td></tr>
</tbody></table>
</div>
</div>
<div class="daily-menu-content__content">
<div class="daily-menu-content__item">
<table class="daily-menu-content__table"><tbody>
<tr><td>250ml</td><td>Česnečka 1</td><td>35&nbsp;</td></tr>
<tr><td>150g</td><td>Vepřový guláš s houskovým knedlíkem 1, 3</td><td>145&nbsp;</td></tr>
</tbody></table>
</div>
</div>
<div class="daily-menu-content__content">
<div class="daily-menu-content__item">
<table class="daily-menu-content__table"><tbody>
<tr><td>250ml</td><td>Hovězí vývar s nudlemi 1</td><td>35&nbsp;</td></tr>
<tr><td>150g</td><td>Smažený sýr s bramborovým salátem 1, 3, 7</td><td>135&nbsp;</td></tr>
</tbody></table>
</div>
</div>
<div class="daily-menu-content__content">
<div class="daily-menu-content__item">
<table class="daily-menu-content__table"><tbody>
<tr><td>250ml</td><td>Rajská polévka 1</td><td>35&nbsp;</td></tr>
<tr><td>150g</td><td>Rizoto s kuřecím masem 1</td><td>139&nbsp;</td></tr>
</tbody></table>
</div>
</div>
<div class="daily-menu-content__content">
<div class="daily-menu-content__item">
<table class="daily-menu-content__table"><tbody>
<tr><td>250ml</td><td>Dršťková polévka 1</td><td>35&nbsp;</td></tr>
<tr><td>150g</td><td>Segedínský guláš s knedlíkem 1, 3</td><td>145&nbsp;</td></tr>
</tbody></table>
</div>
</div>
</ul>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div class="outer-container">
<div class="header-section"><!-- font.parent().parent() -->
<p><!-- font.parent() -->
<font class="wsw-41">Obědy 12.5.-16.5.2025</font>
</p>
</div>
<!-- níže jsou sourozenci .header-section = výsledek $(font).parent().parent().siblings() -->
<p>Pondělí</p>
<p>• Polévka dne 1</p>
<p>• Svíčková na smetaně s knedlíkem 1, 3, 7 149&nbsp;</p>
<p>• Smažený sýr s bramborami 1, 3 139&nbsp;</p>
<p>Úterý</p>
<p>• Česnečka 1</p>
<p>• Vepřový guláš s houskovým knedlíkem 1, 3 145&nbsp;</p>
<p>Středa</p>
<p>• Hovězí vývar s nudlemi 1</p>
<p>• Kuřecí řízek s bramborami 1 139&nbsp;</p>
<p>Čtvrtek</p>
<p>• Dršťková polévka 1</p>
<p>• Segedínský guláš s knedlíkem 1, 3 145&nbsp;</p>
<p>Pátek</p>
<p>• Rajská polévka s rýží 1</p>
<p>• Rizoto s kuřecím masem a zeleninou 1 139&nbsp;</p>
</div>
</body>
</html>
-47
View File
@@ -1,47 +0,0 @@
import axios from 'axios';
import { generateQr, getQr } from '../qr';
import { resetMemoryStorage } from '../storage/memory';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
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');
});
-144
View File
@@ -1,144 +0,0 @@
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<void> {
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);
});
-36
View File
@@ -1,36 +0,0 @@
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/);
});
-103
View File
@@ -1,103 +0,0 @@
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<typeof axios>;
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');
});
@@ -1,73 +0,0 @@
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('');
});
-117
View File
@@ -1,117 +0,0 @@
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<typeof axios>;
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');
}
});
});
-4
View File
@@ -1,4 +0,0 @@
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';
-60
View File
@@ -1,60 +0,0 @@
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);
});
-85
View File
@@ -1,85 +0,0 @@
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 });
}
});
-63
View File
@@ -1,63 +0,0 @@
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([]);
});
-76
View File
@@ -1,76 +0,0 @@
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');
});
-3
View File
@@ -3,9 +3,6 @@
"src/**/*",
"../types/**/*"
],
"exclude": [
"src/tests"
],
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
+2 -124
View File
@@ -1448,18 +1448,6 @@
"@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"
@@ -1606,11 +1594,6 @@
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"
@@ -1677,11 +1660,6 @@
"@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"
@@ -1749,24 +1727,6 @@
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"
@@ -1980,11 +1940,6 @@ 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"
@@ -2339,11 +2294,6 @@ 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"
@@ -2364,7 +2314,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.2:
cookie-signature@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793"
integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==
@@ -2374,11 +2324,6 @@ 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"
@@ -2424,7 +2369,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.3.7, 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.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==
@@ -2463,14 +2408,6 @@ 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"
@@ -2744,11 +2681,6 @@ 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"
@@ -2806,17 +2738,6 @@ 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"
@@ -2828,15 +2749,6 @@ 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"
@@ -3712,11 +3624,6 @@ 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"
@@ -3749,11 +3656,6 @@ 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"
@@ -4435,30 +4337,6 @@ 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"
-4
View File
@@ -77,10 +77,6 @@ paths:
/voting/stats:
$ref: "./paths/voting/getVotingStats.yml"
# Changelog (/api/changelogs)
/changelogs:
$ref: "./paths/changelogs/getChangelogs.yml"
# DEV endpointy (/api/dev)
/dev/generate:
$ref: "./paths/dev/generate.yml"
-21
View File
@@ -1,21 +0,0 @@
get:
operationId: getChangelogs
summary: Vrátí seznam změn (changelog). Pokud není předáno datum, vrátí všechny změny. Pokud je předáno datum, vrátí pouze změny po tomto datu.
parameters:
- in: query
name: since
required: false
schema:
type: string
description: Datum (formát YYYY-MM-DD) od kterého se mají vrátit změny (exkluzivně). Pokud není předáno, vrátí se všechny změny.
responses:
"200":
description: Slovník kde klíčem je datum (YYYY-MM-DD) a hodnotou seznam změn k danému datu. Seřazeno od nejnovějšího po nejstarší.
content:
application/json:
schema:
type: object
additionalProperties:
type: array
items:
type: string
+7 -7
View File
@@ -1,21 +1,21 @@
post:
operationId: addPizza
summary: Přidání pizzy nebo salátu do objednávky.
summary: Přidání pizzy do objednávky.
requestBody:
required: true
content:
application/json:
schema:
required:
- pizzaIndex
- pizzaSizeIndex
properties:
pizzaIndex:
description: Index pizzy v nabídce (pro přidání pizzy)
description: Index pizzy v nabídce
type: integer
pizzaSizeIndex:
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)
description: Index velikosti pizzy v nabídce variant
type: integer
responses:
"200":
description: Přidání pizzy nebo salátu do objednávky proběhlo úspěšně.
description: Přidání pizzy do objednávky proběhlo úspěšně.
+5 -34
View File
@@ -53,11 +53,6 @@ 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
@@ -431,7 +426,7 @@ Pizza:
items:
$ref: "#/PizzaSize"
PizzaVariant:
description: Konkrétní varianta (velikost) jedné pizzy nebo salátu.
description: Konkrétní varianta (velikost) jedné pizzy.
type: object
additionalProperties: false
required:
@@ -441,40 +436,16 @@ PizzaVariant:
- price
properties:
varId:
description: Unikátní identifikátor varianty
description: Unikátní identifikátor varianty pizzy
type: integer
name:
description: Název pizzy nebo salátu
description: Název pizzy
type: string
size:
description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
description: Velikost pizzy (např. "30cm")
type: string
price:
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)
description: Cena pizzy v Kč, včetně krabice
type: number
PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele.