feat: automatické sledování doručení objednávky přes Bolt Food
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 36s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 43s
CI / Notify (push) Successful in 2s

Zakladatel skupiny může na stránce objednání vložit sdílecí odkaz
Bolt Food. Server pak každou minutu dotazuje veřejné Bolt API
a automaticky aktualizuje čas doručení skupiny (deliveryAt).
Sledování se samo ukončí po doručení, zrušení objednávky nebo
opakovaných chybách. Leader lease vytažena do znovupoužitelného
modulu leaderLease.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:22:07 +02:00
parent 1df21edc1a
commit 491ec25b52
12 changed files with 559 additions and 52 deletions
+211
View File
@@ -0,0 +1,211 @@
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 { 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ší', async () => {
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
expect(d.groups![0].boltTrackingToken).toBeUndefined();
});
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.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.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();
});
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();
});
});
+10 -1
View File
@@ -1,6 +1,6 @@
import { resetMemoryStorage } from '../storage/memory';
import { getStores, addStore } from '../stores';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupBoltTracking } from '../groups';
import { GroupState } from '../../../types/gen/types.gen';
const CREATOR = 'tomas';
@@ -192,4 +192,13 @@ describe('setGroupState', () => {
test('nečlen nemůže měnit stav', async () => {
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
});
test('ordered → locked smaže boltTrackingToken', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
const token = 'a'.repeat(64);
await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY);
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
expect(d.groups![0].boltTrackingToken).toBeUndefined();
});
});