diff --git a/functions/legacy.js b/functions/legacy.js index 5b57fb7..20ae68f 100644 --- a/functions/legacy.js +++ b/functions/legacy.js @@ -1,7 +1,26 @@ +'use strict'; + const path = require('path'); +const liveActivity = require('./live-activity.js'); module.exports = { createPayload: (req) => { + if (req.body.live_activity_token) { + return liveActivity.createPayload(req); + } + + if (process.env.DEBUG === 'true' && req.body.data?.live_update === true) { + console.info( + '[legacy-live-activity]', + JSON.stringify({ + mode: 'fallback_notification', + reason: 'missing_live_activity_token', + tag: req.body.data?.tag ?? null, + activity_id: req.body.data?.activity_id ?? null, + }), + ); + } + const payload = { notification: { body: req.body.message, diff --git a/functions/live-activity.js b/functions/live-activity.js new file mode 100644 index 0000000..f866e65 --- /dev/null +++ b/functions/live-activity.js @@ -0,0 +1,172 @@ +'use strict'; + +const CLEAR_NOTIFICATION = 'clear_notification'; +const LiveActivityEvent = Object.freeze({ + START: 'start', + UPDATE: 'update', + END: 'end', +}); +const LiveActivityApsKey = Object.freeze({ + ATTRIBUTES_TYPE: 'attributes-type', + CONTENT_STATE: 'content-state', + DISMISSAL_DATE: 'dismissal-date', + INTERRUPTION_LEVEL: 'interruption-level', + RELEVANCE_SCORE: 'relevance-score', + STALE_DATE: 'stale-date', +}); + +module.exports = { createPayload }; + +// Builds an FCM-compatible payload for Live Activity push notifications. +// +// The liveActivityToken field (camelCase) is required by Firebase Admin SDK v13.5.0+. +// When present in the apns config, FCM automatically sets apns-push-type: liveactivity +// and routes the notification to APNs correctly. No APNs credentials, HTTP/2 sessions, +// or environment routing are needed — FCM handles it all. +function createPayload(req) { + const { data = {} } = req.body; + const event = data.event ?? LiveActivityEvent.UPDATE; + const now = Math.floor(Date.now() / 1000); + + const aps = { + timestamp: now, + event, + }; + + // content-state is required for start and update; send for end as well so the + // activity can display final state before dismissal. + const contentState = buildContentState(req.body, data); + aps[LiveActivityApsKey.CONTENT_STATE] = contentState; + + if (event === LiveActivityEvent.START) { + // Push-to-start requires the static attributes that were registered with the activity. + // 'attributes-type' must exactly match the Swift struct name — HALiveActivityAttributes — + // because APNs uses it to look up the registered ActivityKit type on the device. + // This value is case-sensitive and cannot change after the app has shipped. + aps[LiveActivityApsKey.ATTRIBUTES_TYPE] = 'HALiveActivityAttributes'; + aps.attributes = { + tag: data.activity_id ?? data.tag ?? '', + title: req.body.title ?? '', + }; + } + + if (event === LiveActivityEvent.END) { + aps[LiveActivityApsKey.DISMISSAL_DATE] = data.dismissal_date ?? now; + } + + if (data.stale_date !== undefined) { + aps[LiveActivityApsKey.STALE_DATE] = data.stale_date; + } + + if (data.relevance_score !== undefined) { + aps[LiveActivityApsKey.RELEVANCE_SCORE] = data.relevance_score; + } + + if (data.alert) { + aps.alert = data.alert; + if (data.alert_sound) { + aps.sound = data.alert_sound; + } + } else if (event === LiveActivityEvent.START) { + // Start events always carry an alert so the user sees the activity launch. + aps.alert = buildAlert(req.body); + } else if (event === LiveActivityEvent.UPDATE) { + if (data.silent === true) { + // silent: true — title-only alert keeps fast APNs delivery without triggering the chime. + aps.alert = { title: '' }; + } else { + aps.alert = buildAlert(req.body); + } + } else if (event === LiveActivityEvent.END) { + aps.alert = buildAlert(req.body); + } + + if (process.env.DEBUG === 'true') { + console.info( + '[live-activity]', + JSON.stringify({ + mode: 'live_activity', + event, + tag: data.tag ?? null, + activity_id: data.activity_id ?? null, + has_alert: Boolean(aps.alert), + interruption_level: aps[LiveActivityApsKey.INTERRUPTION_LEVEL] ?? null, + content_state_keys: Object.keys(contentState), + dismissal_date: aps[LiveActivityApsKey.DISMISSAL_DATE] ?? null, + }), + ); + } + + const payload = { + apns: { + // The liveActivityToken (camelCase) tells Firebase Admin SDK v13.5.0+ to route + // this message as a Live Activity notification. FCM automatically sets the + // apns-push-type: liveactivity header and the correct apns-topic suffix. + liveActivityToken: req.body.live_activity_token, + headers: { + 'apns-priority': '10', + }, + payload: { + aps, + }, + }, + fcm_options: { + analytics_label: 'iOSLiveActivityV1', + }, + }; + + return { + updateRateLimits: true, + payload, + }; +} + +function buildAlert(body) { + return { + title: body.title ?? '', + body: body.message !== CLEAR_NOTIFICATION ? (body.message ?? '') : '', + }; +} + +// Builds the content-state object that APNs delivers to the app's Live Activity widget. +// Each field maps to a property in the Swift HALiveActivityContentState Codable struct. +// Only recognized fields are forwarded — extra keys would cause APNs to reject the payload. +function buildContentState(body, data) { + const state = {}; + + // Top-level message field is the primary text. Do not render command strings. + // The Swift ContentState requires message, so send an empty string if HA omitted it. + if (body.message !== undefined && body.message !== CLEAR_NOTIFICATION) { + state.message = body.message; + } else { + state.message = ''; + } + + if (body.title !== undefined) state.title = body.title; + if (data.critical_text !== undefined) state.critical_text = data.critical_text; + if (data.progress !== undefined) state.progress = data.progress; + if (data.progress_max !== undefined) state.progress_max = data.progress_max; + if (data.chronometer !== undefined) state.chronometer = data.chronometer; + if (data.notification_icon !== undefined) state.icon = data.notification_icon; + if (data.notification_icon_color !== undefined) state.color = data.notification_icon_color; + if (data.when !== undefined) { + state.countdown_end = data.when_relative + ? Math.floor(Date.now() / 1000) + data.when + : data.when; + } + + if (data.content_state) { + const cs = data.content_state; + if (cs.title !== undefined) state.title = cs.title; + if (cs.message !== undefined) state.message = cs.message; + if (cs.critical_text !== undefined) state.critical_text = cs.critical_text; + if (cs.progress !== undefined) state.progress = cs.progress; + if (cs.progress_max !== undefined) state.progress_max = cs.progress_max; + if (cs.chronometer !== undefined) state.chronometer = cs.chronometer; + if (cs.countdown_end !== undefined) state.countdown_end = cs.countdown_end; + if (cs.icon !== undefined) state.icon = cs.icon; + if (cs.color !== undefined) state.color = cs.color; + } + + return state; +} diff --git a/functions/test/fixtures/live-activity/end.json b/functions/test/fixtures/live-activity/end.json new file mode 100644 index 0000000..528f4f9 --- /dev/null +++ b/functions/test/fixtures/live-activity/end.json @@ -0,0 +1,32 @@ +{ + "input": { + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "clear_notification", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0" + }, + "data": { + "event": "end", + "activity_id": "laundry-001", + "dismissal_date": 1234571490 + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "end", + "contentState": { + "title": "Laundry", + "message": "" + }, + "dismissalDate": 1234571490, + "alert": { + "title": "Laundry", + "body": "" + } + } +} diff --git a/functions/test/fixtures/live-activity/start-push-to-start.json b/functions/test/fixtures/live-activity/start-push-to-start.json new file mode 100644 index 0000000..2c064df --- /dev/null +++ b/functions/test/fixtures/live-activity/start-push-to-start.json @@ -0,0 +1,43 @@ +{ + "input": { + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Laundry started", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.2" + }, + "data": { + "event": "start", + "activity_id": "laundry-001", + "progress": 0, + "progress_max": 3600, + "notification_icon": "mdi:washing-machine", + "notification_icon_color": "#2196F3" + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "start", + "attributesType": "HALiveActivityAttributes", + "attributes": { + "tag": "laundry-001", + "title": "Laundry" + }, + "contentState": { + "title": "Laundry", + "message": "Laundry started", + "progress": 0, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + }, + "alert": { + "title": "Laundry", + "body": "Laundry started" + } + } +} diff --git a/functions/test/fixtures/live-activity/update-basic.json b/functions/test/fixtures/live-activity/update-basic.json new file mode 100644 index 0000000..d0ac9d7 --- /dev/null +++ b/functions/test/fixtures/live-activity/update-basic.json @@ -0,0 +1,38 @@ +{ + "input": { + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Washer is done", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0" + }, + "data": { + "event": "update", + "activity_id": "laundry-001", + "progress": 3600, + "progress_max": 3600, + "notification_icon": "mdi:washing-machine", + "notification_icon_color": "#2196F3" + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "update", + "contentState": { + "title": "Laundry", + "message": "Washer is done", + "progress": 3600, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + }, + "alert": { + "title": "Laundry", + "body": "Washer is done" + } + } +} diff --git a/functions/test/fixtures/live-activity/update-full.json b/functions/test/fixtures/live-activity/update-full.json new file mode 100644 index 0000000..e6a1931 --- /dev/null +++ b/functions/test/fixtures/live-activity/update-full.json @@ -0,0 +1,54 @@ +{ + "input": { + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Timer running", + "title": "Kitchen Timer", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0" + }, + "data": { + "event": "update", + "activity_id": "timer-001", + "critical_text": "45 min", + "progress": 2700, + "progress_max": 3600, + "chronometer": true, + "when": 1704110400, + "notification_icon": "mdi:timer", + "notification_icon_color": "#FF5722", + "stale_date": 1234571490, + "relevance_score": 0.8, + "alert": { + "title": "Timer Update", + "body": "45 minutes remaining" + }, + "alert_sound": "default" + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "update", + "contentState": { + "title": "Kitchen Timer", + "message": "Timer running", + "critical_text": "45 min", + "progress": 2700, + "progress_max": 3600, + "chronometer": true, + "countdown_end": 1704110400, + "icon": "mdi:timer", + "color": "#FF5722" + }, + "staleDate": 1234571490, + "relevanceScore": 0.8, + "alert": { + "title": "Timer Update", + "body": "45 minutes remaining" + }, + "alertSound": "default" + } +} diff --git a/functions/test/legacy.test.js b/functions/test/legacy.test.js index c8f038a..f2720b0 100644 --- a/functions/test/legacy.test.js +++ b/functions/test/legacy.test.js @@ -1,9 +1,52 @@ 'use strict'; const fs = require('fs'); -const legacy = require('../legacy.js'); +const path = require('path'); const assert = require('assert'); +const { + createMockRequest, + createMockResponse, + createMockDocRef, + createMockRateLimitData, + setupFirestoreCollectionChain, +} = require('./utils/mock-factories'); +const { assertResponse } = require('./utils/assertion-helpers'); + +// --- Mocks (required for handleRequest integration tests) --- + +const mockMessaging = { send: jest.fn() }; +const mockFirestore = { collection: jest.fn(), runTransaction: jest.fn() }; +const mockLogging = { + log: jest.fn(() => ({ + write: jest.fn((entry, cb) => cb()), + entry: jest.fn(() => ({})), + debug: jest.fn(), + info: jest.fn(), + })), +}; + +jest.mock('@google-cloud/logging', () => ({ Logging: jest.fn(() => mockLogging) })); +jest.mock('firebase-admin/app', () => ({ initializeApp: jest.fn() })); +jest.mock('firebase-admin/firestore', () => ({ + getFirestore: jest.fn(() => mockFirestore), + Timestamp: { fromDate: jest.fn(() => 'mock-timestamp') }, +})); +jest.mock('firebase-admin/messaging', () => ({ + getMessaging: jest.fn(() => mockMessaging), +})); +jest.mock('firebase-functions/v1', () => ({ + config: jest.fn(() => ({})), + region: jest.fn().mockReturnThis(), + runWith: jest.fn().mockReturnThis(), + https: { onRequest: jest.fn() }, +})); + +const { handleRequest } = require('../index.js'); +const legacy = require('../legacy.js'); + +// --- Fixture-driven tests for existing legacy payload builder --- + describe('legacy.js', () => { const fixturesDir = './test/fixtures/legacy/'; @@ -48,3 +91,517 @@ describe('legacy.js', () => { expect(jsonFiles.length).toBeGreaterThan(0); }); }); + +// --- Live Activity helpers --- + +const FCM_TOKEN = 'test:fcm-token-123'; +const LIVE_ACTIVITY_TOKEN = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + +function createLiveActivityRequest(bodyOverrides = {}) { + return createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + message: 'Test message', + title: 'Test title', + registration_info: { + app_id: 'io.robbie.HomeAssistant', + app_version: '2024.1', + os_version: '17.0', + }, + data: { + event: 'update', + activity_id: 'test-001', + content_state: { message: 'Test message' }, + }, + ...bodyOverrides, + }, + }); +} + +function setupFirestoreMocks() { + const docSnapshot = { exists: false, data: jest.fn(() => createMockRateLimitData()) }; + const docRef = createMockDocRef(docSnapshot); + setupFirestoreCollectionChain(mockFirestore, docRef); + + mockFirestore.runTransaction.mockImplementation(async (callback) => { + let exists = docSnapshot.exists; + let currentData = exists ? { ...docSnapshot.data() } : null; + + const mockTxn = { + get: jest.fn().mockImplementation(() => ({ exists, data: () => currentData || {} })), + set: jest.fn().mockImplementation((ref, data) => { + exists = true; + currentData = { ...data }; + docSnapshot.exists = true; + docSnapshot.data = jest.fn(() => currentData); + docRef.set(data); + }), + update: jest.fn().mockImplementation((ref, data) => { + if (currentData) { + currentData = { ...currentData, ...data }; + docSnapshot.data = jest.fn(() => currentData); + } + docRef.update(data); + }), + }; + + return callback(mockTxn); + }); + + return { docRef, docSnapshot }; +} + +// --- Live Activity fixture-driven tests --- + +const liveActivityFixturesDir = path.join(__dirname, 'fixtures/live-activity'); +const liveActivityFixtureFiles = fs + .readdirSync(liveActivityFixturesDir) + .filter((f) => f.endsWith('.json')); + +describe('live-activity createPayload via FCM', () => { + it.each(liveActivityFixtureFiles)('%s', (filename) => { + const fixture = JSON.parse( + fs.readFileSync(path.join(liveActivityFixturesDir, filename), 'utf8'), + ); + const req = createMockRequest({ body: fixture.input }); + const result = legacy.createPayload(req); + + expect(result.updateRateLimits).toBe(fixture.expected.updateRateLimits); + expect(result.payload.apns.liveActivityToken).toBe(fixture.expected.liveActivityToken); + + // No apns-push-type or apns-topic headers — FCM sets them automatically + expect(result.payload.apns.headers['apns-push-type']).toBeUndefined(); + expect(result.payload.apns.headers['apns-topic']).toBeUndefined(); + + const aps = result.payload.apns.payload.aps; + expect(aps.event).toBe(fixture.expected.apsEvent); + expect(typeof aps.timestamp).toBe('number'); + + if (fixture.expected.contentState) { + expect(aps['content-state']).toMatchObject(fixture.expected.contentState); + } + if (fixture.expected.attributesType) { + expect(aps['attributes-type']).toBe(fixture.expected.attributesType); + } + if (fixture.expected.attributes) { + expect(aps.attributes).toMatchObject(fixture.expected.attributes); + } + if (fixture.expected.dismissalDate) { + expect(aps['dismissal-date']).toBe(fixture.expected.dismissalDate); + } + if (fixture.expected.staleDate) { + expect(aps['stale-date']).toBe(fixture.expected.staleDate); + } + if (fixture.expected.relevanceScore) { + expect(aps['relevance-score']).toBe(fixture.expected.relevanceScore); + } + if (fixture.expected.alert) { + expect(aps.alert).toMatchObject(fixture.expected.alert); + } + if (fixture.expected.interruptionLevel) { + expect(aps['interruption-level']).toBe(fixture.expected.interruptionLevel); + } + if (fixture.expected.alertSound) { + expect(aps.sound).toBe(fixture.expected.alertSound); + } + + expect(result.payload.fcm_options.analytics_label).toBe('iOSLiveActivityV1'); + }); + + test('defaults event to update when not specified', () => { + const req = createLiveActivityRequest({ data: {} }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps.event).toBe('update'); + }); + + test('start event includes attributes-type and attributes', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + title: 'Laundry', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'start', activity_id: 'laundry-001' }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['attributes-type']).toBe('HALiveActivityAttributes'); + expect(payload.apns.payload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); + }); + + test('start event synthesizes alert without sound when alert is omitted', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + title: 'Laundry', + message: 'Rinsing', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'start', tag: 'laundry-001' }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps.alert).toEqual({ title: 'Laundry', body: 'Rinsing' }); + expect(payload.apns.payload.aps['interruption-level']).toBeUndefined(); + expect(payload.apns.payload.aps.sound).toBeUndefined(); + }); + + test('attributes-type is only set for start events, not update or end', () => { + for (const event of ['update', 'end']) { + const req = createLiveActivityRequest({ data: { event, activity_id: 'test-001' } }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['attributes-type']).toBeUndefined(); + expect(payload.apns.payload.aps.attributes).toBeUndefined(); + } + }); + + test('end event includes dismissal-date when provided', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'end', dismissal_date: 9999999 }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['dismissal-date']).toBe(9999999); + }); + + test('clear_notification end event dismisses immediately by default', () => { + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1704067200000); + + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + message: 'clear_notification', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'end', tag: 'washer_cycle' }, + }, + }); + const { payload } = legacy.createPayload(req); + const aps = payload.apns.payload.aps; + expect(aps['dismissal-date']).toBe(1704067200); + expect(aps['content-state']).toEqual({ message: '' }); + expect(aps.alert).toEqual({ title: '', body: '' }); + expect(aps['interruption-level']).toBeUndefined(); + expect(aps.sound).toBeUndefined(); + + dateNowSpy.mockRestore(); + }); + + test('stale-date and relevance-score are included when provided', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update', stale_date: 1111, relevance_score: 0.5 }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['stale-date']).toBe(1111); + expect(payload.apns.payload.aps['relevance-score']).toBe(0.5); + }); + + test('content-state maps fields correctly', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + title: 'Timer', + message: 'Fallback', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { + event: 'update', + content_state: { + message: 'Override', + critical_text: 'Critical', + progress: 50, + progress_max: 100, + chronometer: true, + countdown_end: 1704067200, + icon: 'mdi:test', + color: '#FF0000', + }, + }, + }, + }); + const { payload } = legacy.createPayload(req); + const cs = payload.apns.payload.aps['content-state']; + expect(cs.title).toBe('Timer'); + expect(cs.message).toBe('Override'); + expect(cs.critical_text).toBe('Critical'); + expect(cs.progress).toBe(50); + expect(cs.progress_max).toBe(100); + expect(cs.chronometer).toBe(true); + expect(cs.countdown_end).toBe(1704067200); + expect(cs.icon).toBe('mdi:test'); + expect(cs.color).toBe('#FF0000'); + }); + + test('flat Live Activity fields are translated into content-state', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + title: 'Washing Machine', + message: 'Rinsing', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { + event: 'update', + critical_text: 'Rinse', + progress: 900, + progress_max: 3600, + chronometer: true, + notification_icon: 'mdi:washing-machine', + notification_icon_color: '#2196F3', + }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['content-state']).toMatchObject({ + title: 'Washing Machine', + message: 'Rinsing', + critical_text: 'Rinse', + progress: 900, + progress_max: 3600, + chronometer: true, + icon: 'mdi:washing-machine', + color: '#2196F3', + }); + }); + + test('explicit content_state takes precedence over flat fields', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + message: 'Fallback', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { + event: 'update', + progress: 100, + notification_icon: 'mdi:washing-machine', + content_state: { + title: 'Override title', + message: 'Override', + progress: 999, + icon: 'mdi:timer', + }, + }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['content-state']).toMatchObject({ + title: 'Override title', + message: 'Override', + progress: 999, + icon: 'mdi:timer', + }); + }); + + test('relative when is translated into countdown_end', () => { + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1704067200000); + + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update', when: 300, when_relative: true }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['content-state'].countdown_end).toBe(1704067500); + dateNowSpy.mockRestore(); + }); + + test('top-level message is used when no content_state', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + message: 'Hello from HA', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update' }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['content-state'].message).toBe('Hello from HA'); + }); + + test('update event uses real alert with sound by default', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + title: 'Laundry', + message: 'Rinsing', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update', tag: 'laundry-001' }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps.alert).toEqual({ title: 'Laundry', body: 'Rinsing' }); + expect(payload.apns.payload.aps['interruption-level']).toBeUndefined(); + expect(payload.apns.payload.aps.sound).toBeUndefined(); + }); + + test('update event with silent: true uses title-only alert', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + title: 'Laundry', + message: 'Rinsing', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update', tag: 'laundry-001', silent: true }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps.alert).toEqual({ title: '' }); + expect(payload.apns.payload.aps['interruption-level']).toBeUndefined(); + expect(payload.apns.payload.aps.sound).toBeUndefined(); + }); + + test('content-state includes empty message when top-level message is omitted', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update', progress: 1 }, + }, + }); + const { payload } = legacy.createPayload(req); + expect(payload.apns.payload.aps['content-state']).toMatchObject({ + message: '', + progress: 1, + }); + }); + + test('liveActivityToken is set from req.body.live_activity_token', () => { + const req = createLiveActivityRequest(); + const { payload } = legacy.createPayload(req); + expect(payload.apns.liveActivityToken).toBe(LIVE_ACTIVITY_TOKEN); + }); + + test('apns-priority header is set to 10', () => { + const req = createLiveActivityRequest(); + const { payload } = legacy.createPayload(req); + expect(payload.apns.headers['apns-priority']).toBe('10'); + }); + + test('no apns-push-type or apns-topic headers (FCM sets them)', () => { + const req = createLiveActivityRequest(); + const { payload } = legacy.createPayload(req); + expect(payload.apns.headers['apns-push-type']).toBeUndefined(); + expect(payload.apns.headers['apns-topic']).toBeUndefined(); + }); + + test('all live activity events update rate limits', () => { + for (const event of ['start', 'update', 'end']) { + const req = createLiveActivityRequest({ data: { event, activity_id: 'test-001' } }); + const result = legacy.createPayload(req); + expect(result.updateRateLimits).toBe(true); + } + }); + + test('normal notifications (no live_activity_token) still work as before', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + message: 'Hello', + title: 'Test', + registration_info: { + app_id: 'io.robbie.HomeAssistant', + app_version: '2024.1', + os_version: '17.0', + }, + }, + }); + const result = legacy.createPayload(req); + expect(result.payload.notification).toBeDefined(); + expect(result.payload.notification.body).toBe('Hello'); + expect(result.payload.apns.liveActivityToken).toBeUndefined(); + expect(result.payload.fcm_options.analytics_label).toBe('legacyNotification'); + }); +}); + +// --- handleRequest integration tests for Live Activity --- + +describe('handleRequest with Live Activity payload', () => { + let res; + + beforeEach(() => { + jest.clearAllMocks(); + mockMessaging.send.mockResolvedValue('mock-message-id'); + res = createMockResponse(); + setupFirestoreMocks(); + }); + + test('sends Live Activity via FCM and returns 201', async () => { + const req = createLiveActivityRequest(); + await handleRequest(req, res, legacy.createPayload); + + expect(mockMessaging.send).toHaveBeenCalledTimes(1); + const sentPayload = mockMessaging.send.mock.calls[0][0]; + expect(sentPayload.apns.liveActivityToken).toBe(LIVE_ACTIVITY_TOKEN); + expect(sentPayload.apns.payload.aps.event).toBe('update'); + expect(sentPayload.apns.headers['apns-priority']).toBe('10'); + expect(sentPayload.token).toBe(FCM_TOKEN); + + assertResponse.expectSuccessResponse(res); + const response = res.send.mock.calls[0][0]; + expect(response.messageId).toBe('mock-message-id'); + expect(response.target).toBe(FCM_TOKEN); + expect(response.rateLimits).toBeDefined(); + }); + + test('rejects missing token with 403', async () => { + const req = createLiveActivityRequest({ push_token: undefined }); + delete req.body.push_token; + await handleRequest(req, res, legacy.createPayload); + + assertResponse.expectForbiddenResponse(res, 'You did not send a token!'); + expect(mockMessaging.send).not.toHaveBeenCalled(); + }); + + test('updates rate limits for end events', async () => { + const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); + await handleRequest(req, res, legacy.createPayload); + + expect(mockMessaging.send).toHaveBeenCalledTimes(1); + assertResponse.expectSuccessResponse(res); + const response = res.send.mock.calls[0][0]; + expect(response.rateLimits).toBeDefined(); + }); + + test('returns 500 on FCM send failure', async () => { + mockMessaging.send.mockRejectedValue(new Error('Network error')); + const req = createLiveActivityRequest(); + await handleRequest(req, res, legacy.createPayload); + + assertResponse.expectErrorResponse(res, 500, { + errorType: 'InternalError', + errorStep: 'sendNotification', + }); + }); + + test('returns 429 when rate limited', async () => { + const { docSnapshot } = setupFirestoreMocks(); + docSnapshot.exists = true; + docSnapshot.data.mockReturnValue( + createMockRateLimitData({ attemptsCount: 501, deliveredCount: 501, totalCount: 501 }), + ); + + const req = createLiveActivityRequest(); + await handleRequest(req, res, legacy.createPayload); + + assertResponse.expectRateLimitResponse(res, FCM_TOKEN); + expect(mockMessaging.send).not.toHaveBeenCalled(); + }); +});