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
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:
1 parent
377a350211
commit
065ccaf38a
12 files changed
+440
-41
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user