Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s
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:
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user