diff --git a/functions/index.js b/functions/index.js index 1343cb2..74d5958 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,6 +1,8 @@ 'use strict'; -const functions = require('firebase-functions'); +// firebase-functions v6+ defaults to v2 APIs; use the v1 import to retain +// functions.config(), functions.region(), and functions.runWith(). +const functions = require('firebase-functions/v1'); const { initializeApp } = require('firebase-admin/app'); // We need to initialize the app before importing modules that want Firestore. diff --git a/functions/ios.js b/functions/ios.js index 4c9ec25..00d91d4 100644 --- a/functions/ios.js +++ b/functions/ios.js @@ -1,5 +1,16 @@ +'use strict'; + module.exports = { createPayload: (req) => { + // When live_activity_token is present, this is a Live Activity push notification. + // Firebase Admin SDK v13.5.0+ supports the liveActivityToken (camelCase) field in the + // apns config object. When set, FCM automatically adds 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. + if (req.body.live_activity_token) { + return buildLiveActivityPayload(req); + } + const payload = { notification: { body: req.body.message, @@ -142,3 +153,105 @@ module.exports = { return { updateRateLimits, payload }; }, }; + +// 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 buildLiveActivityPayload(req) { + const { data = {} } = req.body; + const event = data.event ?? '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. + aps['content-state'] = buildLiveActivityContentState(req.body, data); + + if (event === '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['attributes-type'] = 'HALiveActivityAttributes'; + aps.attributes = { + tag: data.activity_id ?? data.tag ?? '', + title: req.body.title ?? '', + }; + } + + if (event === 'end' && data.dismissal_date) { + aps['dismissal-date'] = data.dismissal_date; + } + + if (data.stale_date) { + aps['stale-date'] = data.stale_date; + } + + if (data.relevance_score !== undefined) { + aps['relevance-score'] = data.relevance_score; + } + + // Optional alert shown alongside the live activity update. + if (data.alert) { + aps.alert = data.alert; + if (data.alert_sound) { + aps.sound = data.alert_sound; + } + } + + 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, + }; +} + +// 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 buildLiveActivityContentState(body, data) { + const state = {}; + + // Top-level message field is the primary text; content_state fields take precedence. + if (body.message) { + state.message = body.message; + } + + if (data.content_state) { + const cs = data.content_state; + 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/package.json b/functions/package.json index 8a17626..c148e9a 100644 --- a/functions/package.json +++ b/functions/package.json @@ -17,8 +17,8 @@ "@google-cloud/logging": "^11.0.0", "@valkey/valkey-glide": "^2.0.1", "fastify": "^5.8.3", - "firebase-admin": "^12.1.0", - "firebase-functions": "^5.0.1" + "firebase-admin": "^13.5.0", + "firebase-functions": "^6.1.1" }, "devDependencies": { "@types/node": "^24.1.0", diff --git a/functions/test/fcm-errors.test.js b/functions/test/fcm-errors.test.js index 82fc2c5..a742524 100644 --- a/functions/test/fcm-errors.test.js +++ b/functions/test/fcm-errors.test.js @@ -40,6 +40,7 @@ const mockLogging = { }; jest.mock('firebase-functions', () => mockFunctions); +jest.mock('firebase-functions/v1', () => mockFunctions); jest.mock('@google-cloud/logging', () => ({ Logging: jest.fn(() => mockLogging), })); diff --git a/functions/test/fixtures/live-activity/end.json b/functions/test/fixtures/live-activity/end.json new file mode 100644 index 0000000..3d41bf9 --- /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": "Washer cycle complete", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0" + }, + "data": { + "event": "end", + "activity_id": "laundry-001", + "content_state": { + "message": "Washer cycle complete", + "icon": "mdi:washing-machine-off" + }, + "dismissal_date": 1234571490 + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "end", + "contentState": { + "message": "Washer cycle complete", + "icon": "mdi:washing-machine-off" + }, + "dismissalDate": 1234571490 + } +} 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..05794d3 --- /dev/null +++ b/functions/test/fixtures/live-activity/start-push-to-start.json @@ -0,0 +1,41 @@ +{ + "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", + "content_state": { + "message": "Laundry started", + "progress": 0, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "start", + "attributesType": "HALiveActivityAttributes", + "attributes": { + "tag": "laundry-001", + "title": "Laundry" + }, + "contentState": { + "message": "Laundry started", + "progress": 0, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } +} 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..7d6fb25 --- /dev/null +++ b/functions/test/fixtures/live-activity/update-basic.json @@ -0,0 +1,36 @@ +{ + "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", + "content_state": { + "message": "Washer is done", + "progress": 3600, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } + }, + "expected": { + "updateRateLimits": true, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "update", + "contentState": { + "message": "Washer is done", + "progress": 3600, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } +} 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..49ca9b6 --- /dev/null +++ b/functions/test/fixtures/live-activity/update-full.json @@ -0,0 +1,56 @@ +{ + "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", + "content_state": { + "message": "45 min remaining", + "critical_text": "45 min", + "progress": 2700, + "progress_max": 3600, + "chronometer": true, + "countdown_end": "2024-01-01T12:00:00Z", + "icon": "mdi:timer", + "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": { + "message": "45 min remaining", + "critical_text": "45 min", + "progress": 2700, + "progress_max": 3600, + "chronometer": true, + "countdown_end": "2024-01-01T12:00:00Z", + "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/handleRequest.test.js b/functions/test/handleRequest.test.js index 0f96b3c..6353434 100644 --- a/functions/test/handleRequest.test.js +++ b/functions/test/handleRequest.test.js @@ -48,6 +48,7 @@ const mockLogging = { }; jest.mock('firebase-functions', () => mockFunctions); +jest.mock('firebase-functions/v1', () => mockFunctions); jest.mock('@google-cloud/logging', () => ({ Logging: jest.fn(() => mockLogging), })); diff --git a/functions/test/ios.test.js b/functions/test/ios.test.js new file mode 100644 index 0000000..365f8fe --- /dev/null +++ b/functions/test/ios.test.js @@ -0,0 +1,408 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const { + createMockRequest, + createMockResponse, + createMockDocRef, + createMockRateLimitData, + setupFirestoreCollectionChain, +} = require('./utils/mock-factories'); +const { assertResponse } = require('./utils/assertion-helpers'); + +// --- Mocks --- + +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), +})); +const mockFunctions = { + config: jest.fn(() => ({})), + region: jest.fn().mockReturnThis(), + runWith: jest.fn().mockReturnThis(), + https: { onRequest: jest.fn() }, +}; +jest.mock('firebase-functions', () => mockFunctions); +jest.mock('firebase-functions/v1', () => mockFunctions); + +const { handleRequest } = require('../index.js'); +const ios = require('../ios'); + +// --- 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 }; +} + +// --- Fixture-driven tests --- + +const fixturesDir = path.join(__dirname, 'fixtures/live-activity'); +const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.json')); + +describe('live-activity createPayload via FCM', () => { + // Fixture-driven tests: load each fixture, call ios.createPayload with + // live_activity_token in the body, assert the returned payload has + // apns.liveActivityToken and correct aps fields. + it.each(fixtureFiles)('%s', (filename) => { + const fixture = JSON.parse(fs.readFileSync(path.join(fixturesDir, filename), 'utf8')); + const req = createMockRequest({ body: fixture.input }); + const result = ios.createPayload(req); + + expect(result.updateRateLimits).toBe(fixture.expected.updateRateLimits); + + // liveActivityToken should be set from the input + expect(result.payload.apns.liveActivityToken).toBe(fixture.expected.liveActivityToken); + + // apns-priority header should be '10' + expect(result.payload.apns.headers['apns-priority']).toBe('10'); + + // 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(); + + // Check aps fields + 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.alertSound) { + expect(aps.sound).toBe(fixture.expected.alertSound); + } + + // analytics_label should be set for Live Activity + expect(result.payload.fcm_options.analytics_label).toBe('iOSLiveActivityV1'); + }); + + // --- Unit tests for the FCM payload builder --- + + test('defaults event to update when not specified', () => { + const req = createLiveActivityRequest({ data: {} }); + const { payload } = ios.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 } = ios.createPayload(req); + expect(payload.apns.payload.aps['attributes-type']).toBe('HALiveActivityAttributes'); + expect(payload.apns.payload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); + }); + + test('attributes-type is only set for start events, not update or end', () => { + // HALiveActivityAttributes must only appear in push-to-start payloads. + // APNs rejects update/end payloads that include attributes-type. + for (const event of ['update', 'end']) { + const req = createLiveActivityRequest({ data: { event, activity_id: 'test-001' } }); + const { payload } = ios.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 } = ios.createPayload(req); + expect(payload.apns.payload.aps['dismissal-date']).toBe(9999999); + }); + + 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 } = ios.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, + 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: '2024-01-01T00:00:00Z', + icon: 'mdi:test', + color: '#FF0000', + }, + }, + }, + }); + const { payload } = ios.createPayload(req); + const cs = payload.apns.payload.aps['content-state']; + 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('2024-01-01T00:00:00Z'); + expect(cs.icon).toBe('mdi:test'); + expect(cs.color).toBe('#FF0000'); + }); + + 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 } = ios.createPayload(req); + expect(payload.apns.payload.aps['content-state'].message).toBe('Hello from HA'); + }); + + test('liveActivityToken is set from req.body.live_activity_token', () => { + const req = createLiveActivityRequest(); + const { payload } = ios.createPayload(req); + expect(payload.apns.liveActivityToken).toBe(LIVE_ACTIVITY_TOKEN); + }); + + test('apns-priority header is set to 10', () => { + const req = createLiveActivityRequest(); + const { payload } = ios.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 } = ios.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 = ios.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 = ios.createPayload(req); + // Normal notification should have notification object, not liveActivityToken + 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('iosV1Notification'); + }); +}); + +// --- 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, ios.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, ios.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, ios.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, ios.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, ios.createPayload); + + assertResponse.expectRateLimitResponse(res, FCM_TOKEN); + expect(mockMessaging.send).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/test/rate-limiter/firestore-rate-limiter.test.js b/functions/test/rate-limiter/firestore-rate-limiter.test.js index 1812dc0..692d798 100644 --- a/functions/test/rate-limiter/firestore-rate-limiter.test.js +++ b/functions/test/rate-limiter/firestore-rate-limiter.test.js @@ -34,11 +34,13 @@ jest.mock('firebase-admin/firestore', () => ({ Timestamp: mockTimestamp, })); -jest.mock('firebase-functions', () => ({ +const mockFunctionsLogger = { logger: { info: jest.fn(), }, -})); +}; +jest.mock('firebase-functions', () => mockFunctionsLogger); +jest.mock('firebase-functions/v1', () => mockFunctionsLogger); const FirestoreRateLimiter = require('../../rate-limiter/firestore-rate-limiter'); diff --git a/functions/test/utils/firebase-mocks.js b/functions/test/utils/firebase-mocks.js index 2ee837b..776dc04 100644 --- a/functions/test/utils/firebase-mocks.js +++ b/functions/test/utils/firebase-mocks.js @@ -38,8 +38,9 @@ const setupFirebaseMocks = () => { fromDate: jest.fn((date) => ({ toDate: () => date })), }; - // Set up Jest mocks + // Set up Jest mocks — mock both paths since index.js uses firebase-functions/v1 jest.mock('firebase-functions', () => mockFunctions); + jest.mock('firebase-functions/v1', () => mockFunctions); jest.mock('@google-cloud/logging', () => ({ Logging: jest.fn(() => mockLogging), })); diff --git a/functions/webapp.js b/functions/webapp.js index 52464c8..ee5aa73 100644 --- a/functions/webapp.js +++ b/functions/webapp.js @@ -7,7 +7,7 @@ initializeApp(); const { loggerConfig } = require('./fastify-logger'); const fastify = require('fastify')({ logger: loggerConfig, trustProxy: true }); -// Import the functions from index.js +// Import the handlers const { handleRequest, handleCheckRateLimits } = require('./handlers'); const android = require('./android');