feat: sledování objednávek přes Wolt
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s

Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané
skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do
registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout
kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné.

- pole skupiny bolt* přejmenována na tracking* + nové trackingProvider
- endpoint /groups/setBoltTracking → /groups/setTracking
- Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí
  sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order
- DEV simulace zůstává jen pro Bolt Food
This commit is contained in:
2026-08-24 11:27:56 +02:00
parent eeb1630391
commit 5c048fe1f1
25 changed files with 1326 additions and 869 deletions
+1 -1
View File
@@ -53,7 +53,7 @@
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD=
# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s).
# Interval (ms) scheduleru sledování objednávek (Bolt Food, Wolt). Výchozí 60000 (60 s).
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
# BOLT_POLL_INTERVAL_MS=3000
+4
View File
@@ -0,0 +1,4 @@
[
"Sledování objednávky nově i přes Wolt — stačí u objednané skupiny vložit odkaz z track.wolt.com",
"Upozornění na doručení objednávky nyní chodí i u objednávek sledovaných přes Wolt"
]
+3 -3
View File
@@ -4,7 +4,7 @@ import crypto from 'crypto';
* Vývojový simulátor sledování objednávek Bolt Food.
*
* Drží in-memory registr „simulovaných" objednávek klíčovaný tokenem. Funkce
* pollBoltOrder v boltTracking.ts se na začátku podívá, zda je token simulovaný,
* pollBoltOrder v trackingProviders.ts se na začátku podívá, zda je token simulovaný,
* a pokud ano, vrátí vyfabrikovaný stav místo dotazu na reálné Bolt API.
*
* Registr se plní výhradně přes dev endpointy (gated requireDevMode), takže
@@ -13,7 +13,7 @@ import crypto from 'crypto';
* Postup stavů je řízen ručně (krokováním), bez časové osy — viz advance/setState.
*/
/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte BoltOrderProgress. */
/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte OrderProgress. */
export interface SimStep {
order_state: string;
courier_state?: string;
@@ -40,7 +40,7 @@ interface Simulation {
/**
* Výchozí scénář „happy path". Stavy a stavy kurýra odpovídají mapování ve
* client/src/components/BoltOrderProgress.tsx
* client/src/components/OrderProgress.tsx
* (Přijato → Příprava → Vyzvedávání → Na cestě → Doručeno).
*/
export const DEFAULT_SCENARIO: SimStep[] = [
-217
View File
@@ -1,217 +0,0 @@
import axios from 'axios';
import crypto from 'crypto';
import getStorage from './storage';
import { createLeaderLease } from './leaderLease';
import { getToday } from './service';
import { formatDate } from './utils';
import { getWebsocket } from './websocket';
import { ClientData, GroupState } from '../../types/gen/types.gen';
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
import { notifyBoltDelivered } from './notifikace';
const storage = getStorage();
const lease = createLeaderLease('luncher:bolt:leader');
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i;
const DELIVERED_STATE_REGEX = /delivered|finished/i;
const MAX_CONSECUTIVE_FAILURES = 10;
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
const DEVICE_ID = crypto.randomUUID();
let boltInterval: ReturnType<typeof setInterval> | undefined;
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na Bolt API. */
const consecutiveFailures = new Map<string, number>();
/**
* Vytáhne sledovací token ze sdílecí URL Bolt Food
* (https://food.bolt.eu/sharedActiveOrder/<token>) nebo přijme samotný token.
* Vrátí null, pokud vstup neobsahuje platný token (64 hex znaků).
*/
export function extractBoltToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
let pathname: string;
try {
pathname = new URL(trimmed).pathname;
} catch {
return null;
}
const segments = pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
}
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
const eta = new Date(now.getTime() + seconds * 1000);
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
}
interface BoltOrder {
order_id: number;
order_state: string;
expected_time_to_client_in_seconds?: number;
courier?: { state?: string } | null;
}
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
export async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
// DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API.
// V produkci je registr vždy prázdný, takže se sem nikdy nedostane.
if (isBoltSimulated(token)) {
return getSimulatedBoltOrder(token);
}
const res = await axios.post(BOLT_POLLING_URL, { token }, {
params: {
version: 'FW.1.113',
language: 'cs-CZ',
country: 'cz',
device_name: 'web',
device_os_version: 'web',
deviceType: 'web',
session_id: DEVICE_ID,
distinct_id: `$device:${DEVICE_ID}`,
deviceId: DEVICE_ID,
},
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
if (res.data?.code !== 0) {
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
}
return res.data?.data?.orders?.[0] ?? null;
}
/**
* Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím tokenem
* zjistí očekávaný čas doručení z Bolt API a aktualizuje deliveryAt.
* Sledování se automaticky ukončí (token se smaže), když objednávka skončí
* nebo dotazy opakovaně selhávají.
*/
export async function checkBoltTracking(): Promise<void> {
const isLeader = await lease.tryAcquireOrRenew();
if (!isLeader) return;
const key = `${formatDate(getToday())}_extra`;
const data = await storage.getData<ClientData>(key);
const candidates = (data?.groups ?? []).filter(g => g.boltTrackingToken && g.state === GroupState.ORDERED);
// Úklid čítačů selhání pro skupiny, které už nesledujeme
for (const groupId of consecutiveFailures.keys()) {
if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId);
}
if (candidates.length === 0) return;
let updated: ClientData | undefined;
for (const group of candidates) {
let deliveryAt: string | undefined;
let orderState: string | undefined;
let courierState: string | undefined;
let clearToken = false;
try {
const order = await pollBoltOrder(group.boltTrackingToken!);
consecutiveFailures.delete(group.id);
if (!order) {
// Objednávka z API zmizela — považujeme ji za doručenou
orderState = 'delivered';
clearToken = true;
} else {
orderState = order.order_state || undefined;
courierState = order.courier?.state || undefined;
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
clearToken = true;
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
}
}
} catch (e) {
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
consecutiveFailures.set(group.id, failures);
console.error(`Bolt tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e);
if (failures < MAX_CONSECUTIVE_FAILURES) continue;
consecutiveFailures.delete(group.id);
clearToken = true;
}
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
const courierChanged = courierState !== group.boltCourierState && !clearToken;
if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue;
// Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím
// stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy
// objednávka zmizí z API poté, co už byla označena za doručenou.
const deliveredNow = stateChanged
&& DELIVERED_STATE_REGEX.test(orderState ?? '')
&& !DELIVERED_STATE_REGEX.test(group.boltOrderState ?? '');
// Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle
// průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx).
if (stateChanged || courierChanged) {
console.log(
`Bolt tracking: skupina "${group.name}" stav ${group.boltOrderState ?? '(žádný)'}${orderState ?? '(žádný)'}` +
`, kurýr ${group.boltCourierState ?? '(žádný)'}${courierState ?? '(žádný)'}`
);
}
updated = await storage.updateData<ClientData>(key, current => {
const d = current ?? data!;
const g = d.groups?.find(x => x.id === group.id);
if (g?.boltTrackingToken) {
if (timeChanged) g.deliveryAt = deliveryAt;
if (stateChanged) g.boltOrderState = orderState;
if (courierChanged) g.boltCourierState = courierState;
if (clearToken) g.boltTrackingToken = undefined;
}
return d;
});
if (clearToken) {
console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`);
}
// Až po uložení stavu, aby pád neposlal notifikaci podruhé.
// Selhání push nesmí shodit celý tik sledování.
if (deliveredNow) {
try {
await notifyBoltDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin);
} catch (e) {
console.error(`Bolt tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e);
}
}
}
if (updated) {
getWebsocket()?.emit('message', updated);
}
}
/**
* Spustí scheduler pro sledování Bolt objednávek. Interval je 60 s, lze ho ale
* zkrátit přes env BOLT_POLL_INTERVAL_MS (užitečné při vývoji se simulací).
*/
export function startBoltTrackingScheduler(): void {
const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS);
const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000;
boltInterval = setInterval(checkBoltTracking, intervalMs);
console.log(`Bolt tracking: scheduler spuštěn (interval ${intervalMs} ms)`);
}
/** Stopne scheduler sledování. Volá se při graceful shutdown. */
export function stopBoltTrackingScheduler(): void {
if (boltInterval) {
clearInterval(boltInterval);
boltInterval = undefined;
}
}
/** Uvolní leader lease při graceful shutdown. */
export async function releaseBoltTrackingLease(): Promise<void> {
await lease.release();
}
+19 -16
View File
@@ -3,7 +3,7 @@ import getStorage from "./storage";
import { getClientData, getToday, initIfNeeded } from "./service";
import { getStores } from "./stores";
import { removePendingQrsByGroupId } from "./pizza";
import { extractBoltToken } from "./boltTracking";
import { extractTracking } from "./trackingProviders";
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
import { formatDate } from "./utils";
@@ -151,9 +151,10 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
group.orderedAt = undefined;
group.deliveryAt = undefined;
group.qrGenerated = undefined;
group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
group.boltCourierState = undefined;
group.trackingProvider = undefined;
group.trackingCode = undefined;
group.trackingOrderState = undefined;
group.trackingCourierState = undefined;
for (const ml of memberLogins) {
group.members[ml] = { ...group.members[ml], paid: undefined };
}
@@ -204,24 +205,26 @@ export async function updateGroupTimes(login: string, groupId: string, orderedAt
return saveExtraData(data, date);
}
export async function setGroupBoltTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise<ClientData> {
export async function setGroupTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel');
if (group.creatorLogin !== login) throw new Error('Sledování objednávky může nastavit pouze zakladatel');
if (!shareUrl) {
group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
group.boltCourierState = undefined;
group.trackingProvider = undefined;
group.trackingCode = undefined;
group.trackingOrderState = undefined;
group.trackingCourierState = undefined;
} else {
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
const token = extractBoltToken(shareUrl);
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
if (token !== group.boltTrackingToken) {
group.boltTrackingToken = token;
if (group.state !== GroupState.ORDERED) throw new Error('Sledování objednávky lze nastavit pouze ve stavu "objednáno"');
const tracking = extractTracking(shareUrl);
if (!tracking) throw new Error('Neplatný odkaz na sledování objednávky');
if (tracking.code !== group.trackingCode || tracking.provider !== group.trackingProvider) {
group.trackingProvider = tracking.provider;
group.trackingCode = tracking.code;
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
group.boltOrderState = undefined;
group.boltCourierState = undefined;
group.trackingOrderState = undefined;
group.trackingCourierState = undefined;
}
}
return saveExtraData(data, date);
+5 -5
View File
@@ -13,7 +13,7 @@ import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToke
import { getPendingQrs } from "./pizza";
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
import { startBoltTrackingScheduler, stopBoltTrackingScheduler, releaseBoltTrackingLease } from "./boltTracking";
import { startOrderTrackingScheduler, stopOrderTrackingScheduler, releaseOrderTrackingLease } from "./orderTracking";
import { storageReady } from "./storage";
import getStorage from "./storage";
import { shutdownRedisStorage } from "./storage/redis";
@@ -89,9 +89,9 @@ async function shutdown(signal: string) {
stopReminderScheduler();
await releaseReminderLease();
// Stop Bolt tracking scheduler and release leader lease
stopBoltTrackingScheduler();
await releaseBoltTrackingLease();
// Stop order tracking scheduler and release leader lease
stopOrderTrackingScheduler();
await releaseOrderTrackingLease();
// Shut down Redis pub/sub clients (Socket.io adapter)
await shutdownWebsocketClients();
@@ -310,6 +310,6 @@ storageReady.then(async () => {
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
startReminderScheduler();
startBoltTrackingScheduler();
startOrderTrackingScheduler();
});
});
+6 -3
View File
@@ -231,9 +231,12 @@ const OBJEDNANI_PATH = '/objednani';
/**
* Odešle push notifikaci o doručení skupinové objednávky členům skupiny,
* kteří si to zapnuli v nastavení. Zakladatel skupiny notifikaci nedostává —
* ten je o doručení informován přímo aplikací Bolt.
* ten je o doručení informován přímo aplikací rozvozové služby.
*
* Klíč nastavení zůstal boltDeliveredPush z doby, kdy sledování uměl jen Bolt Food —
* přejmenování by uživatelům zahodilo už uložené zapnutí.
*/
export async function notifyBoltDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise<void> {
export async function notifyOrderDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise<void> {
const recipients: string[] = [];
for (const login of memberLogins) {
if (login === creatorLogin) continue;
@@ -245,7 +248,7 @@ export async function notifyBoltDelivered(groupName: string, memberLogins: strin
await sendPushToLogins(recipients, {
title: 'Luncher',
body: `Objednávka „${groupName}" byla doručena!`,
tag: `bolt-delivered-${groupName}`,
tag: `order-delivered-${groupName}`,
url: OBJEDNANI_PATH,
});
}
+159
View File
@@ -0,0 +1,159 @@
import getStorage from './storage';
import { createLeaderLease } from './leaderLease';
import { getToday } from './service';
import { formatDate } from './utils';
import { getWebsocket } from './websocket';
import { ClientData, GroupState, OrderGroup, TrackingProvider } from '../../types/gen/types.gen';
import { TRACKERS } from './trackingProviders';
import { notifyOrderDelivered } from './notifikace';
const storage = getStorage();
// Klíč lease zůstal z doby, kdy sledování uměl jen Bolt — přejmenování by při
// rolling deployi na chvíli pustilo dva „leadery" (starý a nový klíč) najednou.
const lease = createLeaderLease('luncher:bolt:leader');
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|canceled|rejected|failed/i;
const DELIVERED_STATE_REGEX = /delivered|finished/i;
const MAX_CONSECUTIVE_FAILURES = 10;
let trackingInterval: ReturnType<typeof setInterval> | undefined;
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na API rozvozové služby. */
const consecutiveFailures = new Map<string, number>();
/** Skupina se sledováním — trackingCode i trackingProvider jsou zaručeně vyplněné. */
type TrackedGroup = OrderGroup & { trackingProvider: TrackingProvider; trackingCode: string };
function isTracked(group: OrderGroup): group is TrackedGroup {
return !!group.trackingCode && !!group.trackingProvider && !!TRACKERS[group.trackingProvider];
}
/**
* Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím kódem
* zjistí očekávaný čas doručení z API rozvozové služby a aktualizuje deliveryAt.
* Sledování se automaticky ukončí (kód se smaže), když objednávka skončí
* nebo dotazy opakovaně selhávají.
*/
export async function checkOrderTracking(): Promise<void> {
const isLeader = await lease.tryAcquireOrRenew();
if (!isLeader) return;
const key = `${formatDate(getToday())}_extra`;
const data = await storage.getData<ClientData>(key);
const candidates = (data?.groups ?? []).filter(g => isTracked(g) && g.state === GroupState.ORDERED) as TrackedGroup[];
// Úklid čítačů selhání pro skupiny, které už nesledujeme
for (const groupId of consecutiveFailures.keys()) {
if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId);
}
if (candidates.length === 0) return;
let updated: ClientData | undefined;
for (const group of candidates) {
const tracker = TRACKERS[group.trackingProvider];
let deliveryAt: string | undefined;
let orderState: string | undefined;
let courierState: string | undefined;
let clearTracking = false;
try {
const order = await tracker.poll(group.trackingCode);
consecutiveFailures.delete(group.id);
if (!order) {
// Objednávka z API zmizela — považujeme ji za doručenou
orderState = 'delivered';
clearTracking = true;
} else {
orderState = order.orderState || undefined;
courierState = order.courierState;
if (TERMINAL_STATE_REGEX.test(order.orderState)) {
clearTracking = true;
} else {
deliveryAt = order.deliveryAt;
}
}
} catch (e) {
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
consecutiveFailures.set(group.id, failures);
console.error(`${tracker.label} tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e);
if (failures < MAX_CONSECUTIVE_FAILURES) continue;
consecutiveFailures.delete(group.id);
clearTracking = true;
}
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
const stateChanged = orderState !== undefined && orderState !== group.trackingOrderState;
const courierChanged = courierState !== group.trackingCourierState && !clearTracking;
if (!clearTracking && !timeChanged && !stateChanged && !courierChanged) continue;
// Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím
// stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy
// objednávka zmizí z API poté, co už byla označena za doručenou.
const deliveredNow = stateChanged
&& DELIVERED_STATE_REGEX.test(orderState ?? '')
&& !DELIVERED_STATE_REGEX.test(group.trackingOrderState ?? '');
// Log každého přechodu stavu — API rozvozových služeb nejsou dokumentovaná, takže
// si takhle průběžně mapujeme jejich stavové automaty (viz mapování v OrderProgress.tsx).
if (stateChanged || courierChanged) {
console.log(
`${tracker.label} tracking: skupina "${group.name}" stav ${group.trackingOrderState ?? '(žádný)'}${orderState ?? '(žádný)'}` +
`, kurýr ${group.trackingCourierState ?? '(žádný)'}${courierState ?? '(žádný)'}`
);
}
updated = await storage.updateData<ClientData>(key, current => {
const d = current ?? data!;
const g = d.groups?.find(x => x.id === group.id);
if (g?.trackingCode) {
if (timeChanged) g.deliveryAt = deliveryAt;
if (stateChanged) g.trackingOrderState = orderState;
if (courierChanged) g.trackingCourierState = courierState;
if (clearTracking) g.trackingCode = undefined;
}
return d;
});
if (clearTracking) {
console.log(`${tracker.label} tracking: sledování skupiny "${group.name}" ukončeno`);
}
// Až po uložení stavu, aby pád neposlal notifikaci podruhé.
// Selhání push nesmí shodit celý tik sledování.
if (deliveredNow) {
try {
await notifyOrderDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin);
} catch (e) {
console.error(`${tracker.label} tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e);
}
}
}
if (updated) {
getWebsocket()?.emit('message', updated);
}
}
/**
* Spustí scheduler pro sledování objednávek. Interval je 60 s, lze ho ale
* zkrátit přes env BOLT_POLL_INTERVAL_MS (užitečné při vývoji se simulací).
*/
export function startOrderTrackingScheduler(): void {
const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS);
const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000;
trackingInterval = setInterval(checkOrderTracking, intervalMs);
console.log(`Sledování objednávek: scheduler spuštěn (interval ${intervalMs} ms)`);
}
/** Stopne scheduler sledování. Volá se při graceful shutdown. */
export function stopOrderTrackingScheduler(): void {
if (trackingInterval) {
clearInterval(trackingInterval);
trackingInterval = undefined;
}
}
/** Uvolní leader lease při graceful shutdown. */
export async function releaseOrderTrackingLease(): Promise<void> {
await lease.release();
}
+11 -10
View File
@@ -6,12 +6,12 @@ import { getWebsocket } from "../websocket";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import webpush from 'web-push';
import { ClientData, GroupState } from "../../../types/gen/types.gen";
import { ClientData, GroupState, TrackingProvider } from "../../../types/gen/types.gen";
import {
startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep,
stopBoltSimulationByGroup, getBoltSimulation,
} from "../boltSimulator";
import { checkBoltTracking } from "../boltTracking";
import { checkOrderTracking } from "../orderTracking";
const router = express.Router();
const storage = getStorage();
@@ -201,7 +201,7 @@ router.post("/testPush", async (req, res, next) => {
} catch (e: any) { next(e) }
});
// --- DEV simulace sledování Bolt Food ---
// --- DEV simulace sledování Bolt Food (Wolt simulaci zatím nemá) ---
/** Nastaví/zruší sledovací token skupiny a zajistí stav ORDERED. Vrátí aktualizovaná data. */
async function applyBoltToken(groupId: string, token: string | undefined): Promise<ClientData> {
@@ -210,12 +210,13 @@ async function applyBoltToken(groupId: string, token: string | undefined): Promi
const d = current;
const group = d?.groups?.find(g => g.id === groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
group.boltTrackingToken = token;
group.trackingProvider = token ? TrackingProvider.BOLT : undefined;
group.trackingCode = token;
if (token) {
group.state = GroupState.ORDERED;
} else {
group.boltOrderState = undefined;
group.boltCourierState = undefined;
group.trackingOrderState = undefined;
group.trackingCourierState = undefined;
}
return d!;
});
@@ -228,7 +229,7 @@ router.post("/bolt/simulate", async (req: Request<{}, any, any>, res, next) => {
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
const token = startBoltSimulation(groupId);
await applyBoltToken(groupId, token);
await checkBoltTracking(); // okamžitý první poll → stav "accepted" + websocket
await checkOrderTracking(); // okamžitý první poll → stav "accepted" + websocket
res.status(200).json({ success: true, token, simulation: getBoltSimulation(groupId) });
} catch (e: any) { next(e); }
});
@@ -239,7 +240,7 @@ router.post("/bolt/advance", async (req: Request<{}, any, any>, res, next) => {
const groupId = req.body?.groupId;
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
advanceBoltSimulation(groupId);
await checkBoltTracking();
await checkOrderTracking();
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
} catch (e: any) { next(e); }
});
@@ -250,7 +251,7 @@ router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => {
const { groupId, order_state, courier_state, etaSeconds } = req.body ?? {};
if (!groupId || !order_state) return res.status(400).json({ error: 'Chybí groupId nebo order_state' });
setBoltSimulationStep(groupId, { order_state, courier_state, etaSeconds });
await checkBoltTracking();
await checkOrderTracking();
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
} catch (e: any) { next(e); }
});
@@ -258,7 +259,7 @@ router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => {
/** Spustí jeden tik scheduleru okamžitě (bez čekání na interval). */
router.post("/bolt/poll", async (_req, res, next) => {
try {
await checkBoltTracking();
await checkOrderTracking();
res.status(200).json({ success: true });
} catch (e: any) { next(e); }
});
+6 -6
View File
@@ -2,9 +2,9 @@ import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
import { GroupState } from "../../../types/gen/types.gen";
import { checkBoltTracking } from "../boltTracking";
import { checkOrderTracking } from "../orderTracking";
const router = express.Router();
@@ -161,20 +161,20 @@ router.post("/updateTimes", async (req: Request, res, next) => {
} catch (e: any) { next(e); }
});
router.post("/setBoltTracking", async (req: Request, res, next) => {
router.post("/setTracking", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, shareUrl } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky Bolt' });
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
}
try {
const data = await setGroupBoltTracking(login, id, shareUrl);
const data = await setGroupTracking(login, id, shareUrl);
broadcastExtra(data);
res.status(200).json(data);
// Okamžitý poll, ať uživatel nečeká na další tik scheduleru
if (shareUrl) {
checkBoltTracking().catch(e => console.error('Bolt tracking: okamžitý poll selhal', e));
checkOrderTracking().catch(e => console.error('Sledování objednávek: okamžitý poll selhal', e));
}
} catch (e: any) { next(e); }
});
-416
View File
@@ -1,416 +0,0 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { addStore } from '../stores';
import { createGroup, setGroupState, setGroupBoltTracking, addGroupMember } from '../groups';
import { saveNotificationSettings } from '../notifikace';
import { sendPushToLogins } from '../pushReminder';
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 }),
}));
jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() }));
const mockedSendPush = sendPushToLogins as jest.MockedFunction<typeof sendPushToLogins>;
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();
});
});
describe('notifikace o doručení objednávky', () => {
const MEMBER = 'petr';
const OTHER = 'jana';
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE);
groupId = d.groups![0].id;
await addGroupMember(CREATOR, groupId, MEMBER);
await addGroupMember(CREATOR, groupId, OTHER);
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL);
});
/** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */
async function tickInDelivery() {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 }));
await checkBoltTracking();
}
test('doručení notifikuje členy, kteří to mají zapnuté', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await saveNotificationSettings(OTHER, { boltDeliveredPush: false });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
expect(mockedSendPush).toHaveBeenCalledWith(
[MEMBER],
expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }),
);
});
test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => {
await saveNotificationSettings(CREATOR, { boltDeliveredPush: true });
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything());
});
test('bez zapnutého nastavení se neposílá nic', async () => {
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).not.toHaveBeenCalled();
});
test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' }));
await checkBoltTracking();
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('zmizelá objednávka bez předchozího doručení notifikuje', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('zrušená objednávka notifikaci neposílá', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' }));
await checkBoltTracking();
expect(mockedSendPush).not.toHaveBeenCalled();
});
});
+5 -4
View File
@@ -1,6 +1,6 @@
import { resetMemoryStorage } from '../storage/memory';
import { getStores, addStore } from '../stores';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupBoltTracking, markGroupMemberPaid } from '../groups';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupTracking, markGroupMemberPaid } from '../groups';
import { GroupState } from '../../../types/gen/types.gen';
const CREATOR = 'tomas';
@@ -193,13 +193,14 @@ describe('setGroupState', () => {
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
});
test('ordered → locked smaže boltTrackingToken', async () => {
test('ordered → locked smaže sledování objednávky', 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);
await setGroupTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY);
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
expect(d.groups![0].boltTrackingToken).toBeUndefined();
expect(d.groups![0].trackingCode).toBeUndefined();
expect(d.groups![0].trackingProvider).toBeUndefined();
});
});
+585
View File
@@ -0,0 +1,585 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { addStore } from '../stores';
import { createGroup, setGroupState, setGroupTracking, addGroupMember } from '../groups';
import { saveNotificationSettings } from '../notifikace';
import { sendPushToLogins } from '../pushReminder';
import { checkOrderTracking } from '../orderTracking';
import { extractTracking, computeDeliveryHHMM, formatEtaHHMM } from '../trackingProviders';
import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator';
import { ClientData, GroupState, TrackingProvider } 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 }),
}));
jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() }));
const mockedSendPush = sendPushToLogins as jest.MockedFunction<typeof sendPushToLogins>;
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}`;
const WOLT_CODE = 'ZfKWLEtm1JyB-nI0zk6S7g';
const WOLT_URL = `https://track.wolt.com/${WOLT_CODE}`;
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('extractTracking', () => {
const bolt = (code: string) => ({ provider: TrackingProvider.BOLT, code });
const wolt = (code: string) => ({ provider: TrackingProvider.WOLT, code });
test('přijme plnou share URL Bolt Food', () => {
expect(extractTracking(SHARE_URL)).toEqual(bolt(TOKEN));
});
test('toleruje lomítko, query a hash na konci', () => {
expect(extractTracking(`${SHARE_URL}/`)).toEqual(bolt(TOKEN));
expect(extractTracking(`${SHARE_URL}?utm=x`)).toEqual(bolt(TOKEN));
expect(extractTracking(`${SHARE_URL}#sekce`)).toEqual(bolt(TOKEN));
expect(extractTracking(` ${SHARE_URL} `)).toEqual(bolt(TOKEN));
});
test('přijme samotný token Bolt včetně velkých písmen', () => {
expect(extractTracking(TOKEN)).toEqual(bolt(TOKEN));
expect(extractTracking(TOKEN.toUpperCase())).toEqual(bolt(TOKEN.toUpperCase()));
});
test('přijme odkaz na sledování Wolt i s jazykem v cestě', () => {
expect(extractTracking(WOLT_URL)).toEqual(wolt(WOLT_CODE));
expect(extractTracking(`https://track.wolt.com/en/${WOLT_CODE}`)).toEqual(wolt(WOLT_CODE));
expect(extractTracking(` ${WOLT_URL}?utm=x `)).toEqual(wolt(WOLT_CODE));
});
test('přijme samotný tracking code Wolt', () => {
expect(extractTracking(WOLT_CODE)).toEqual(wolt(WOLT_CODE));
});
test('odmítne neplatný vstup', () => {
expect(extractTracking('')).toBeNull();
expect(extractTracking('nesmysl')).toBeNull();
expect(extractTracking('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull();
expect(extractTracking(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull();
expect(extractTracking(TOKEN.slice(0, 63))).toBeNull();
expect(extractTracking('https://track.wolt.com/cs')).toBeNull();
// Odkaz cizí služby se stejně tvarovaným kódem nesmí projít jako Wolt
expect(extractTracking(`https://track.example.com/${WOLT_CODE}`)).toBeNull();
});
});
describe('formatEtaHHMM', () => {
test('převede ISO čas do zóny objednávky', () => {
expect(formatEtaHHMM('2026-08-24T09:35:33.066000+00:00', 'Europe/Prague')).toBe('11:35');
expect(formatEtaHHMM('2026-08-24T09:35:33.066000+00:00', 'UTC')).toBe('09:35');
});
test('půlnoc se zobrazí jako 00:00', () => {
expect(formatEtaHHMM('2026-08-24T22:00:00Z', 'Europe/Prague')).toBe('00:00');
});
test('neznámá zóna spadne na čas serveru, nesmyslný čas vrátí undefined', () => {
expect(formatEtaHHMM('2026-08-24T09:35:00Z', 'Neznama/Zona')).toMatch(/^\d{2}:\d{2}$/);
expect(formatEtaHHMM('nesmysl')).toBeUndefined();
});
});
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('setGroupTracking (Bolt)', () => {
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 setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
expect(d.groups![0].trackingCode).toBe(TOKEN);
});
test('prázdná hodnota sledování zruší včetně stavu', async () => {
await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
const d = await setGroupTracking(CREATOR, groupId, '', TODAY);
expect(d.groups![0].trackingCode).toBeUndefined();
expect(d.groups![0].trackingOrderState).toBeUndefined();
});
test('nový token vynuluje stav předchozí objednávky', async () => {
await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
await storage.updateData<ClientData>(`2025-01-10_extra`, (current) => {
current!.groups![0].trackingOrderState = 'preparing';
return current!;
});
// Stejný token stav nemění
let d = await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY);
expect(d.groups![0].trackingOrderState).toBe('preparing');
// Jiný token stav vynuluje
const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`;
d = await setGroupTracking(CREATOR, groupId, otherUrl, TODAY);
expect(d.groups![0].trackingOrderState).toBeUndefined();
expect(d.groups![0].trackingCode).toBe('b'.repeat(64));
});
test('odmítne neplatný odkaz', async () => {
await expect(setGroupTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz');
});
test('nezakladatel nemůže sledování nastavit', async () => {
await expect(setGroupTracking(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(setGroupTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno');
});
});
describe('checkOrderTracking (Bolt)', () => {
// 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 setGroupTracking(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 checkOrderTracking();
const after = computeDeliveryHHMM(1800);
const group = await getGroup();
expect([before, after]).toContain(group.deliveryAt);
expect(group.trackingOrderState).toBe('waiting_preparation');
expect(group.trackingCode).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 checkOrderTracking();
expect(mockEmit).toHaveBeenCalledTimes(1);
await checkOrderTracking();
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 checkOrderTracking();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 }));
await checkOrderTracking();
const group = await getGroup();
expect(group.trackingCode).toBeUndefined();
expect(group.trackingOrderState).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 checkOrderTracking();
const group = await getGroup();
expect(group.trackingCode).toBeUndefined();
expect(group.trackingOrderState).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 checkOrderTracking();
let group = await getGroup();
expect(group.trackingOrderState).toBe('waiting_delivery');
expect(group.trackingCourierState).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 checkOrderTracking();
group = await getGroup();
expect(group.trackingCourierState).toBe('picked_up');
});
test('aktualizuje trackingOrderState 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 checkOrderTracking();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
await checkOrderTracking();
const group = await getGroup();
expect(group.trackingOrderState).toBe('preparing');
expect(group.trackingCode).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 checkOrderTracking();
const group = await getGroup();
expect(group.trackingCode).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 checkOrderTracking();
}
expect((await getGroup()).trackingCode).toBe(TOKEN);
await checkOrderTracking();
expect((await getGroup()).trackingCode).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 checkOrderTracking();
expect(mockedAxios.post).not.toHaveBeenCalled();
});
});
describe('checkOrderTracking (Wolt)', () => {
const extraKey = () => `${formatDate(new Date())}_extra`;
let groupId: string;
/** Odpověď Wolt tracking API — tvarem odpovídá reálnému /order-tracking-api/v1/details. */
function woltResponse(details: object) {
return { data: { refresh_in_seconds: 30, timezone: 'Europe/Prague', ...details } };
}
/** 404 z Wolt API (neznámý nebo expirovaný tracking code). */
function woltNotFound() {
(mockedAxios.isAxiosError as unknown as jest.Mock).mockImplementation((e: any) => !!e?.isAxiosError);
return Object.assign(new Error('Request failed with status code 404'), {
isAxiosError: true,
response: { status: 404, data: { detail: 'Not Found' } },
});
}
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE);
groupId = d.groups![0].id;
await addGroupMember(CREATOR, groupId, USER);
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
await setGroupTracking(CREATOR, groupId, WOLT_URL);
});
async function getGroup() {
const data = await storage.getData<ClientData>(extraKey());
return data!.groups!.find(g => g.id === groupId)!;
}
test('odkaz uloží službu i tracking code', async () => {
const group = await getGroup();
expect(group.trackingProvider).toBe(TrackingProvider.WOLT);
expect(group.trackingCode).toBe(WOLT_CODE);
});
test('nastaví deliveryAt z delivery_eta v zóně objednávky', async () => {
mockedAxios.get.mockResolvedValue(woltResponse({
status: 'production',
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
pickup_eta: '2026-08-24T09:12:45.481000+00:00',
couriers: [],
}));
await checkOrderTracking();
const group = await getGroup();
expect(group.deliveryAt).toBe('11:35');
expect(group.trackingOrderState).toBe('production');
expect(group.trackingCourierState).toBeUndefined();
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
// Wolt se dotazuje GETem, Bolt API se nesmí volat vůbec
expect(mockedAxios.get).toHaveBeenCalledWith(
expect.stringContaining(`/track/${WOLT_CODE}`),
expect.objectContaining({ headers: expect.objectContaining({ Origin: 'https://track.wolt.com' }) }),
);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
test('kurýr doručující cizí objednávku je jen assigned', async () => {
// Reálná odpověď: jídlo hotové, kurýr u podniku, ale veze ještě cizí objednávku
mockedAxios.get.mockResolvedValue(woltResponse({
status: 'ready',
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
couriers: [{
id: '31ce2f6e139bec6c',
coordinates: { lat: 49.72954, lon: 13.34632 },
vehicle_type: 'car',
is_delivering: true,
is_delivering_other_order: true,
}],
}));
await checkOrderTracking();
const group = await getGroup();
expect(group.trackingOrderState).toBe('ready');
expect(group.trackingCourierState).toBe('assigned');
});
test('kurýr vezoucí naši objednávku se uloží jako delivering', async () => {
mockedAxios.get.mockResolvedValue(woltResponse({
status: 'ready',
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
couriers: [{ is_delivering: true, is_delivering_other_order: false }],
}));
await checkOrderTracking();
expect((await getGroup()).trackingCourierState).toBe('delivering');
});
test('kurýr bez příznaků doručování je assigned', async () => {
mockedAxios.get.mockResolvedValue(woltResponse({
status: 'ready',
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
couriers: [{ coordinates: { lat: 49.72, lon: 13.34 } }],
}));
await checkOrderTracking();
expect((await getGroup()).trackingCourierState).toBe('assigned');
});
test('doručení ukončí sledování a upozorní členy', async () => {
await saveNotificationSettings(USER, { boltDeliveredPush: true });
mockedAxios.get.mockResolvedValue(woltResponse({
status: 'production',
delivery_eta: '2026-08-24T09:35:33.066000+00:00',
}));
await checkOrderTracking();
mockedAxios.get.mockResolvedValue(woltResponse({ status: 'delivered' }));
await checkOrderTracking();
const group = await getGroup();
expect(group.trackingCode).toBeUndefined();
expect(group.trackingOrderState).toBe('delivered');
expect(group.deliveryAt).toBe('11:35');
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('neznámý tracking code (404) ukončí sledování', async () => {
mockedAxios.get.mockRejectedValue(woltNotFound());
await checkOrderTracking();
const group = await getGroup();
expect(group.trackingCode).toBeUndefined();
});
test('odpověď bez stavu se počítá jako selhání, sledování pokračuje', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockedAxios.get.mockResolvedValue(woltResponse({ delivery_eta: null }));
await checkOrderTracking();
const group = await getGroup();
expect(group.trackingCode).toBe(WOLT_CODE);
expect(group.trackingOrderState).toBeUndefined();
errorSpy.mockRestore();
});
});
describe('DEV simulace (boltSimulator + checkOrderTracking)', () => {
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 setGroupTracking(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 checkOrderTracking();
expect(mockedAxios.post).not.toHaveBeenCalled();
});
test('první poll nastaví stav waiting_acceptance a ETA', async () => {
await checkOrderTracking();
const g = await getGroup();
expect(g.trackingOrderState).toBe('waiting_acceptance');
expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100));
});
test('advance posune sekvenci na accepted', async () => {
await checkOrderTracking();
advanceBoltSimulation(groupId);
await checkOrderTracking();
expect((await getGroup()).trackingOrderState).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 checkOrderTracking();
const g = await getGroup();
expect(g.trackingOrderState).toBe('in_delivery');
expect(g.trackingCourierState).toBe('heading_to_client');
});
test('terminální stav delivered ukončí sledování (smaže token)', async () => {
setBoltSimulationStep(groupId, { order_state: 'delivered' });
await checkOrderTracking();
const g = await getGroup();
expect(g.trackingOrderState).toBe('delivered');
expect(g.trackingCode).toBeUndefined();
});
});
describe('notifikace o doručení objednávky', () => {
const MEMBER = 'petr';
const OTHER = 'jana';
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE);
groupId = d.groups![0].id;
await addGroupMember(CREATOR, groupId, MEMBER);
await addGroupMember(CREATOR, groupId, OTHER);
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
await setGroupTracking(CREATOR, groupId, SHARE_URL);
});
/** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */
async function tickInDelivery() {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 }));
await checkOrderTracking();
}
test('doručení notifikuje členy, kteří to mají zapnuté', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await saveNotificationSettings(OTHER, { boltDeliveredPush: false });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkOrderTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
expect(mockedSendPush).toHaveBeenCalledWith(
[MEMBER],
expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }),
);
});
test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => {
await saveNotificationSettings(CREATOR, { boltDeliveredPush: true });
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkOrderTracking();
expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything());
});
test('bez zapnutého nastavení se neposílá nic', async () => {
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkOrderTracking();
expect(mockedSendPush).not.toHaveBeenCalled();
});
test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkOrderTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' }));
await checkOrderTracking();
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkOrderTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('zmizelá objednávka bez předchozího doručení notifikuje', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkOrderTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('zrušená objednávka notifikaci neposílá', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' }));
await checkOrderTracking();
expect(mockedSendPush).not.toHaveBeenCalled();
});
});
+240
View File
@@ -0,0 +1,240 @@
import axios from 'axios';
import crypto from 'crypto';
import { TrackingProvider } from '../../types/gen/types.gen';
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
/**
* Adaptéry rozvozových služeb pro sledování objednávek.
*
* Každá služba umí ze sdílecího odkazu vytáhnout svůj kód a dotázat se svého
* (nedokumentovaného) veřejného API. Výsledek normalizují do tvaru TrackedOrder,
* se kterým už dál pracuje jen scheduler v orderTracking.ts.
*/
/** Znormalizovaný stav sledované objednávky — společný jmenovatel všech služeb. */
export interface TrackedOrder {
/** Raw stav objednávky ze služby (Bolt order_state, Wolt status). */
orderState: string;
/** Raw stav kurýra, pokud ho služba poskytuje. */
courierState?: string;
/** Očekávaný čas doručení ve formátu HH:MM, pokud ho lze určit. */
deliveryAt?: string;
}
export interface DeliveryTracker {
provider: TrackingProvider;
/** Lidský název služby (do UI hlášek a logů). */
label: string;
/** Vytáhne kód sledování ze vstupu, nebo null, pokud vstup službě nepatří. */
extractCode(input: string): string | null;
/** Dotáže se API služby. Vrátí null, pokud objednávka už neexistuje. */
poll(code: string): Promise<TrackedOrder | null>;
}
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
const BOLT_SHARE_HOST = 'bolt.eu';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const WOLT_TRACKING_URL = 'https://consumer-api.wolt.com/order-tracking-api/v1/details/tracking-code/track/';
const WOLT_SHARE_HOST = 'wolt.com';
/** Wolt tracking code je base64url (v praxi 22 znaků); délku bereme s rezervou. */
const WOLT_CODE_REGEX = /^[A-Za-z0-9_-]{20,32}$/;
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
const DEVICE_ID = crypto.randomUUID();
/** Vrátí poslední neprázdný segment cesty URL (u odkazu bez URL tvaru vrátí null). */
function lastPathSegment(input: string): string | null {
let url: URL;
try {
url = new URL(input);
} catch {
return null;
}
const segments = url.pathname.split('/').filter(Boolean);
return segments[segments.length - 1] ?? null;
}
/** Patří hostitel odkazu dané službě (včetně subdomén)? */
function isHost(input: string, host: string): boolean {
try {
const hostname = new URL(input).hostname.toLowerCase();
return hostname === host || hostname.endsWith(`.${host}`);
} catch {
return false;
}
}
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
const eta = new Date(now.getTime() + seconds * 1000);
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
}
/**
* Zformátuje absolutní ISO čas na HH:MM. Wolt v odpovědi posílá i časovou zónu
* objednávky, takže se čas zobrazí správně i kdyby server běžel v jiné zóně.
*/
export function formatEtaHHMM(iso: string, timeZone?: string): string | undefined {
const eta = new Date(iso);
if (Number.isNaN(eta.getTime())) return undefined;
const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
try {
return new Intl.DateTimeFormat('cs-CZ', { ...options, timeZone }).format(eta);
} catch {
// Neznámá zóna z API — spadneme na lokální čas serveru (TZ=Europe/Prague)
return new Intl.DateTimeFormat('cs-CZ', options).format(eta);
}
}
interface BoltOrder {
order_id?: number;
order_state: string;
expected_time_to_client_in_seconds?: number;
courier?: { state?: string } | null;
}
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
// DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API.
// V produkci je registr vždy prázdný, takže se sem nikdy nedostane.
if (isBoltSimulated(token)) {
return getSimulatedBoltOrder(token);
}
const res = await axios.post(BOLT_POLLING_URL, { token }, {
params: {
version: 'FW.1.113',
language: 'cs-CZ',
country: 'cz',
device_name: 'web',
device_os_version: 'web',
deviceType: 'web',
session_id: DEVICE_ID,
distinct_id: `$device:${DEVICE_ID}`,
deviceId: DEVICE_ID,
},
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
if (res.data?.code !== 0) {
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
}
return res.data?.data?.orders?.[0] ?? null;
}
const boltTracker: DeliveryTracker = {
provider: TrackingProvider.BOLT,
label: 'Bolt Food',
/**
* Přijme sdílecí URL Bolt Food (https://food.bolt.eu/sharedActiveOrder/<token>)
* nebo samotný token (64 hex znaků).
*/
extractCode(input) {
if (BOLT_TOKEN_REGEX.test(input)) return input;
if (!isHost(input, BOLT_SHARE_HOST)) return null;
const last = lastPathSegment(input);
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
},
async poll(code) {
const order = await pollBoltOrder(code);
if (!order) return null;
const seconds = order.expected_time_to_client_in_seconds;
return {
orderState: order.order_state || '',
courierState: order.courier?.state || undefined,
deliveryAt: typeof seconds === 'number' ? computeDeliveryHHMM(seconds) : undefined,
};
},
};
/** Odpověď Wolt tracking API (jen pole, která používáme). */
interface WoltTrackingDetails {
status?: string;
delivery_eta?: string | null;
couriers?: WoltCourier[];
timezone?: string;
}
interface WoltCourier {
/** Kurýr veze nějakou objednávku (nemusí to být ta naše — viz is_delivering_other_order). */
is_delivering?: boolean;
/** Kurýr právě doručuje cizí objednávku, tu naši teprve vyzvedne nebo veze až po ní. */
is_delivering_other_order?: boolean;
}
/**
* Wolt neposílá stav kurýra, jen jeho pozici a dva příznaky. Odvodíme z nich
* dvojí stav: 'delivering' (veze naši objednávku → krok „Na cestě") a 'assigned'
* (kurýr přiřazen, ale naše jídlo ještě nemá — typicky doručuje cizí objednávku).
*/
function woltCourierState(couriers?: WoltCourier[]): string | undefined {
const courier = couriers?.[0];
if (!courier) return undefined;
return courier.is_delivering && !courier.is_delivering_other_order ? 'delivering' : 'assigned';
}
const woltTracker: DeliveryTracker = {
provider: TrackingProvider.WOLT,
label: 'Wolt',
/**
* Přijme odkaz na sledování Wolt (https://track.wolt.com/<code>, případně
* s jazykovým segmentem /en/<code>) nebo samotný tracking code.
*/
extractCode(input) {
if (WOLT_CODE_REGEX.test(input)) return input;
if (!isHost(input, WOLT_SHARE_HOST)) return null;
const last = lastPathSegment(input);
return last && WOLT_CODE_REGEX.test(last) ? last : null;
},
async poll(code) {
let details: WoltTrackingDetails;
try {
const res = await axios.get<WoltTrackingDetails>(`${WOLT_TRACKING_URL}${encodeURIComponent(code)}`, {
headers: {
Accept: 'application/json',
'Accept-Language': 'cs',
// Wolt API odpovídá jen na dotazy z jeho tracking stránky
Referer: 'https://track.wolt.com/',
Origin: 'https://track.wolt.com',
},
timeout: 10_000,
});
details = res.data ?? {};
} catch (e) {
// Neznámý/expirovaný kód → objednávka už neexistuje (stejně jako u Boltu)
if (axios.isAxiosError(e) && e.response?.status === 404) return null;
throw e;
}
if (!details.status) {
throw new Error('Wolt API vrátilo odpověď bez stavu objednávky');
}
return {
orderState: details.status,
courierState: woltCourierState(details.couriers),
deliveryAt: details.delivery_eta ? formatEtaHHMM(details.delivery_eta, details.timezone) : undefined,
};
},
};
export const TRACKERS: Record<TrackingProvider, DeliveryTracker> = {
[TrackingProvider.BOLT]: boltTracker,
[TrackingProvider.WOLT]: woltTracker,
};
/**
* Rozpozná službu podle sdílecího odkazu (nebo samotného kódu) a vytáhne z něj
* kód sledování. Vrátí null, pokud vstup nepatří žádné podporované službě.
*/
export function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
const trimmed = input.trim();
if (!trimmed) return null;
for (const tracker of Object.values(TRACKERS)) {
const code = tracker.extractCode(trimmed);
if (code) return { provider: tracker.provider, code };
}
return null;
}