Files
MealQuest/web/src/push/notification.test.ts
T
dev_agentandClaude Opus 5.5 067e85bd54
CI / ci (push) Failing after 7m33s
CI / ci (pull_request) Failing after 4m31s
Add service worker push handlers and client push subscribe
The worker's push listener shows the payload's title, body and tag with
the app icon and badge, carrying the same-origin url as its data; a
payload that is not a MealQuest notice (no payload, not JSON, no title,
wrong field types, an off-origin url) shows a generic MealQuest
notification and logs why, never throwing. notificationclick focuses
an open MealQuest window and navigates it to the url, or opens a new
window there. Both listeners delegate to pure functions in
web/src/push/notification.ts.

web/src/push/subscribe.ts adds subscribePush, which checks support,
asks for permission only during a tap (and before any await), subscribes
with the server's VAPID key, replacing a subscription made with an
older key, and POSTs exactly {endpoint, keys, user_agent?}; and
unsubscribePush, which DELETEs on the server and then unsubscribes the
browser, answering not-subscribed when there is nothing to remove.
usePush binds both to the app's API client.

Maestro task fb6dd817-2224-4800-9b7d-8499e856e171 (Feature 19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:00 -04:00

192 lines
6.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
APP_BADGE,
APP_ICON,
GENERIC_NOTICE,
appPath,
clickPath,
openNotification,
parsePushPayload,
planClick,
showPush,
type ClickWindow,
} from './notification.ts'
const origin = 'https://mealquest.example'
describe('appPath', () => {
it('keeps a same-origin path with its query and fragment', () => {
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
})
it.each([
'https://evil.example/plan',
'//evil.example/plan',
'/\\evil.example/plan',
'javascript:alert(1)',
'plan',
'',
])('refuses %s', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
it('resolves dot segments and keeps the result on origin', () => {
expect(appPath('/plan/../discover', origin)).toBe('/discover')
})
})
describe('parsePushPayload', () => {
it('reads title, body, tag and url', () => {
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
expect(parsePushPayload(text, origin)).toEqual({
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
problem: null,
})
})
it('opens home with an empty body and no tag when only a title is sent', () => {
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
notice: { title: 'Hi', body: '', url: '/' },
problem: null,
})
})
it('ignores fields it does not know', () => {
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
expect(parsed.problem).toBeNull()
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
})
it.each([
[null, 'push message has no payload'],
['', 'push message has no payload'],
['\u0000ÿ not json', 'push payload is not JSON'],
['[1,2]', 'push payload is not a JSON object'],
['null', 'push payload is not a JSON object'],
['{"body":"no title"}', 'push payload has no title'],
['{"title":" "}', 'push payload has no title'],
['{"title":42}', 'push payload has no title'],
['{"title":"Hi","body":7}', 'push payload body is not a string'],
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
])('shows the generic notice for %j', (text, problem) => {
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
})
})
describe('showPush', () => {
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await showPush(
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
registration,
origin,
warn,
)
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
body: 'Time to shop.',
tag: 'list_ready',
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/plan/shopping' },
})
expect(warn).not.toHaveBeenCalled()
})
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
body: GENERIC_NOTICE.body,
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/' },
})
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
})
})
describe('clickPath', () => {
it('opens the url the notification carries', () => {
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
})
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
expect(clickPath(data, origin)).toBe('/')
})
})
describe('planClick', () => {
const at = (path: string, focused = false) => ({ url: origin + path, focused })
it('opens a new window when no MealQuest window is open', () => {
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('ignores windows on another origin', () => {
const other = { url: 'https://other.example/plan', focused: true }
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('focuses the focused MealQuest window and navigates it', () => {
const first = at('/')
const focused = at('/discover', true)
expect(planClick([first, focused], '/plan', origin)).toEqual({
action: 'focus',
window: focused,
url: `${origin}/plan`,
navigate: true,
})
})
it('focuses the first MealQuest window when none is focused', () => {
const first = at('/')
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
})
it('does not reload a window already at the url', () => {
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
})
})
describe('openNotification', () => {
function window(path: string, focused = false) {
return {
url: origin + path,
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
} satisfies ClickWindow
}
function clients(windows: ClickWindow[]) {
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
}
it('focuses an open window, then navigates it to the url', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/plan/shopping' }, all, origin)
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(all.openWindow).not.toHaveBeenCalled()
})
it('opens a new window at the url when none is open', async () => {
const all = clients([])
await openNotification({ url: '/discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
it('never opens a url off origin, even from tampered data', async () => {
const all = clients([])
await openNotification({ url: 'https://evil.example/' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
})
})