- integrace Sentry na serveru (@sentry/node) i klientu (@sentry/react): chyby 5xx, pády a console logy se hlásí do self-hosted Sentry - nový veřejný endpoint GET /api/config — klient si z něj za běhu vyzvedne Sentry DSN (SENTRY_DSN / SENTRY_CLIENT_DSN v env) - ErrorBoundary na klientu a sourcemapy pro čitelné stack traces - oprava mapování stavu waiting_acceptance (zobrazoval se jako Příprava) - mapování stavů kurýra matched/accepted, logování přechodů stavů Bolt objednávek pro postupné zmapování nedokumentovaného API - simulátor Bolt ve vývoji nově prochází celou reálnou sekvenci stavů - vypnuté logování connect/disconnect websocket klientů
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
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<typeof axios>;
|
|
|
|
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<ClientData>(`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<ClientData>(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<ClientData>(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<ClientData>(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 waiting_acceptance a ETA', async () => {
|
|
await checkBoltTracking();
|
|
const g = await getGroup();
|
|
expect(g.boltOrderState).toBe('waiting_acceptance');
|
|
expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100));
|
|
});
|
|
|
|
test('advance posune sekvenci na accepted', async () => {
|
|
await checkBoltTracking();
|
|
advanceBoltSimulation(groupId);
|
|
await checkBoltTracking();
|
|
expect((await getGroup()).boltOrderState).toBe('accepted');
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|