feat: push upozornění na doručení objednávky Bolt Food
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s

Nové zaškrtávátko v Nastavení → Notifikace pod výběrem času připomínky.
Když skupinová objednávka sledovaná přes Bolt Food přejde do stavu
delivered/finished, dostanou push členové skupiny, kteří si to zapnuli.
Zakladatel skupiny notifikaci nedostává — ten je informován aplikací Bolt.

Push subscription byla dosud svázaná s časem připomínky (subscribe endpoint
bez reminderTime vracel 400), takže by nový přepínač samostatně nefungoval.
Čas připomínky je nově v registru volitelný a scheduler připomínek záznamy
bez času přeskakuje.

Service worker nemá natvrdo tag a akci "Mám vlastní/neobědvám" — obojí se
řídí payloadem, aby je doručovací notifikace nedědila.
This commit is contained in:
batmanisko committed 2026-08-10 12:36:07 +02:00
1 parent 377a350211
commit 065ccaf38a
12 files changed
+440 -41

No files matched your search

+94 -1
View File
@@ -2,7 +2,9 @@ import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { addStore } from '../stores';
import { createGroup, setGroupState, setGroupBoltTracking } from '../groups';
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';
@@ -16,6 +18,9 @@ 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';
@@ -321,3 +326,91 @@ describe('DEV simulace (boltSimulator + checkBoltTracking)', () => {
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();
});
});
+104
View File
@@ -0,0 +1,104 @@
import webpush from 'web-push';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { subscribePush, unsubscribePush, sendPushToLogins } from '../pushReminder';
jest.mock('web-push');
const mockedWebpush = webpush as jest.Mocked<typeof webpush>;
const storage = getStorage();
const REGISTRY_KEY = 'push_reminder_registry';
const SUB_A = { endpoint: 'https://push.example/a', keys: { p256dh: 'a', auth: 'a' } } as any;
const SUB_B = { endpoint: 'https://push.example/b', keys: { p256dh: 'b', auth: 'b' } } as any;
async function getRegistry(): Promise<Record<string, { time?: string; subscription: unknown }>> {
return await storage.getData(REGISTRY_KEY) ?? {};
}
beforeEach(() => {
resetMemoryStorage();
jest.clearAllMocks();
mockedWebpush.sendNotification.mockResolvedValue({} as any);
});
describe('subscribePush', () => {
test('uloží subscription i bez času připomínky', async () => {
await subscribePush('petr', SUB_A);
const entry = (await getRegistry())['petr'];
expect(entry.subscription).toEqual(SUB_A);
expect(entry.time).toBeUndefined();
});
test('uloží čas připomínky, když je zadán', async () => {
await subscribePush('petr', SUB_A, '10:30');
expect((await getRegistry())['petr'].time).toBe('10:30');
});
test('opakované přihlášení přepíše předchozí záznam', async () => {
await subscribePush('petr', SUB_A, '10:30');
await subscribePush('petr', SUB_B);
const entry = (await getRegistry())['petr'];
expect(entry.subscription).toEqual(SUB_B);
expect(entry.time).toBeUndefined();
});
test('unsubscribePush záznam odebere', async () => {
await subscribePush('petr', SUB_A);
await unsubscribePush('petr');
expect(await getRegistry()).toEqual({});
});
});
describe('sendPushToLogins', () => {
const PAYLOAD = { title: 'Luncher', body: 'Objednávka byla doručena!', url: '/objednani' };
test('odešle notifikaci registrovaným uživatelům', async () => {
await subscribePush('petr', SUB_A);
await subscribePush('jana', SUB_B, '11:00');
await sendPushToLogins(['petr', 'jana'], PAYLOAD);
expect(mockedWebpush.sendNotification).toHaveBeenCalledTimes(2);
expect(mockedWebpush.sendNotification).toHaveBeenCalledWith(SUB_A, JSON.stringify(PAYLOAD));
expect(mockedWebpush.sendNotification).toHaveBeenCalledWith(SUB_B, JSON.stringify(PAYLOAD));
});
test('přeskočí uživatele bez registrované subscription', async () => {
await subscribePush('petr', SUB_A);
await sendPushToLogins(['petr', 'nikdo'], PAYLOAD);
expect(mockedWebpush.sendNotification).toHaveBeenCalledTimes(1);
expect(mockedWebpush.sendNotification).toHaveBeenCalledWith(SUB_A, expect.any(String));
});
test('prázdný seznam neodesílá nic', async () => {
await subscribePush('petr', SUB_A);
await sendPushToLogins([], PAYLOAD);
expect(mockedWebpush.sendNotification).not.toHaveBeenCalled();
});
test('expirovanou subscription (410) odebere z registru', async () => {
await subscribePush('petr', SUB_A);
await subscribePush('jana', SUB_B);
mockedWebpush.sendNotification.mockImplementation(async (sub: any) => {
if (sub.endpoint === SUB_A.endpoint) throw Object.assign(new Error('Gone'), { statusCode: 410 });
return {} as any;
});
await sendPushToLogins(['petr', 'jana'], PAYLOAD);
const registry = await getRegistry();
expect(registry['petr']).toBeUndefined();
expect(registry['jana']).toBeDefined();
});
test('ostatní chyby subscription nemažou a nešíří se dál', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await subscribePush('petr', SUB_A);
mockedWebpush.sendNotification.mockRejectedValue(Object.assign(new Error('Boom'), { statusCode: 500 }));
await expect(sendPushToLogins(['petr'], PAYLOAD)).resolves.toBeUndefined();
expect((await getRegistry())['petr']).toBeDefined();
errorSpy.mockRestore();
});
});