Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
2026-10-09 18:04:05 -04:00
committed by approver_agent
parent 4b592fdbbb
commit dcd35e78ec
111 changed files with 11455 additions and 494 deletions
+30 -3
View File
@@ -61,14 +61,41 @@ export const GOOGLE_NOT_CONNECTED = {
last_sync_error: null,
}
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
// seed channels and reminder time.
export const NOTIFICATION_SETTINGS = {
prefs: [
{ event: 'plan_ready', push: true, chat: true },
{ event: 'list_ready', push: true, chat: true },
{ event: 'day_of_reminder', push: true, chat: false },
{ event: 'new_suggestions', push: true, chat: false },
{ event: 'rotation_review', push: true, chat: false },
],
reminder_time: '16:30',
}
// CHAT_WEBHOOK_NONE is a GET /api/v1/settings/chat-webhook body: no webhook
// stored.
export const CHAT_WEBHOOK_NONE = { space_label: null, url_masked: null, last_test_at: null, last_test_status: null }
// CHAT_WEBHOOK is a GET /api/v1/settings/chat-webhook body: a webhook
// stored and never tested.
export const CHAT_WEBHOOK = {
space_label: 'Dinner Crew space',
url_masked: 'https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?token=…qQ%3D',
last_test_at: null,
last_test_status: null,
}
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners and notifications status endpoints do not
// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so
// today's server does: the dinners endpoint does not exist yet, and neither
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
+68
View File
@@ -0,0 +1,68 @@
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')
}