diff --git a/client/public/sw.js b/client/public/sw.js index 01900d5..29d4218 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -1,4 +1,4 @@ -// Service Worker pro Web Push notifikace (připomínka výběru oběda) +// Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky) self.addEventListener('push', (event) => { const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' }; @@ -6,11 +6,12 @@ self.addEventListener('push', (event) => { self.registration.showNotification(data.title, { body: data.body, icon: '/favicon.ico', - tag: 'lunch-reminder', - data: { login: data.login, token: data.token }, - actions: [ - { action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, - ], + tag: data.tag ?? 'lunch-reminder', + data: { login: data.login, token: data.token, url: data.url }, + // Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají. + actions: data.token + ? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }] + : [], }) ); }); @@ -32,14 +33,16 @@ self.addEventListener('notificationclick', (event) => { return; } + const url = event.notification.data?.url ?? '/'; event.waitUntil( self.clients.matchAll({ type: 'window' }).then((clientList) => { for (const client of clientList) { if (client.url.includes(self.location.origin) && 'focus' in client) { + if ('navigate' in client) client.navigate(url).catch(() => {}); return client.focus(); } } - return self.clients.openWindow('/'); + return self.clients.openWindow(url); }) ); }); diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 8315e02..f976347 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -21,6 +21,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly(null); const reminderTimeRef = useRef(null); + const boltDeliveredRef = useRef(null); const ntfyTopicRef = useRef(null); const discordWebhookRef = useRef(null); const teamsWebhookRef = useRef(null); @@ -47,22 +48,29 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly { const newReminderTime = reminderTimeRef.current?.value || undefined; const oldReminderTime = notifSettings.reminderTime; + const newBoltDelivered = boltDeliveredRef.current?.checked ?? false; + const oldBoltDelivered = notifSettings.boltDeliveredPush ?? false; // Uložení notifikačních nastavení na server - await updateNotificationSettings({ - body: { - ntfyTopic: ntfyTopicRef.current?.value || undefined, - discordWebhookUrl: discordWebhookRef.current?.value || undefined, - teamsWebhookUrl: teamsWebhookRef.current?.value || undefined, - enabledEvents, - reminderTime: newReminderTime, - } - }).catch(() => {}); + const newSettings: NotificationSettings = { + ntfyTopic: ntfyTopicRef.current?.value || undefined, + discordWebhookUrl: discordWebhookRef.current?.value || undefined, + teamsWebhookUrl: teamsWebhookRef.current?.value || undefined, + enabledEvents, + reminderTime: newReminderTime, + boltDeliveredPush: newBoltDelivered, + }; + await updateNotificationSettings({ body: newSettings }).catch(() => {}); + setNotifSettings(newSettings); - // Správa push subscription pro připomínky - if (newReminderTime && newReminderTime !== oldReminderTime) { + // Správa push subscription — drží ji naživu kterákoli z push funkcí. + // Záměrně bez await: subscribeToPush si vyžádá oprávnění prohlížeče a modal + // by na tu dobu zůstal otevřený. + const wantsPush = !!newReminderTime || newBoltDelivered; + const hadPush = !!oldReminderTime || oldBoltDelivered; + if (wantsPush && (newReminderTime !== oldReminderTime || newBoltDelivered !== oldBoltDelivered)) { subscribeToPush(newReminderTime); - } else if (!newReminderTime && oldReminderTime) { + } else if (!wantsPush && hadPush) { unsubscribeFromPush(); } @@ -128,6 +136,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly + + + + Až bude skupinová objednávka sledovaná přes Bolt Food doručena, přijde vám push notifikace. + Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace Bolt. + + + ntfy téma (topic) { +export async function subscribeToPush(reminderTime?: string): Promise { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.warn('Push notifikace nejsou v tomto prohlížeči podporovány'); return false; @@ -75,7 +76,7 @@ export async function subscribeToPush(reminderTime: string): Promise { return false; } - console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime); + console.log('Push notifikace: úspěšně přihlášeno', reminderTime ? `(připomínka v ${reminderTime})` : '(bez připomínky oběda)'); return true; } catch (error) { console.error('Push notifikace: chyba při registraci', error); @@ -101,7 +102,7 @@ export async function unsubscribeFromPush(): Promise { } await pushApiFetch('/unsubscribe', { method: 'POST' }); - console.log('Push notifikace: úspěšně odhlášeno z připomínek'); + console.log('Push notifikace: úspěšně odhlášeno'); } catch (error) { console.error('Push notifikace: chyba při odhlášení', error); } diff --git a/e2e/tests/notification-settings.spec.ts b/e2e/tests/notification-settings.spec.ts new file mode 100644 index 0000000..aaa62e1 --- /dev/null +++ b/e2e/tests/notification-settings.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { loginViaApi } from './helpers'; + +const BOLT_LABEL = 'Upozornění na doručení objednávky (Bolt Food)'; + +test.beforeEach(async ({ page }) => { + await loginViaApi(page, 'e2e-user'); + await page.reload(); + await page.waitForLoadState('networkidle'); +}); + +async function openSettings(page: import('@playwright/test').Page) { + await page.locator('#basic-nav-dropdown').click(); + await page.locator('text=Nastavení').click(); + await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 }); +} + +test('Přepínač doručení Bolt je pod výběrem času připomínky', async ({ page }) => { + await openSettings(page); + + const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' }); + const boltGroup = page.locator('.modal-body .mb-3').filter({ hasText: BOLT_LABEL }); + await expect(reminderGroup).toBeVisible(); + await expect(boltGroup).toBeVisible(); + + // Přepínač musí v DOM následovat až za skupinou s časem připomínky + const order = await page.evaluate((label) => { + const groups = Array.from(document.querySelectorAll('.modal-body .mb-3')); + return { + reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')), + bolt: groups.findIndex(g => g.textContent?.includes(label)), + }; + }, BOLT_LABEL); + expect(order.reminder).toBeGreaterThanOrEqual(0); + expect(order.bolt).toBe(order.reminder + 1); + + // Výchozí stav je vypnuto + await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked(); +}); + +test('Zapnutí přepínače se uloží na server a přežije znovuotevření', async ({ page }) => { + await openSettings(page); + await page.locator('#boltDeliveredCheckbox').check(); + await page.locator('.modal-footer button', { hasText: 'Uložit' }).click(); + await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 }); + + // Server má nastavení uložené + const token = await page.evaluate(() => localStorage.getItem('token')); + const resp = await page.request.get('/api/notifications/settings', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(await resp.json()).toMatchObject({ boltDeliveredPush: true }); + + // Po znovunačtení stránky je přepínač stále zapnutý + await page.reload(); + await page.waitForLoadState('networkidle'); + await openSettings(page); + await expect(page.locator('#boltDeliveredCheckbox')).toBeChecked(); +}); + +test('Vypnutí přepínače se uloží zpět', async ({ page }) => { + await openSettings(page); + await page.locator('#boltDeliveredCheckbox').check(); + await page.locator('.modal-footer button', { hasText: 'Uložit' }).click(); + await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 }); + + await openSettings(page); + await page.locator('#boltDeliveredCheckbox').uncheck(); + await page.locator('.modal-footer button', { hasText: 'Uložit' }).click(); + await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 }); + + const token = await page.evaluate(() => localStorage.getItem('token')); + const resp = await page.request.get('/api/notifications/settings', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(await resp.json()).toMatchObject({ boltDeliveredPush: false }); +}); diff --git a/server/changelogs/2026-08-10.json b/server/changelogs/2026-08-10.json new file mode 100644 index 0000000..ab95b3e --- /dev/null +++ b/server/changelogs/2026-08-10.json @@ -0,0 +1,3 @@ +[ + "Upozornění na doručení skupinové objednávky sledované přes Bolt Food (zapíná se v Nastavení → Notifikace)" +] diff --git a/server/src/boltTracking.ts b/server/src/boltTracking.ts index 8719a4d..f8b90bc 100644 --- a/server/src/boltTracking.ts +++ b/server/src/boltTracking.ts @@ -7,6 +7,7 @@ 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'); @@ -14,6 +15,7 @@ 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. */ @@ -143,6 +145,13 @@ export async function checkBoltTracking(): Promise { 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) { @@ -166,6 +175,16 @@ export async function checkBoltTracking(): Promise { 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) { diff --git a/server/src/notifikace.ts b/server/src/notifikace.ts index 232c3eb..ad1fb32 100644 --- a/server/src/notifikace.ts +++ b/server/src/notifikace.ts @@ -5,6 +5,7 @@ import { getClientData, getToday } from "./service"; import { getUsersByLocation, getHumanTime } from "./utils"; import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types'; import getStorage from "./storage"; +import { sendPushToLogins } from "./pushReminder"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -223,3 +224,28 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy console.error("Error in callNotifikace: ", error); } }; + +/** Cesta na stránku skupinových objednávek (viz OBJEDNANI_URL v klientovi). */ +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. + */ +export async function notifyBoltDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise { + const recipients: string[] = []; + for (const login of memberLogins) { + if (login === creatorLogin) continue; + const settings = await getNotificationSettings(login); + if (settings.boltDeliveredPush) recipients.push(login); + } + if (recipients.length === 0) return; + + await sendPushToLogins(recipients, { + title: 'Luncher', + body: `Objednávka „${groupName}" byla doručena!`, + tag: `bolt-delivered-${groupName}`, + url: OBJEDNANI_PATH, + }); +} diff --git a/server/src/pushReminder.ts b/server/src/pushReminder.ts index 89354a0..c9c6193 100644 --- a/server/src/pushReminder.ts +++ b/server/src/pushReminder.ts @@ -13,10 +13,21 @@ const lease = createLeaderLease('luncher:reminder:leader'); const POD_ID = process.env.POD_ID ?? `local-${process.pid}`; interface RegistryEntry { - time: string; + /** Čas připomínky výběru oběda (HH:MM). Nevyplněno = uživatel odebírá jen ostatní push notifikace. */ + time?: string; subscription: webpush.PushSubscription; } +/** Data odesílaná do service workeru. */ +export interface PushPayload { + title: string; + body: string; + /** Seskupovací tag notifikace (výchozí v service workeru je připomínka oběda). */ + tag?: string; + /** Cesta, která se otevře po kliknutí na notifikaci. */ + url?: string; +} + type Registry = Record; /** Mapa login → timestamp (ms) posledního odeslání připomínky. */ @@ -55,14 +66,18 @@ export function stopReminderScheduler(): void { } } -/** Přidá nebo aktualizuje push subscription pro uživatele. */ -export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise { +/** + * Přidá nebo aktualizuje push subscription pro uživatele. + * `reminderTime` je volitelný — bez něj uživatel dostává jen ostatní push notifikace + * (např. doručení objednávky Bolt Food), ale ne připomínku výběru oběda. + */ +export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime?: string): Promise { await storage.updateData(REGISTRY_KEY, (current) => { const registry = current ?? {}; registry[login] = { time: reminderTime, subscription }; return registry; }); - console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`); + console.log(`Push reminder: uživatel ${login} přihlášen k push notifikacím${reminderTime ? `, připomínka v ${reminderTime}` : ' (bez připomínky oběda)'}`); } /** Odebere push subscription pro uživatele. */ @@ -94,6 +109,45 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean { return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex')); } +/** Odebere z registru subscriptions, které push služba označila za neplatné (404/410). */ +async function pruneExpiredSubscriptions(expiredLogins: string[]): Promise { + if (expiredLogins.length === 0) return; + await storage.updateData(REGISTRY_KEY, (current) => { + const registry = current ?? {}; + for (const login of expiredLogins) delete registry[login]; + return registry; + }); +} + +/** + * Odešle push notifikaci daným uživatelům. Loginy bez registrované subscription + * se tiše přeskočí, expirované subscriptions se z registru odeberou. + */ +export async function sendPushToLogins(logins: string[], payload: PushPayload): Promise { + if (logins.length === 0) return; + + const registry = await storage.getData(REGISTRY_KEY) ?? {}; + const expiredLogins: string[] = []; + + for (const login of logins) { + const entry = registry[login]; + if (!entry) continue; + try { + await webpush.sendNotification(entry.subscription, JSON.stringify(payload)); + console.log(`Push: odeslána notifikace "${payload.title}" uživateli ${login}`); + } catch (error: any) { + if (error.statusCode === 410 || error.statusCode === 404) { + console.log(`Push: subscription uživatele ${login} expirovala, odebírám`); + expiredLogins.push(login); + } else { + console.error(`Push: chyba při odesílání notifikace uživateli ${login}:`, error); + } + } + } + + await pruneExpiredSubscriptions(expiredLogins); +} + /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */ async function checkAndSendReminders(): Promise { if (getIsWeekend(getToday())) return; @@ -119,7 +173,8 @@ async function checkAndSendReminders(): Promise { const expiredLogins: string[] = []; for (const [login, entry] of entries) { - if (currentTime < entry.time) continue; + // Uživatelé bez nastaveného času připomínky odebírají jen ostatní push notifikace + if (!entry.time || currentTime < entry.time) continue; const last = lastReminded.get(login) ?? 0; if (Date.now() - last < REMINDER_COOLDOWN_MS) continue; @@ -148,13 +203,7 @@ async function checkAndSendReminders(): Promise { } } - if (expiredLogins.length > 0) { - await storage.updateData(REGISTRY_KEY, (current) => { - const r = current ?? {}; - for (const login of expiredLogins) delete r[login]; - return r; - }); - } + await pruneExpiredSubscriptions(expiredLogins); } /** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */ diff --git a/server/src/routes/notificationRoutes.ts b/server/src/routes/notificationRoutes.ts index c377829..890b300 100644 --- a/server/src/routes/notificationRoutes.ts +++ b/server/src/routes/notificationRoutes.ts @@ -26,6 +26,7 @@ router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettings teamsWebhookUrl: req.body.teamsWebhookUrl, enabledEvents: req.body.enabledEvents, reminderTime: req.body.reminderTime, + boltDeliveredPush: req.body.boltDeliveredPush, }); res.status(200).json(settings); } catch (e: any) { next(e) } @@ -40,22 +41,19 @@ router.get("/push/vapidKey", (req, res) => { res.status(200).json({ key }); }); -/** Přihlásí uživatele k push připomínkám. */ +/** Přihlásí uživatele k push notifikacím. Čas připomínky výběru oběda je volitelný. */ router.post("/push/subscribe", async (req, res, next) => { const login = getLogin(parseToken(req)); try { if (!req.body.subscription) { return res.status(400).json({ error: "Nebyla předána push subscription" }); } - if (!req.body.reminderTime) { - return res.status(400).json({ error: "Nebyl předán čas připomínky" }); - } await subscribePush(login, req.body.subscription, req.body.reminderTime); res.status(200).json({}); } catch (e: any) { next(e) } }); -/** Odhlásí uživatele z push připomínek. */ +/** Odhlásí uživatele z push notifikací. */ router.post("/push/unsubscribe", async (req, res, next) => { const login = getLogin(parseToken(req)); try { diff --git a/server/src/tests/boltTracking.test.ts b/server/src/tests/boltTracking.test.ts index 85bec33..0ba9329 100644 --- a/server/src/tests/boltTracking.test.ts +++ b/server/src/tests/boltTracking.test.ts @@ -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; + 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(); + }); +}); diff --git a/server/src/tests/pushReminder.test.ts b/server/src/tests/pushReminder.test.ts new file mode 100644 index 0000000..e2e56af --- /dev/null +++ b/server/src/tests/pushReminder.test.ts @@ -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; + +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> { + 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(); + }); +}); diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 6e515d2..be17059 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -627,6 +627,9 @@ NotificationSettings: reminderTime: description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto. type: string + boltDeliveredPush: + description: Zapnuté push upozornění na doručení skupinové objednávky sledované přes Bolt Food. + type: boolean GotifyServer: type: object required: