feat: spin the wheel, objednavani scrape menu, historie cen
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Notify (push) Successful in 2s

This commit is contained in:
batmanisko committed 2026-09-23 15:41:15 +02:00
1 parent 682deaf20b
commit e281615cc3
35 files changed
+3979 -15

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import { getConfettiCharacters, getConfettiImage } from '../confetti';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
beforeEach(() => {
resetMemoryStorage();
mockedAxios.get.mockReset();
});
test('postavy odkazují jen na cachující proxy serveru', () => {
const images = getConfettiCharacters().flatMap(c => c.images);
expect(images.length).toBeGreaterThan(0);
expect(images.every(u => /^\/api\/confetti\/image\/[0-9a-f]{16}$/.test(u))).toBe(true);
// ID jsou stabilní (hash URL) a unikátní
expect(new Set(images).size).toBe(images.length);
});
const idAt = (i: number) => getConfettiCharacters().flatMap(c => c.images)[i].split('/').pop()!;
test('obrázek stáhne jen jednou, pak ho vrací ze storage', async () => {
mockedAxios.get.mockResolvedValue({ data: Buffer.from('PNGDATA'), headers: { 'content-type': 'image/png' } });
const first = await getConfettiImage(idAt(0));
const second = await getConfettiImage(idAt(0));
expect(first?.contentType).toBe('image/png');
expect(second?.data.toString()).toBe('PNGDATA');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
});
test('neznámé ID ani ne-obrázek nevrátí', async () => {
expect(await getConfettiImage('ffffffffffffffff')).toBeUndefined();
mockedAxios.get.mockResolvedValue({ data: Buffer.from('<html>'), headers: { 'content-type': 'text/html' } });
expect(await getConfettiImage(idAt(1))).toBeUndefined();
// po selhání se hned znovu nestahuje
expect(await getConfettiImage(idAt(1))).toBeUndefined();
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
});
+315
View File
@@ -0,0 +1,315 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { diffMenus, fetchBoltMenu, fetchWoltMenu, getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, parseBoltProviderId, parseWoltSlug, refreshStoreMenu, renameStoreMenus, resetProviderThrottle } from '../storeMenus';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const BOLT_URL = 'https://food.bolt.eu/cs-cz/427-plzen/p/27744-new-york-burger-chicken/';
const WOLT_URL = 'https://wolt.com/cs/cze/pilsen/restaurant/new-york-burger-chicken';
/** Odpověď Bolt getMenuCategories ve zkrácené podobě (plochá mapa položek). */
const boltResponse = () => ({
data: {
code: 0,
message: 'OK',
data: {
items: {
1: { type: 'menu', id: 1, parent_id: null },
10: { type: 'category', id: 10, parent_id: 1, index: 1, name: { value: 'Burgery 🍔' } },
11: { type: 'category', id: 11, parent_id: 1, index: 2, name: { value: 'Přílohy 🍟' } },
12: { type: 'category', id: 12, parent_id: 1, index: 0, name: { value: 'Polévky' } },
100: { type: 'dish', id: 100, parent_id: 10, index: 1, name: { value: 'Big Cheeseburger ' }, price: { value: 189 }, availability: 'in_stock' },
101: { type: 'dish', id: 101, parent_id: 10, index: 0, name: { value: 'Chicken Burger' }, price: { value: 169 }, availability: 'in_stock' },
102: { type: 'dish', id: 102, parent_id: 10, index: 2, name: { value: 'Vyprodaný burger' }, price: { value: 1 }, availability: 'out_of_stock' },
103: { type: 'dish', id: 103, parent_id: 11, index: 0, name: { value: 'Hranolky' }, price: { value: 59 }, availability: 'in_stock' },
104: { type: 'dish', id: 104, parent_id: 12, index: 0, name: { value: 'Gulášovka' }, price: { value: 79 }, availability: 'in_stock' },
13: { type: 'category', id: 13, parent_id: 1, index: 3, name: { value: 'Nealko' } },
105: { type: 'dish', id: 105, parent_id: 13, index: 0, name: { value: 'Red Bull 0.25' }, price: { value: 69 }, availability: 'in_stock' },
106: { type: 'dish', id: 106, parent_id: 10, index: 3, name: { value: 'Půlená pizza (příplatek)' }, price: { value: 20 }, availability: 'in_stock' },
107: { type: 'dish', id: 107, parent_id: 10, index: 4, name: { value: 'DRŠŤKOVÁ' }, price: { value: 89 }, availability: 'in_stock' },
200: { type: 'option_select', id: 200, parent_id: 100, name: { value: 'Extra sýr' } },
},
},
},
});
/** Odpověď Wolt assortment ve zkrácené podobě (ceny v haléřích). */
const woltResponse = () => ({
data: {
categories: [
{ name: '🥩 BEEF BURGER', item_ids: ['a', 'b'], subcategories: [] },
{ name: '🥤 Nápoje', item_ids: ['c'] },
{ name: 'Menu', item_ids: [], subcategories: [{ name: 'Tortilly', item_ids: ['d', 'e'] }] },
],
items: [
{ id: 'a', name: 'Cheeseburger', price: 18900, disabled_info: null },
{ id: 'b', name: 'Nedostupný burger', price: 100, disabled_info: { reason: 'sold out' } },
{ id: 'c', name: 'Cola', price: 4900, disabled_info: null },
{ id: 'd', name: 'Kuřecí tortilla', price: 15950, disabled_info: null },
{ id: 'e', name: 'Kuřecí polévka', price: 6900, disabled_info: null },
],
},
});
beforeEach(() => {
resetMemoryStorage();
resetProviderThrottle();
mockedAxios.get.mockReset();
mockedAxios.isAxiosError.mockImplementation((e: any) => !!e?.isAxiosError);
});
/** Chyba axiosu s HTTP 429 (omezení počtu dotazů). */
const tooManyRequests = (retryAfter?: string) =>
Object.assign(new Error('Request failed with status code 429'), {
isAxiosError: true,
response: { status: 429, headers: retryAfter ? { 'retry-after': retryAfter } : {} },
});
describe('parsování URL', () => {
test('ID podniku z Bolt URL', () => {
expect(parseBoltProviderId(BOLT_URL)).toBe('27744');
expect(parseBoltProviderId(WOLT_URL)).toBeUndefined();
expect(parseBoltProviderId('nesmysl')).toBeUndefined();
});
test('slug podniku z Wolt URL', () => {
expect(parseWoltSlug(WOLT_URL)).toBe('new-york-burger-chicken');
expect(parseWoltSlug(BOLT_URL)).toBeUndefined();
});
});
describe('fetchBoltMenu', () => {
test('vrátí dostupné položky dle pořadí v nabídce, hlavní jídla označí (ne přílohy, nápoje, příplatky, polévky)', async () => {
mockedAxios.get.mockResolvedValue(boltResponse());
const dishes = await fetchBoltMenu('27744');
expect(dishes).toEqual([
{ name: 'Gulášovka', price: 79, category: 'Polévky', isMain: false },
{ name: 'Chicken Burger', price: 169, category: 'Burgery 🍔', isMain: true },
{ name: 'Big Cheeseburger', price: 189, category: 'Burgery 🍔', isMain: true },
{ name: 'Půlená pizza (příplatek)', price: 20, category: 'Burgery 🍔', isMain: false },
{ name: 'DRŠŤKOVÁ', price: 89, category: 'Burgery 🍔', isMain: false },
{ name: 'Hranolky', price: 59, category: 'Přílohy 🍟', isMain: false },
{ name: 'Red Bull 0.25', price: 69, category: 'Nealko', isMain: false },
]);
expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('getMenuCategories'),
expect.objectContaining({ params: expect.objectContaining({ provider_id: '27744' }) }));
});
test('chybový kód Bolt API vyhodí výjimku', async () => {
mockedAxios.get.mockResolvedValue({ data: { code: 702, message: 'INVALID_REQUEST' } });
await expect(fetchBoltMenu('1')).rejects.toThrow('702');
});
});
describe('fetchWoltMenu', () => {
test('vrátí dostupné položky včetně podkategorií, ceny v Kč, hlavní jídla označí', async () => {
mockedAxios.get.mockResolvedValue(woltResponse());
const dishes = await fetchWoltMenu('new-york-burger-chicken');
expect(dishes).toEqual([
{ name: 'Cheeseburger', price: 189, category: '🥩 BEEF BURGER', isMain: true },
{ name: 'Cola', price: 49, category: '🥤 Nápoje', isMain: false },
{ name: 'Kuřecí tortilla', price: 160, category: 'Menu / Tortilly', isMain: true },
{ name: 'Kuřecí polévka', price: 69, category: 'Menu / Tortilly', isMain: false },
]);
});
});
const DAY = 24 * 60 * 60 * 1000;
const ADMIN_PW = 'testadmin';
describe('getStoreMenu', () => {
beforeEach(async () => {
process.env.ADMIN_PASSWORD = ADMIN_PW;
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL, WOLT_URL] }, { name: 'Bez URL' }]);
});
afterEach(() => {
jest.useRealTimers();
delete process.env.ADMIN_PASSWORD;
});
test('stáhne Bolt jen jednou — uložená nabídka nemá expiraci', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
mockedAxios.get.mockResolvedValue(boltResponse());
const menu = await getStoreMenu('new york');
expect(menu.source).toBe('bolt');
expect(menu.dishes.filter(d => d.isMain)).toHaveLength(2);
jest.setSystemTime(new Date('2026-12-01T10:00:00Z'));
await getStoreMenu('New York');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
});
test('při selhání Boltu použije Wolt', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValueOnce(woltResponse());
const menu = await getStoreMenu('New York');
expect(menu.source).toBe('wolt');
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Cheeseburger', 'Kuřecí tortilla']);
const [status] = await getStoreMenuStatus();
expect(status.providers).toEqual([
expect.objectContaining({ provider: 'bolt', state: 'failed', failures: 1, lastError: 'timeout' }),
expect.objectContaining({ provider: 'wolt', state: 'ok', dishCount: 2 }),
]);
});
test('po selhání zkouší nejdřív za 3 dny a po 2 neúspěších službu zablokuje', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }]);
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
await getStoreMenu('Jen Bolt');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + 2 * DAY);
await getStoreMenu('Jen Bolt'); // příliš brzy — bez pokusu
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + 1 * DAY);
await getStoreMenu('Jen Bolt'); // druhý pokus → zablokováno
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
jest.setSystemTime(Date.now() + 30 * DAY);
const menu = await getStoreMenu('Jen Bolt');
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
expect(menu.source).toBe('none');
const [status] = await getStoreMenuStatus();
expect(status.providers[0]).toEqual(expect.objectContaining({ state: 'blocked', failures: 2, lastError: 'HTTP 500' }));
});
test('HTTP 429 se nepočítá jako selhání a službu dočasně pozastaví', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }, { name: 'Jiný Bolt', urls: ['https://food.bolt.eu/cs-cz/427-plzen/p/2-jiny/'] }]);
mockedAxios.get.mockRejectedValueOnce(tooManyRequests('120'));
await getStoreMenu('Jen Bolt');
// Během pauzy se na Bolt vůbec nedotazujeme — ani pro jiný obchod
await getStoreMenu('Jiný Bolt');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
const status = await getStoreMenuStatus();
expect(status[0].providers[0]).toEqual(expect.objectContaining({ state: 'pending', failures: 0 }));
expect(status[0].providers[0].lastError).toContain('429');
// Po uplynutí Retry-After se hned zkusí znovu (žádný 3denní odklad)
jest.setSystemTime(Date.now() + 121 * 1000);
mockedAxios.get.mockResolvedValue(boltResponse());
const menu = await getStoreMenu('Jen Bolt');
expect(menu.source).toBe('bolt');
});
test('ruční obnovení stáhne i zablokovanou službu a zablokování zruší', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: BOLT_URL, failures: 2, blocked: true, lastError: 'x', lastAttemptAt: new Date().toISOString() } },
});
mockedAxios.get.mockResolvedValue(boltResponse());
const info = await refreshStoreMenu('new york', 'bolt', ADMIN_PW);
expect(info.providers[0]).toEqual(expect.objectContaining({ provider: 'bolt', state: 'ok', dishCount: 2, failures: 0 }));
expect(info.providers[0].lastError).toBeUndefined();
});
test('neúspěšné ruční obnovení ponechá dříve staženou nabídku', async () => {
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockRejectedValueOnce(new Error('HTTP 403'));
await getStoreMenu('New York');
const info = await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
expect(info.providers[0]).toEqual(expect.objectContaining({ state: 'ok', dishCount: 2, failures: 1, lastError: 'HTTP 403' }));
});
test('ruční obnovení vyžaduje heslo a URL služby', async () => {
await expect(refreshStoreMenu('New York', 'bolt', 'spatne')).rejects.toThrow('UNAUTHORIZED');
await expect(refreshStoreMenu('Bez URL', 'wolt', ADMIN_PW)).rejects.toThrow('Obchod nemá URL');
});
test('změna URL služby zahodí uloženou nabídku', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: 'https://food.bolt.eu/cs-cz/427-plzen/p/1-stary/', dishes: [{ name: 'Staré' }], failures: 0, blocked: false } },
});
mockedAxios.get.mockResolvedValue(boltResponse());
const menu = await getStoreMenu('New York');
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Chicken Burger', 'Big Cheeseburger']);
});
test('přejmenování obchodu přenese uloženou nabídku', async () => {
await getStorage().setData('store_menus', { 'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger' }], failures: 0, blocked: false } } });
await renameStoreMenus('new york', 'NY Burger');
expect(await getStorage().getData('store_menus')).toEqual({ 'NY Burger': expect.any(Object) });
});
test('bez stažitelné nabídky použije historii objednávek', async () => {
await getStorage().setData('2026-09-01_extra', {
groups: [{ id: 'g', name: 'Bez URL', creatorLogin: 'a', state: 'delivered', members: { a: { note: 'Kebab box' } } }],
});
const menu = await getStoreMenu('Bez URL');
expect(menu).toEqual({ store: 'Bez URL', source: 'history', dishes: [{ name: 'Kebab box', isMain: true }] });
expect(mockedAxios.get).not.toHaveBeenCalled();
});
test('neznámý obchod vyhodí chybu', async () => {
await expect(getStoreMenu('Neexistuje')).rejects.toThrow('Obchod nebyl nalezen');
});
});
describe('historie cen', () => {
beforeEach(async () => {
process.env.ADMIN_PASSWORD = ADMIN_PW;
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL] }]);
});
afterEach(() => {
delete process.env.ADMIN_PASSWORD;
});
test('diffMenus najde změněné ceny, nové i odebrané položky', () => {
expect(diffMenus(
[{ name: 'Burger', price: 150 }, { name: 'Hranolky', price: 59 }, { name: 'Salát', price: 99 }],
[{ name: 'burger', price: 165 }, { name: 'Hranolky', price: 59 }, { name: 'Wrap', price: 129 }],
)).toEqual([
{ name: 'burger', before: 150, after: 165 },
{ name: 'Wrap', after: 129 },
{ name: 'Salát', before: 99 },
]);
});
test('první stažení uloží výchozí stav, další jen změny (beze změn nic)', async () => {
const changed = boltResponse();
changed.data.data.items[100 as any].price = { value: 199 };
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(changed);
await getStoreMenu('New York');
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // beze změny
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // Big Cheeseburger 189 → 199
const history = await getStoreMenuHistory('new york');
expect(history.store).toBe('New York');
const entries = history.providers[0].entries;
expect(entries).toHaveLength(2);
expect(entries[0].changes).toEqual([{ name: 'Big Cheeseburger', before: 189, after: 199 }]);
expect(entries[1]).toEqual(expect.objectContaining({ initial: true, itemCount: 7, changes: [] }));
});
test('neúspěšné stažení historii nemění', async () => {
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
await getStoreMenu('New York');
expect((await getStoreMenuHistory('New York')).providers).toEqual([]);
});
test('nabídka stažená před zavedením historie se zobrazí jako výchozí verze', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger', price: 150 }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
});
const history = await getStoreMenuHistory('New York');
expect(history.providers).toEqual([
{ provider: 'bolt', entries: [{ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] }] },
]);
});
test('první změna u nabídky stažené před zavedením historie zachová výchozí verzi', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Chicken Burger', price: 150, isMain: true }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
});
mockedAxios.get.mockResolvedValue(boltResponse());
await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
const entries = (await getStoreMenuHistory('New York')).providers[0].entries;
expect(entries).toHaveLength(2);
expect(entries[0].changes).toContainEqual({ name: 'Chicken Burger', before: 150, after: 169 });
expect(entries[1]).toEqual({ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] });
});
});
+25 -1
View File
@@ -1,6 +1,6 @@
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { getStores, addStore, updateStore, removeStore } from '../stores';
import { getStores, addStore, updateStore, removeStore, getStoreDishes } from '../stores';
const ADMIN_PW = 'testadmin';
@@ -213,3 +213,27 @@ describe('removeStore', () => {
expect(names(stores)).toContain('McDonald\'s');
});
});
describe('getStoreDishes', () => {
/** Uloží skupinové objednávky pro daný den ve formátu `YYYY-MM-DD_extra`. */
const saveGroups = (date: string, groups: { name: string, notes: string[] }[]) =>
getStorage().setData(`${date}_extra`, {
groups: groups.map((g, gi) => ({
id: `${date}-${gi}`, name: g.name, creatorLogin: 'a', state: 'delivered',
members: Object.fromEntries(g.notes.map((note, i) => [`user${i}`, { note }])),
})),
});
test('vrátí jídla dle četnosti, bez duplicit, polévek a čísel položek', async () => {
await getStorage().setData('stores', [{ name: 'China Express' }, { name: 'Everest' }]);
await saveGroups('2026-09-01', [{ name: 'China Express', notes: ['28. Vepřové v pekingské omáčce', 'Kuře na medu', 'Polévka Pho'] }]);
await saveGroups('2026-09-02', [{ name: 'china express', notes: ['kuře na medu', ' ', '⬇️ (to samé)', 'Hovězí se zeleninou'] }]);
await saveGroups('2026-09-03', [{ name: 'Neznámý podnik', notes: ['Burger'] }]);
const result = await getStoreDishes();
expect(result).toEqual([
{ store: 'China Express', dishes: ['Kuře na medu', 'Hovězí se zeleninou', 'Vepřové v pekingské omáčce'] },
{ store: 'Everest', dishes: [] },
]);
});
});