Files
MealQuest/web/src/test/push.ts
T
dev_agentandClaude Opus 5.5 fd835829e2
CI / ci (pull_request) Successful in 6m57s
CI / ci (push) Successful in 5m49s
Add notification preferences API and screen (1n)
GET/PUT /api/v1/settings/notifications read and replace the event x
{push, chat} matrix for the five events and the reminder time. The PUT
validates every event exactly once and an HH:MM time, refuses unknown
events and fields, and writes the preferences and the household
settings' reminder_time (the one copy) in one transaction, publishing
the new settings to the scheduler as a household settings change does.

/settings/notifications renders mock 1n: the matrix with Toggle
switches, including the next-day "Keep it in rotation?" Rotation check
(D-021); the reminder time edited in a sheet; the push permission row
using pushStatus/subscribePush/unsubscribePush; the iOS InstallHint;
the Google Chat webhook card with the masked URL, "Connected · {space}"
and Send test showing success or the HTTP status; and the preview
bubble. The desktop 2e Notifications & AI tab shows the matrix and
webhook cards in its left column. The settings home row now reads Web
Push on this device and the Google Chat space, and the Household form
takes a reminder time saved elsewhere without undoing it.

Maestro task 308a460d-b22c-4179-acba-d5ea0968ccd5.

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

69 lines
2.4 KiB
TypeScript

import { vi } from 'vitest'
import { encodeBase64url, type BrowserPushSubscription } from '../push/subscribe.ts'
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(length))
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
return out
}
const vapidKey = bytes(65, 4)
// VAPID_PUBLIC_KEY is a GET /api/v1/push/vapid-public-key body whose key
// the stubbed subscriptions were made with.
export const VAPID_PUBLIC_KEY = { public_key: encodeBase64url(vapidKey.buffer) }
export const PUSH_ENDPOINT = 'https://push.example/send/abc'
type BrowserPush = {
// The browser's notification permission before any prompt.
permission?: NotificationPermission
// How the permission prompt is answered.
answer?: NotificationPermission
// Whether this browser already holds a push subscription.
subscribed?: boolean
}
// stubPushEnvironment gives jsdom, which has no Web Push, a browser with a
// registered service worker, PushManager, Notification and an active tap.
// Undo with restorePushEnvironment and vi.unstubAllGlobals.
export function stubPushEnvironment({ permission = 'default', answer = 'granted', subscribed = false }: BrowserPush = {}) {
const subscription: BrowserPushSubscription = {
endpoint: PUSH_ENDPOINT,
options: { applicationServerKey: vapidKey.buffer },
getKey: (name) => (name === 'p256dh' ? bytes(65, 4).buffer : bytes(16, 200).buffer),
unsubscribe: vi.fn(async () => {
current = null
return true
}),
}
let current: BrowserPushSubscription | null = subscribed ? subscription : null
const pushManager = {
getSubscription: vi.fn(async () => current),
subscribe: vi.fn(async () => {
current = subscription
return subscription
}),
}
const notification = {
permission,
requestPermission: vi.fn(async () => {
notification.permission = answer
return answer
}),
}
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: async () => ({ pushManager }) },
})
Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: true } })
vi.stubGlobal('PushManager', function PushManager() {})
vi.stubGlobal('Notification', notification)
return { notification, pushManager }
}
export function restorePushEnvironment() {
Reflect.deleteProperty(navigator, 'serviceWorker')
Reflect.deleteProperty(navigator, 'userActivation')
}