import axios from 'axios'; import { resetMemoryStorage } from '../storage/memory'; import getStorage from '../storage'; import { addStore } from '../stores'; import { createGroup, setGroupState, setGroupBoltTracking } from '../groups'; import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking'; import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator'; import { ClientData, GroupState } from '../../../types/gen/types.gen'; import { formatDate } from '../utils'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; const mockEmit = jest.fn(); jest.mock('../websocket', () => ({ getWebsocket: () => ({ emit: mockEmit }), })); const storage = getStorage(); const CREATOR = 'tomas'; const USER = 'petr'; const ADMIN_PW = 'testadmin'; const STORE = 'McDonald\'s'; const TOKEN = '0d521a8be3c4acebb26d8bd5716d91eac67050fb152a899a55fa19bd5ed65f15'; const SHARE_URL = `https://food.bolt.eu/sharedActiveOrder/${TOKEN}`; function boltResponse(order: object | null) { return { data: { code: 0, message: 'OK', data: { orders: order ? [order] : [], baskets: [] }, }, }; } beforeEach(async () => { resetMemoryStorage(); jest.clearAllMocks(); process.env.ADMIN_PASSWORD = ADMIN_PW; await addStore(STORE, ADMIN_PW); }); afterEach(() => { delete process.env.ADMIN_PASSWORD; }); describe('extractBoltToken', () => { test('přijme plnou share URL', () => { expect(extractBoltToken(SHARE_URL)).toBe(TOKEN); }); test('toleruje lomítko, query a hash na konci', () => { expect(extractBoltToken(`${SHARE_URL}/`)).toBe(TOKEN); expect(extractBoltToken(`${SHARE_URL}?utm=x`)).toBe(TOKEN); expect(extractBoltToken(`${SHARE_URL}#sekce`)).toBe(TOKEN); expect(extractBoltToken(` ${SHARE_URL} `)).toBe(TOKEN); }); test('přijme samotný token včetně velkých písmen', () => { expect(extractBoltToken(TOKEN)).toBe(TOKEN); expect(extractBoltToken(TOKEN.toUpperCase())).toBe(TOKEN.toUpperCase()); }); test('odmítne neplatný vstup', () => { expect(extractBoltToken('')).toBeNull(); expect(extractBoltToken('nesmysl')).toBeNull(); expect(extractBoltToken('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull(); expect(extractBoltToken(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull(); expect(extractBoltToken(TOKEN.slice(0, 63))).toBeNull(); }); }); describe('computeDeliveryHHMM', () => { test('přičte sekundy k aktuálnímu času', () => { expect(computeDeliveryHHMM(1800, new Date('2025-01-10T11:00:00'))).toBe('11:30'); }); test('přechod přes půlnoc', () => { expect(computeDeliveryHHMM(1200, new Date('2025-01-10T23:50:00'))).toBe('00:10'); }); }); describe('setGroupBoltTracking', () => { const TODAY = new Date('2025-01-10'); let groupId: string; beforeEach(async () => { const d = await createGroup(CREATOR, STORE, TODAY); groupId = d.groups![0].id; await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); }); test('uloží token ze share URL', async () => { const d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); expect(d.groups![0].boltTrackingToken).toBe(TOKEN); }); test('prázdná hodnota sledování zruší včetně stavu', async () => { await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY); expect(d.groups![0].boltTrackingToken).toBeUndefined(); expect(d.groups![0].boltOrderState).toBeUndefined(); }); test('nový token vynuluje stav předchozí objednávky', async () => { await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); await storage.updateData(`2025-01-10_extra`, (current) => { current!.groups![0].boltOrderState = 'preparing'; return current!; }); // Stejný token stav nemění let d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); expect(d.groups![0].boltOrderState).toBe('preparing'); // Jiný token stav vynuluje const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`; d = await setGroupBoltTracking(CREATOR, groupId, otherUrl, TODAY); expect(d.groups![0].boltOrderState).toBeUndefined(); expect(d.groups![0].boltTrackingToken).toBe('b'.repeat(64)); }); test('odmítne neplatný odkaz', async () => { await expect(setGroupBoltTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz'); }); test('nezakladatel nemůže sledování nastavit', async () => { await expect(setGroupBoltTracking(USER, groupId, SHARE_URL, TODAY)).rejects.toThrow('zakladatel'); }); test('nelze nastavit mimo stav objednáno', async () => { const d = await createGroup(CREATOR, STORE, TODAY); const openGroupId = d.groups![1].id; await expect(setGroupBoltTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno'); }); }); describe('checkBoltTracking', () => { // Scheduler čte vždy dnešní data (getToday), proto se skupiny zakládají bez explicitního data const extraKey = () => `${formatDate(new Date())}_extra`; let groupId: string; beforeEach(async () => { const d = await createGroup(CREATOR, STORE); groupId = d.groups![0].id; await setGroupState(CREATOR, groupId, GroupState.LOCKED); await setGroupState(CREATOR, groupId, GroupState.ORDERED); await setGroupBoltTracking(CREATOR, groupId, SHARE_URL); }); async function getGroup() { const data = await storage.getData(extraKey()); return data!.groups!.find(g => g.id === groupId)!; } test('aktualizuje deliveryAt podle expected_time_to_client_in_seconds', async () => { mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 })); const before = computeDeliveryHHMM(1800); await checkBoltTracking(); const after = computeDeliveryHHMM(1800); const group = await getGroup(); expect([before, after]).toContain(group.deliveryAt); expect(group.boltOrderState).toBe('waiting_preparation'); expect(group.boltTrackingToken).toBe(TOKEN); expect(mockEmit).toHaveBeenCalledWith('message', expect.anything()); expect(mockedAxios.post).toHaveBeenCalledWith( expect.stringContaining('getOrderPolling'), { token: TOKEN }, expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), ); }); test('nezapisuje, pokud se čas nezměnil', async () => { mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); await checkBoltTracking(); expect(mockEmit).toHaveBeenCalledTimes(1); await checkBoltTracking(); expect(mockEmit).toHaveBeenCalledTimes(1); }); test('ukončí sledování po doručení (token smazán, deliveryAt zůstává)', async () => { mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); await checkBoltTracking(); mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 })); await checkBoltTracking(); const group = await getGroup(); expect(group.boltTrackingToken).toBeUndefined(); expect(group.boltOrderState).toBe('delivered'); expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/); }); test('ukončí sledování, když objednávka už neexistuje', async () => { mockedAxios.post.mockResolvedValue(boltResponse(null)); await checkBoltTracking(); const group = await getGroup(); expect(group.boltTrackingToken).toBeUndefined(); expect(group.boltOrderState).toBe('delivered'); }); test('ukládá stav kurýra (reálná odpověď s waiting_delivery)', async () => { mockedAxios.post.mockResolvedValue(boltResponse({ order_id: 312222357, order_state: 'waiting_delivery', expected_time_to_client_in_seconds: 911, provider: { provider_id: 82859, state: 'waiting_pickup' }, courier: { courier_id: 1958424, state: 'arrived_to_provider', lat: 49.7, lng: 13.3 }, })); await checkBoltTracking(); let group = await getGroup(); expect(group.boltOrderState).toBe('waiting_delivery'); expect(group.boltCourierState).toBe('arrived_to_provider'); // Kurýr vyzvedl — změní se jen courier state mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_delivery', expected_time_to_client_in_seconds: 911, courier: { state: 'picked_up' }, })); await checkBoltTracking(); group = await getGroup(); expect(group.boltCourierState).toBe('picked_up'); }); test('aktualizuje boltOrderState při změně stavu beze změny času', async () => { mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 })); await checkBoltTracking(); mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); await checkBoltTracking(); const group = await getGroup(); expect(group.boltOrderState).toBe('preparing'); expect(group.boltTrackingToken).toBe(TOKEN); }); test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); mockedAxios.post.mockResolvedValue({ data: { code: 42, message: 'FAIL' } }); await checkBoltTracking(); const group = await getGroup(); expect(group.boltTrackingToken).toBe(TOKEN); errorSpy.mockRestore(); }); test('po 10 po sobě jdoucích selháních sledování ukončí', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); mockedAxios.post.mockRejectedValue(new Error('network down')); for (let i = 0; i < 9; i++) { await checkBoltTracking(); } expect((await getGroup()).boltTrackingToken).toBe(TOKEN); await checkBoltTracking(); expect((await getGroup()).boltTrackingToken).toBeUndefined(); errorSpy.mockRestore(); }); test('ignoruje skupiny mimo stav objednáno', async () => { await storage.updateData(extraKey(), (current) => { const d = current!; const g = d.groups!.find(x => x.id === groupId)!; g.state = GroupState.LOCKED; return d; }); await checkBoltTracking(); expect(mockedAxios.post).not.toHaveBeenCalled(); }); }); describe('DEV simulace (boltSimulator + checkBoltTracking)', () => { const extraKey = () => `${formatDate(new Date())}_extra`; let groupId: string; beforeEach(async () => { const d = await createGroup(CREATOR, STORE); groupId = d.groups![0].id; await setGroupState(CREATOR, groupId, GroupState.LOCKED); await setGroupState(CREATOR, groupId, GroupState.ORDERED); // Simulátor vygeneruje validní 64-hex token a přiřadíme ho skupině jako reálný dev endpoint const token = startBoltSimulation(groupId); await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`); }); afterEach(() => stopBoltSimulationByGroup(groupId)); async function getGroup() { const data = await storage.getData(extraKey()); return data!.groups!.find(g => g.id === groupId)!; } test('simulovaný token nevolá reálné Bolt API', async () => { await checkBoltTracking(); expect(mockedAxios.post).not.toHaveBeenCalled(); }); test('první poll nastaví stav accepted a ETA', async () => { await checkBoltTracking(); const g = await getGroup(); expect(g.boltOrderState).toBe('accepted'); expect(g.deliveryAt).toBe(computeDeliveryHHMM(1800)); }); test('advance posune sekvenci na preparing', async () => { await checkBoltTracking(); advanceBoltSimulation(groupId); await checkBoltTracking(); expect((await getGroup()).boltOrderState).toBe('preparing'); }); test('ruční nastavení stavu (override) se projeví při pollu', async () => { setBoltSimulationStep(groupId, { order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 }); await checkBoltTracking(); const g = await getGroup(); expect(g.boltOrderState).toBe('in_delivery'); expect(g.boltCourierState).toBe('heading_to_client'); }); test('terminální stav delivered ukončí sledování (smaže token)', async () => { setBoltSimulationStep(groupId, { order_state: 'delivered' }); await checkBoltTracking(); const g = await getGroup(); expect(g.boltOrderState).toBe('delivered'); expect(g.boltTrackingToken).toBeUndefined(); }); });