From 27361a7733dbe5da364779b2f535d61b6756e938 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 8 May 2026 15:04:32 -0400 Subject: [PATCH 1/2] feat(messaging): migrate topic subscription APIs to new REST endpoints via HTTP/2 multiplexing --- .../messaging-api-request-internal.ts | 37 + src/messaging/messaging.ts | 139 +- test/unit/messaging/messaging.spec.ts | 1764 ++++++++--------- 3 files changed, 1000 insertions(+), 940 deletions(-) diff --git a/src/messaging/messaging-api-request-internal.ts b/src/messaging/messaging-api-request-internal.ts index 3c4e124d44..c7bcbc20eb 100644 --- a/src/messaging/messaging-api-request-internal.ts +++ b/src/messaging/messaging-api-request-internal.ts @@ -152,6 +152,43 @@ export class FirebaseMessagingRequestHandler { }); } + /** + * Invokes the HTTP/2 request handler for generic operations (e.g., topic subscriptions). + * + * @param host - The host to which to send the request. + * @param path - The path to which to send the request. + * @param method - The HTTP method to use. + * @param requestData - Optional request data. + * @param http2SessionHandler - The HTTP/2 session handler. + * @returns A promise that resolves with the response data. + */ + public invokeHttp2RequestHandler( + host: string, + path: string, + method: HttpMethod, + requestData: object | undefined, + http2SessionHandler: Http2SessionHandler + ): Promise { + const request: Http2RequestConfig = { + method, + url: `https://${host}${path}`, + data: requestData, + headers: FIREBASE_MESSAGING_HEADERS, + timeout: FIREBASE_MESSAGING_TIMEOUT, + http2SessionHandler, + }; + return this.http2Client.send(request).then((response) => { + if (!response.isJson()) { + throw new RequestResponseError(response); + } + const errorCode = getErrorCode(response.data); + if (errorCode) { + throw new RequestResponseError(response); + } + return response.data; + }); + } + private buildSendResponse(response: RequestResponse): SendResponse { const result: SendResponse = { success: response.status === 200, diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index a78fdb71ce..7efc60e247 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -24,6 +24,7 @@ import { ErrorInfo } from '../utils/error'; import * as utils from '../utils'; import * as validator from '../utils/validator'; import { validateMessage } from './messaging-internal'; +import { getErrorCode, createFirebaseError } from './messaging-errors-internal'; import { FirebaseMessagingRequestHandler } from './messaging-api-request-internal'; import { @@ -36,53 +37,14 @@ import { // Legacy API types SendResponse, } from './messaging-api'; -import { Http2SessionHandler } from '../utils/api-request'; +import { Http2SessionHandler, RequestResponseError } from '../utils/api-request'; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; -const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; // Maximum messages that can be included in a batch request. const FCM_MAX_BATCH_SIZE = 500; -/** - * Maps a raw FCM server response to a `MessagingTopicManagementResponse` object. - * - * @param {object} response The raw FCM server response to map. - * - * @returns {MessagingTopicManagementResponse} The mapped `MessagingTopicManagementResponse` object. - */ -function mapRawResponseToTopicManagementResponse(response: object): MessagingTopicManagementResponse { - // Add the success and failure counts. - const result: MessagingTopicManagementResponse = { - successCount: 0, - failureCount: 0, - errors: [], - }; - - if ('results' in response) { - (response as any).results.forEach((tokenManagementResult: any, index: number) => { - // Map the FCM server's error strings to actual error objects. - if ('error' in tokenManagementResult) { - result.failureCount += 1; - const newError = FirebaseMessagingError.fromTopicManagementServerError( - tokenManagementResult.error, /* message */ undefined, tokenManagementResult.error, - ); - - result.errors.push({ - index, - error: newError, - }); - } else { - result.successCount += 1; - } - }); - } - return result; -} - /** * Messaging service bound to the provided app. @@ -379,7 +341,7 @@ export class Messaging { registrationTokenOrTokens, topic, 'subscribeToTopic', - FCM_TOPIC_MANAGEMENT_ADD_PATH, + '', ); } @@ -406,7 +368,7 @@ export class Messaging { registrationTokenOrTokens, topic, 'unsubscribeFromTopic', - FCM_TOPIC_MANAGEMENT_REMOVE_PATH, + '', ); } @@ -448,7 +410,7 @@ export class Messaging { registrationTokenOrTokens: string | string[], topic: string, methodName: string, - path: string, + _path: string, ): Promise { this.validateRegistrationTokensType(registrationTokenOrTokens, methodName); this.validateTopicType(topic, methodName); @@ -469,17 +431,88 @@ export class Messaging { registrationTokensArray = [registrationTokenOrTokens as string]; } - const request = { - to: topic, - registration_tokens: registrationTokensArray, - }; + return utils.findProjectId(this.app).then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, + 'Failed to determine project ID for Messaging. Initialize the ' + + 'SDK with service account credentials or set project ID as an app option. ' + + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', + ); + } - return this.messagingRequestHandler.invokeRequestHandler( - FCM_TOPIC_MANAGEMENT_HOST, path, request, - ); - }) - .then((response) => { - return mapRawResponseToTopicManagementResponse(response); + const topicName = topic.replace(/^\/topics\//, ''); + const isSubscribe = methodName === 'subscribeToTopic'; + const httpMethod = isSubscribe ? 'POST' : 'DELETE'; + + const http2SessionHandler = new Http2SessionHandler('https://fcm.googleapis.com'); + + let settledPromise: Promise[]>; + return new Promise((resolve, reject) => { + http2SessionHandler.invoke().catch((error) => { + reject(new FirebaseMessagingSessionError(error, undefined, undefined)); + }); + + const requests = registrationTokensArray.map(async (registrationId) => { + let requestPath = `/v1/projects/${projectId}/registrations/${registrationId}/topicSubscriptions`; + if (isSubscribe) { + requestPath += `?topic_name=${topicName}`; + } else { + requestPath += `/${topicName}?allow_missing=true`; + } + return this.messagingRequestHandler.invokeHttp2RequestHandler( + 'fcm.googleapis.com', + requestPath, + httpMethod, + isSubscribe ? {} : undefined, + http2SessionHandler + ); + }); + + settledPromise = Promise.allSettled(requests); + settledPromise.then((results) => { + if (results.length > 0 && results.every((r) => r.status === 'rejected')) { + const firstReason = (results[0] as PromiseRejectedResult).reason; + if (firstReason instanceof RequestResponseError) { + reject(createFirebaseError(firstReason)); + } else { + reject(firstReason); + } + return; + } + + const response: MessagingTopicManagementResponse = { + successCount: 0, + failureCount: 0, + errors: [], + }; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + response.successCount += 1; + } else { + response.failureCount += 1; + const err = result.reason; + const errorCode = err.response?.isJson() ? getErrorCode(err.response.data) : null; + const errorMessage = err.response?.isJson() ? err.response.data?.error?.message : err.message; + const newError = FirebaseMessagingError.fromTopicManagementServerError( + errorCode || 'UNKNOWN', + errorMessage, + err.response?.isJson() ? err.response.data : undefined + ); + response.errors.push({ + index, + error: newError, + }); + } + }); + + resolve(response); + }); + }).finally(() => { + http2SessionHandler.close(); + }); + }); }); } diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index bf6e0808e2..04824661c8 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -44,9 +44,6 @@ const expect = chai.expect; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; -const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; const mockServerErrorResponse = { json: { @@ -152,57 +149,10 @@ function mockErrorResponse( }); } -function mockTopicSubscriptionRequest( - methodName: string, - successCount = 1, - failureCount = 0, -): nock.Scope { - const mockedResults = []; - - for (let i = 0; i < successCount; i++) { - mockedResults.push({}); - } - - for (let i = 0; i < failureCount; i++) { - mockedResults.push({ error: 'TOO_MANY_TOPICS' }); - } - - const path = (methodName === 'subscribeToTopic') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; - - return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) - .post(path) - .reply(200, { - results: mockedResults, - }); -} - -function mockTopicSubscriptionRequestWithError( - methodName: string, - statusCode: number, - errorFormat: 'json' | 'text', - responseOverride?: any, -): nock.Scope { - let response; - let contentType; - if (errorFormat === 'json') { - response = mockServerErrorResponse.json; - contentType = 'application/json; charset=UTF-8'; - } else { - response = mockServerErrorResponse.text; - contentType = 'text/html; charset=UTF-8'; - } - - const path = (methodName === 'subscribeToTopic') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; - - return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) - .post(path) - .reply(statusCode, responseOverride || response, { - 'Content-Type': contentType, - }); -} function disableRetries(messaging: Messaging): void { (messaging as any).messagingRequestHandler.httpClient.retry = null; + (messaging as any).messagingRequestHandler.http2Client.retry = null; } class CustomArray extends Array { } @@ -228,7 +178,7 @@ describe('Messaging', () => { 'X-Goog-Api-Client': getMetricsHeader(), 'access_token_auth': 'true', }; - const emptyResponse = utils.responseFrom({}); + after(() => { nock.cleanAll(); @@ -257,6 +207,62 @@ describe('Messaging', () => { return mockApp.delete(); }); + function mockTopicSubscriptionRequest( + methodName: string, + successCount = 1, + failureCount = 0, + ): nock.Scope { + for (let i = 0; i < successCount; i++) { + mockedHttp2Responses.push({ + headers: { ':status': 200, 'content-type': 'application/json; charset=UTF-8' }, + data: Buffer.from(JSON.stringify({})), + } as any); + } + + for (let i = 0; i < failureCount; i++) { + mockedHttp2Responses.push({ + headers: { ':status': 400, 'content-type': 'application/json; charset=UTF-8' }, + data: Buffer.from(JSON.stringify({ error: { status: 'TOO_MANY_TOPICS', message: 'TOO_MANY_TOPICS' } })), + } as any); + } + + http2Mocker.http2Stub(mockedHttp2Responses); + return { done: () => { } } as any; + } + + function mockTopicSubscriptionRequestWithError( + methodName: string, + statusCode: number, + errorFormat: 'json' | 'text', + responseOverride?: any, + ): nock.Scope { + let responseStr; + let contentType; + if (errorFormat === 'json') { + const overrideObj = responseOverride || mockServerErrorResponse.json; + let respObj = overrideObj; + if (overrideObj && overrideObj.error && typeof overrideObj.error === 'string') { + respObj = { error: { status: overrideObj.error, message: overrideObj.error } }; + } else if (overrideObj && typeof overrideObj.error === 'object') { + respObj = overrideObj; + } else if (overrideObj && overrideObj.foo) { + respObj = overrideObj; + } + responseStr = JSON.stringify(respObj); + contentType = 'application/json; charset=UTF-8'; + } else { + responseStr = responseOverride || mockServerErrorResponse.text; + contentType = 'text/html; charset=UTF-8'; + } + + mockedHttp2Responses.push({ + headers: { ':status': statusCode, 'content-type': contentType }, + data: Buffer.from(responseStr), + } as any); + + http2Mocker.http2Stub(mockedHttp2Responses); + return { done: () => { } } as any; + } describe('Constructor', () => { const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; @@ -667,14 +673,14 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens', () => { - return nullAccessTokenMessaging.sendEach( - [validMessage, validMessage], - ).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); + return nullAccessTokenMessaging.sendEach( + [validMessage, validMessage], + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); + }); }); - }); it('should expose the FCM error code in a detailed error via BatchResponse', () => { const messageIds = [ @@ -875,13 +881,13 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens using HTTP/2', () => { - return nullAccessTokenMessaging.sendEach( - [validMessage, validMessage], false).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); + return nullAccessTokenMessaging.sendEach( + [validMessage, validMessage], false).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); + }); }); - }); it('should expose the FCM error code in a detailed error via BatchResponse using HTTP/2', () => { const messageIds = [ @@ -978,40 +984,40 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse containing AggregateError when multiple session errors occur' + ' using HTTP/2', () => { - const sessionError1 = 'MOCK_SESSION_ERROR_1'; - const sessionError2 = 'MOCK_SESSION_ERROR_2'; - - mockedHttp2Responses.push(mockHttp2Error( - new Error('MOCK_STREAM_ERROR_1'), - new Error(sessionError1) - )); - mockedHttp2Responses.push(mockHttp2Error( - new Error('MOCK_STREAM_ERROR_2'), - new Error(sessionError2) - )); - - http2Mocker.http2Stub(mockedHttp2Responses); - - return messaging.sendEach( - [validMessage, validMessage], true - ).then((response: BatchResponse) => { - expect(http2Mocker.requests.length).to.equal(2); - expect(response.failureCount).to.equal(2); - - const failure = response.responses[0]; - expect(failure.success).to.be.false; - expect(failure.error!.code).to.equal('messaging/unknown-error'); - - const cause = failure.error!.cause; - expect(cause).to.not.be.undefined; - expect(cause!.constructor.name).to.equal('AggregateError'); - expect((cause as any).errors).to.be.an.instanceOf(Array); - expect((cause as any).errors.length).to.equal(3); - expect((cause as any).errors[0].message).to.contain('MOCK_STREAM_ERROR'); - expect((cause as any).errors[1].message).to.contain(sessionError1); - expect((cause as any).errors[2].message).to.contain(sessionError2); + const sessionError1 = 'MOCK_SESSION_ERROR_1'; + const sessionError2 = 'MOCK_SESSION_ERROR_2'; + + mockedHttp2Responses.push(mockHttp2Error( + new Error('MOCK_STREAM_ERROR_1'), + new Error(sessionError1) + )); + mockedHttp2Responses.push(mockHttp2Error( + new Error('MOCK_STREAM_ERROR_2'), + new Error(sessionError2) + )); + + http2Mocker.http2Stub(mockedHttp2Responses); + + return messaging.sendEach( + [validMessage, validMessage], true + ).then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + expect(response.failureCount).to.equal(2); + + const failure = response.responses[0]; + expect(failure.success).to.be.false; + expect(failure.error!.code).to.equal('messaging/unknown-error'); + + const cause = failure.error!.cause; + expect(cause).to.not.be.undefined; + expect(cause!.constructor.name).to.equal('AggregateError'); + expect((cause as any).errors).to.be.an.instanceOf(Array); + expect((cause as any).errors.length).to.equal(3); + expect((cause as any).errors[0].message).to.contain('MOCK_STREAM_ERROR'); + expect((cause as any).errors[1].message).to.contain(sessionError1); + expect((cause as any).errors[2].message).to.contain(sessionError2); + }); }); - }); // This test was added to also verify https://github.com/firebase/firebase-admin-node/issues/1146 it('should be fulfilled when called with different message types using HTTP/2', () => { @@ -1410,14 +1416,14 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens', () => { - return nullAccessTokenMessaging.sendEachForMulticast( - { tokens: ['a', 'a'] }, - ).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); + return nullAccessTokenMessaging.sendEachForMulticast( + { tokens: ['a', 'a'] }, + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); + }); }); - }); it('should expose the FCM error code in a detailed error via BatchResponse', () => { const messageIds = [ @@ -1556,14 +1562,14 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens using HTTP/2', () => { - return nullAccessTokenMessaging.sendEachForMulticast( - { tokens: ['a', 'a'] }, false - ).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); + return nullAccessTokenMessaging.sendEachForMulticast( + { tokens: ['a', 'a'] }, false + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); + }); }); - }); it('should expose the FCM error code in a detailed error via BatchResponse using HTTP/2', () => { const messageIds = [ @@ -2041,757 +2047,757 @@ describe('Messaging', () => { req: any; expectedReq?: any; }> = [ - { - label: 'Generic data message', - req: { - data: { - k1: 'v1', - k2: 'v2', - }, - }, - }, - { - label: 'Generic notification message', - req: { - notification: { - title: 'test.title', - body: 'test.body', - imageUrl: 'https://example.com/image.png', - }, - }, - expectedReq: { - notification: { - title: 'test.title', - body: 'test.body', - image: 'https://example.com/image.png', - }, - }, - }, - { - label: 'Generic fcmOptions message', - req: { - fcmOptions: { - analyticsLabel: 'test.analytics', - }, - }, - }, - { - label: 'Android data message', - req: { - android: { + { + label: 'Generic data message', + req: { data: { k1: 'v1', k2: 'v2', }, }, }, - }, - { - label: 'Android notification message', - req: { - android: { + { + label: 'Generic notification message', + req: { notification: { title: 'test.title', body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', imageUrl: 'https://example.com/image.png', - ticker: 'test.ticker', - sticky: true, - visibility: 'private', - proxy: 'deny', }, }, - }, - expectedReq: { - android: { + expectedReq: { notification: { title: 'test.title', body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', image: 'https://example.com/image.png', - ticker: 'test.ticker', - sticky: true, - visibility: 'PRIVATE', - proxy: 'DENY' }, }, }, - }, - { - label: 'Android camel cased properties', - req: { - android: { - collapseKey: 'test.key', - restrictedPackageName: 'test.package', - directBootOk: true, - bandwidthConstrainedOk: true, - restrictedSatelliteOk: true, - notification: { - clickAction: 'test.click.action', - titleLocKey: 'title.loc.key', - titleLocArgs: ['arg1', 'arg2'], - bodyLocKey: 'body.loc.key', - bodyLocArgs: ['arg1', 'arg2'], - channelId: 'test.channel', - eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), - localOnly: true, - priority: 'high', - vibrateTimingsMillis: [100, 50, 250], - defaultVibrateTimings: false, - defaultSound: true, - lightSettings: { - color: '#AABBCCDD', - lightOnDurationMillis: 200, - lightOffDurationMillis: 300, - }, - defaultLightSettings: false, - notificationCount: 1, + { + label: 'Generic fcmOptions message', + req: { + fcmOptions: { + analyticsLabel: 'test.analytics', }, }, }, - expectedReq: { - android: { - collapse_key: 'test.key', - restricted_package_name: 'test.package', - direct_boot_ok: true, - bandwidth_constrained_ok: true, - restricted_satellite_ok: true, - notification: { - click_action: 'test.click.action', - title_loc_key: 'title.loc.key', - title_loc_args: ['arg1', 'arg2'], - body_loc_key: 'body.loc.key', - body_loc_args: ['arg1', 'arg2'], - channel_id: 'test.channel', - event_time: '2019-10-20T18:30:00.000Z', - local_only: true, - notification_priority: 'PRIORITY_HIGH', - vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], - default_vibrate_timings: false, - default_sound: true, - light_settings: { - color: { - red: 0.6666666666666666, - green: 0.7333333333333333, - blue: 0.8, - alpha: 0.8666666666666667, - }, - light_on_duration: '0.200000000s', - light_off_duration: '0.300000000s', + { + label: 'Android data message', + req: { + android: { + data: { + k1: 'v1', + k2: 'v2', }, - default_light_settings: false, - notification_count: 1, }, }, }, - }, - { - label: 'Android TTL', - req: { - android: { - priority: 'high', - collapseKey: 'test.key', - restrictedPackageName: 'test.package', - ttl: 5000, - }, - }, - expectedReq: { - android: { - priority: 'high', - collapse_key: 'test.key', - restricted_package_name: 'test.package', - ttl: '5s', - }, - }, - }, - { - label: 'All Android properties', - req: { - android: { - priority: 'high', - collapseKey: 'test.key', - restrictedPackageName: 'test.package', - directBootOk: true, - bandwidthConstrainedOk: true, - restrictedSatelliteOk: true, - ttl: 5, - data: { - k1: 'v1', - k2: 'v2', - }, - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', - imageUrl: 'https://example.com/image.png', - clickAction: 'test.click.action', - titleLocKey: 'title.loc.key', - titleLocArgs: ['arg1', 'arg2'], - bodyLocKey: 'body.loc.key', - bodyLocArgs: ['arg1', 'arg2'], - channelId: 'test.channel', - ticker: 'test.ticker', - sticky: true, - visibility: 'private', - eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), - localOnly: true, - priority: 'high', - vibrateTimingsMillis: [100, 50, 250], - defaultVibrateTimings: false, - defaultSound: true, - lightSettings: { - color: '#AABBCC', - lightOnDurationMillis: 200, - lightOffDurationMillis: 300, + { + label: 'Android notification message', + req: { + android: { + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + imageUrl: 'https://example.com/image.png', + ticker: 'test.ticker', + sticky: true, + visibility: 'private', + proxy: 'deny', }, - defaultLightSettings: false, - notificationCount: 1, - proxy: 'if_priority_lowered', }, - fcmOptions: { - analyticsLabel: 'test.analytics', + }, + expectedReq: { + android: { + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + image: 'https://example.com/image.png', + ticker: 'test.ticker', + sticky: true, + visibility: 'PRIVATE', + proxy: 'DENY' + }, }, }, }, - expectedReq: { - android: { - priority: 'high', - collapse_key: 'test.key', - restricted_package_name: 'test.package', - direct_boot_ok: true, - bandwidth_constrained_ok: true, - restricted_satellite_ok: true, - ttl: '0.005000000s', // 5 ms = 5,000,000 ns - data: { - k1: 'v1', - k2: 'v2', - }, - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', - image: 'https://example.com/image.png', - click_action: 'test.click.action', - title_loc_key: 'title.loc.key', - title_loc_args: ['arg1', 'arg2'], - body_loc_key: 'body.loc.key', - body_loc_args: ['arg1', 'arg2'], - channel_id: 'test.channel', - ticker: 'test.ticker', - sticky: true, - visibility: 'PRIVATE', - event_time: '2019-10-20T18:30:00.000Z', - local_only: true, - notification_priority: 'PRIORITY_HIGH', - vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], - default_vibrate_timings: false, - default_sound: true, - light_settings: { - color: { - red: 0.6666666666666666, - green: 0.7333333333333333, - blue: 0.8, - alpha: 1, + { + label: 'Android camel cased properties', + req: { + android: { + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + directBootOk: true, + bandwidthConstrainedOk: true, + restrictedSatelliteOk: true, + notification: { + clickAction: 'test.click.action', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + bodyLocKey: 'body.loc.key', + bodyLocArgs: ['arg1', 'arg2'], + channelId: 'test.channel', + eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), + localOnly: true, + priority: 'high', + vibrateTimingsMillis: [100, 50, 250], + defaultVibrateTimings: false, + defaultSound: true, + lightSettings: { + color: '#AABBCCDD', + lightOnDurationMillis: 200, + lightOffDurationMillis: 300, }, - light_on_duration: '0.200000000s', - light_off_duration: '0.300000000s', + defaultLightSettings: false, + notificationCount: 1, }, - default_light_settings: false, - notification_count: 1, - proxy: 'IF_PRIORITY_LOWERED', }, - fcmOptions: { - analyticsLabel: 'test.analytics', + }, + expectedReq: { + android: { + collapse_key: 'test.key', + restricted_package_name: 'test.package', + direct_boot_ok: true, + bandwidth_constrained_ok: true, + restricted_satellite_ok: true, + notification: { + click_action: 'test.click.action', + title_loc_key: 'title.loc.key', + title_loc_args: ['arg1', 'arg2'], + body_loc_key: 'body.loc.key', + body_loc_args: ['arg1', 'arg2'], + channel_id: 'test.channel', + event_time: '2019-10-20T18:30:00.000Z', + local_only: true, + notification_priority: 'PRIORITY_HIGH', + vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], + default_vibrate_timings: false, + default_sound: true, + light_settings: { + color: { + red: 0.6666666666666666, + green: 0.7333333333333333, + blue: 0.8, + alpha: 0.8666666666666667, + }, + light_on_duration: '0.200000000s', + light_off_duration: '0.300000000s', + }, + default_light_settings: false, + notification_count: 1, + }, }, }, }, - }, - { - label: 'Webpush data message', - req: { - webpush: { - data: { - k1: 'v1', - k2: 'v2', + { + label: 'Android TTL', + req: { + android: { + priority: 'high', + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + ttl: 5000, }, }, - }, - }, - { - label: 'Webpush notification message', - req: { - webpush: { - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', + expectedReq: { + android: { + priority: 'high', + collapse_key: 'test.key', + restricted_package_name: 'test.package', + ttl: '5s', }, }, }, - }, - { - label: 'All Webpush properties', - req: { - webpush: { - headers: { - h1: 'v1', - h2: 'v2', - }, - data: { - k1: 'v1', - k2: 'v2', - }, - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - actions: [{ - action: 'test.action.1', - title: 'test.action.1.title', - icon: 'test.action.1.icon', - }, { - action: 'test.action.2', - title: 'test.action.2.title', - icon: 'test.action.2.icon', - }], - badge: 'test.badge', + { + label: 'All Android properties', + req: { + android: { + priority: 'high', + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + directBootOk: true, + bandwidthConstrainedOk: true, + restrictedSatelliteOk: true, + ttl: 5, data: { - key: 'value', + k1: 'v1', + k2: 'v2', + }, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + imageUrl: 'https://example.com/image.png', + clickAction: 'test.click.action', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + bodyLocKey: 'body.loc.key', + bodyLocArgs: ['arg1', 'arg2'], + channelId: 'test.channel', + ticker: 'test.ticker', + sticky: true, + visibility: 'private', + eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), + localOnly: true, + priority: 'high', + vibrateTimingsMillis: [100, 50, 250], + defaultVibrateTimings: false, + defaultSound: true, + lightSettings: { + color: '#AABBCC', + lightOnDurationMillis: 200, + lightOffDurationMillis: 300, + }, + defaultLightSettings: false, + notificationCount: 1, + proxy: 'if_priority_lowered', + }, + fcmOptions: { + analyticsLabel: 'test.analytics', }, - dir: 'auto', - image: 'test.image', - requireInteraction: true, }, - fcmOptions: { - link: 'https://example.com', + }, + expectedReq: { + android: { + priority: 'high', + collapse_key: 'test.key', + restricted_package_name: 'test.package', + direct_boot_ok: true, + bandwidth_constrained_ok: true, + restricted_satellite_ok: true, + ttl: '0.005000000s', // 5 ms = 5,000,000 ns + data: { + k1: 'v1', + k2: 'v2', + }, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + image: 'https://example.com/image.png', + click_action: 'test.click.action', + title_loc_key: 'title.loc.key', + title_loc_args: ['arg1', 'arg2'], + body_loc_key: 'body.loc.key', + body_loc_args: ['arg1', 'arg2'], + channel_id: 'test.channel', + ticker: 'test.ticker', + sticky: true, + visibility: 'PRIVATE', + event_time: '2019-10-20T18:30:00.000Z', + local_only: true, + notification_priority: 'PRIORITY_HIGH', + vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], + default_vibrate_timings: false, + default_sound: true, + light_settings: { + color: { + red: 0.6666666666666666, + green: 0.7333333333333333, + blue: 0.8, + alpha: 1, + }, + light_on_duration: '0.200000000s', + light_off_duration: '0.300000000s', + }, + default_light_settings: false, + notification_count: 1, + proxy: 'IF_PRIORITY_LOWERED', + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + }, }, }, }, - }, - { - label: 'APNS headers only', - req: { - apns: { - headers: { - k1: 'v1', - k2: 'v2', + { + label: 'Webpush data message', + req: { + webpush: { + data: { + k1: 'v1', + k2: 'v2', + }, }, }, }, - }, - { - label: 'APNS string alert', - req: { - apns: { - payload: { - aps: { - alert: 'test.alert', + { + label: 'Webpush notification message', + req: { + webpush: { + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', }, }, }, }, - }, - { - label: 'All APNS properties', - req: { - apns: { - headers: { - h1: 'v1', - h2: 'v2', - }, - payload: { - aps: { - alert: { - title: 'title', - subtitle: 'subtitle', - body: 'body', - titleLocKey: 'title.loc.key', - titleLocArgs: ['arg1', 'arg2'], - subtitleLocKey: 'subtitle.loc.key', - subtitleLocArgs: ['arg1', 'arg2'], - locKey: 'body.loc.key', - locArgs: ['arg1', 'arg2'], - actionLocKey: 'action.loc.key', - launchImage: 'image', + { + label: 'All Webpush properties', + req: { + webpush: { + headers: { + h1: 'v1', + h2: 'v2', + }, + data: { + k1: 'v1', + k2: 'v2', + }, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + actions: [{ + action: 'test.action.1', + title: 'test.action.1.title', + icon: 'test.action.1.icon', + }, { + action: 'test.action.2', + title: 'test.action.2.title', + icon: 'test.action.2.icon', + }], + badge: 'test.badge', + data: { + key: 'value', }, - badge: 42, - sound: 'test.sound', - category: 'test.category', - contentAvailable: true, - mutableContent: true, - threadId: 'thread.id', + dir: 'auto', + image: 'test.image', + requireInteraction: true, + }, + fcmOptions: { + link: 'https://example.com', }, - customKey1: 'custom.value', - customKey2: { nested: 'value' }, - }, - fcmOptions: { - analyticsLabel: 'test.analytics', - imageUrl: 'https://example.com/image.png', }, }, }, - expectedReq: { - apns: { - headers: { - h1: 'v1', - h2: 'v2', + { + label: 'APNS headers only', + req: { + apns: { + headers: { + k1: 'v1', + k2: 'v2', + }, }, - payload: { - aps: { - 'alert': { - 'title': 'title', - 'subtitle': 'subtitle', - 'body': 'body', - 'title-loc-key': 'title.loc.key', - 'title-loc-args': ['arg1', 'arg2'], - 'subtitle-loc-key': 'subtitle.loc.key', - 'subtitle-loc-args': ['arg1', 'arg2'], - 'loc-key': 'body.loc.key', - 'loc-args': ['arg1', 'arg2'], - 'action-loc-key': 'action.loc.key', - 'launch-image': 'image', + }, + }, + { + label: 'APNS string alert', + req: { + apns: { + payload: { + aps: { + alert: 'test.alert', }, - 'badge': 42, - 'sound': 'test.sound', - 'category': 'test.category', - 'content-available': 1, - 'mutable-content': 1, - 'thread-id': 'thread.id', }, - customKey1: 'custom.value', - customKey2: { nested: 'value' }, - }, - fcmOptions: { - analyticsLabel: 'test.analytics', - image: 'https://example.com/image.png', }, }, }, - }, - { - label: 'APNS critical sound', - req: { - apns: { - payload: { - aps: { - sound: { - critical: true, - name: 'test.sound', - volume: 0.5, + { + label: 'All APNS properties', + req: { + apns: { + headers: { + h1: 'v1', + h2: 'v2', + }, + payload: { + aps: { + alert: { + title: 'title', + subtitle: 'subtitle', + body: 'body', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + subtitleLocKey: 'subtitle.loc.key', + subtitleLocArgs: ['arg1', 'arg2'], + locKey: 'body.loc.key', + locArgs: ['arg1', 'arg2'], + actionLocKey: 'action.loc.key', + launchImage: 'image', + }, + badge: 42, + sound: 'test.sound', + category: 'test.category', + contentAvailable: true, + mutableContent: true, + threadId: 'thread.id', }, + customKey1: 'custom.value', + customKey2: { nested: 'value' }, + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + imageUrl: 'https://example.com/image.png', }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: { - sound: { - critical: 1, - name: 'test.sound', - volume: 0.5, + expectedReq: { + apns: { + headers: { + h1: 'v1', + h2: 'v2', + }, + payload: { + aps: { + 'alert': { + 'title': 'title', + 'subtitle': 'subtitle', + 'body': 'body', + 'title-loc-key': 'title.loc.key', + 'title-loc-args': ['arg1', 'arg2'], + 'subtitle-loc-key': 'subtitle.loc.key', + 'subtitle-loc-args': ['arg1', 'arg2'], + 'loc-key': 'body.loc.key', + 'loc-args': ['arg1', 'arg2'], + 'action-loc-key': 'action.loc.key', + 'launch-image': 'image', + }, + 'badge': 42, + 'sound': 'test.sound', + 'category': 'test.category', + 'content-available': 1, + 'mutable-content': 1, + 'thread-id': 'thread.id', }, + customKey1: 'custom.value', + customKey2: { nested: 'value' }, + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + image: 'https://example.com/image.png', }, }, }, }, - }, - { - label: 'APNS critical sound name only', - req: { - apns: { - payload: { - aps: { - sound: { - name: 'test.sound', + { + label: 'APNS critical sound', + req: { + apns: { + payload: { + aps: { + sound: { + critical: true, + name: 'test.sound', + volume: 0.5, + }, }, }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: { - sound: { - name: 'test.sound', + expectedReq: { + apns: { + payload: { + aps: { + sound: { + critical: 1, + name: 'test.sound', + volume: 0.5, + }, }, }, }, }, }, - }, - { - label: 'APNS critical sound explicitly false', - req: { - apns: { - payload: { - aps: { - sound: { - critical: false, - name: 'test.sound', - volume: 0.5, + { + label: 'APNS critical sound name only', + req: { + apns: { + payload: { + aps: { + sound: { + name: 'test.sound', + }, }, }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: { - sound: { - name: 'test.sound', - volume: 0.5, + expectedReq: { + apns: { + payload: { + aps: { + sound: { + name: 'test.sound', + }, }, }, }, }, }, - }, - { - label: 'APNS contentAvailable explicitly false', - req: { - apns: { - payload: { - aps: { - contentAvailable: false, + { + label: 'APNS critical sound explicitly false', + req: { + apns: { + payload: { + aps: { + sound: { + critical: false, + name: 'test.sound', + volume: 0.5, + }, + }, }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: {}, + expectedReq: { + apns: { + payload: { + aps: { + sound: { + name: 'test.sound', + volume: 0.5, + }, + }, + }, }, }, }, - }, - { - label: 'APNS content-available set explicitly', - req: { - apns: { - payload: { - aps: { - 'content-available': 1, + { + label: 'APNS contentAvailable explicitly false', + req: { + apns: { + payload: { + aps: { + contentAvailable: false, + }, }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: { 'content-available': 1 }, + expectedReq: { + apns: { + payload: { + aps: {}, + }, }, }, }, - }, - { - label: 'APNS mutableContent explicitly false', - req: { - apns: { - payload: { - aps: { - mutableContent: false, + { + label: 'APNS content-available set explicitly', + req: { + apns: { + payload: { + aps: { + 'content-available': 1, + }, }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: {}, + expectedReq: { + apns: { + payload: { + aps: { 'content-available': 1 }, + }, }, }, }, - }, - { - label: 'APNS custom fields', - req: { - apns: { - payload: { - aps: { - k1: 'v1', - k2: true, + { + label: 'APNS mutableContent explicitly false', + req: { + apns: { + payload: { + aps: { + mutableContent: false, + }, }, }, }, - }, - expectedReq: { - apns: { - payload: { - aps: { - k1: 'v1', - k2: true, + expectedReq: { + apns: { + payload: { + aps: {}, }, }, }, }, - }, - { - label: 'APNS Start LiveActivity', - req: { - apns: { - liveActivityToken: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'start', - 'content-state': { - 'demo': 1 - }, - 'attributes-type': 'DemoAttributes', - 'attributes': { - 'demoAttribute': 1, + { + label: 'APNS custom fields', + req: { + apns: { + payload: { + aps: { + k1: 'v1', + k2: true, }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } }, }, }, - }, - expectedReq: { - apns: { - live_activity_token: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'start', - 'content-state': { - 'demo': 1 - }, - 'attributes-type': 'DemoAttributes', - 'attributes': { - 'demoAttribute': 1, + expectedReq: { + apns: { + payload: { + aps: { + k1: 'v1', + k2: true, }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } }, }, }, }, - }, - { - label: 'APNS Update LiveActivity', - req: { - apns: { - liveActivityToken: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'update', - 'content-state': { - 'test1': 100, - 'test2': 'demo' + { + label: 'APNS Start LiveActivity', + req: { + apns: { + liveActivityToken: 'live-activity-token', + headers: { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'start', + 'content-state': { + 'demo': 1 + }, + 'attributes-type': 'DemoAttributes', + 'attributes': { + 'demoAttribute': 1, + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } }, }, }, - }, - expectedReq: { - apns: { - live_activity_token: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'update', - 'content-state': { - 'test1': 100, - 'test2': 'demo' + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + headers: { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'start', + 'content-state': { + 'demo': 1 + }, + 'attributes-type': 'DemoAttributes', + 'attributes': { + 'demoAttribute': 1, + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } }, }, }, }, - }, - { - label: 'APNS End LiveActivity', - req: { - apns: { - liveActivityToken: 'live-activity-token', - 'headers': { - 'apns-priority': '10' + { + label: 'APNS Update LiveActivity', + req: { + apns: { + liveActivityToken: 'live-activity-token', + headers: { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'update', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, }, - payload: { - aps: { - timestamp: 1746475860808, - 'dismissal-date': 1746475860808 + 60, - event: 'end', - 'content-state': { - 'test1': 100, - 'test2': 'demo' + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + headers: { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'update', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } }, }, }, }, - expectedReq: { - apns: { - live_activity_token: 'live-activity-token', - 'headers': { - 'apns-priority': '10' + { + label: 'APNS End LiveActivity', + req: { + apns: { + liveActivityToken: 'live-activity-token', + 'headers': { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + 'dismissal-date': 1746475860808 + 60, + event: 'end', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } + }, + }, }, - payload: { - aps: { - timestamp: 1746475860808, - 'dismissal-date': 1746475860808 + 60, - event: 'end', - 'content-state': { - 'test1': 100, - 'test2': 'demo' + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + 'headers': { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + 'dismissal-date': 1746475860808 + 60, + event: 'end', + 'content-state': { + 'test1': 100, + 'test2': 'demo' + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } }, }, }, }, - }, - ]; + ]; validMessages.forEach((config) => { it(`should serialize well-formed Message: ${config.label}`, () => { @@ -2843,10 +2849,10 @@ describe('Messaging', () => { invalidRegistrationTokens.forEach((invalidRegistrationToken) => { it('should throw given invalid type for registration token(s) argument: ' + JSON.stringify(invalidRegistrationToken), () => { - expect(() => { - messagingService[methodName](invalidRegistrationToken as string, mocks.messaging.topic); - }).to.throw(invalidRegistrationTokensArgumentError); - }); + expect(() => { + messagingService[methodName](invalidRegistrationToken as string, mocks.messaging.topic); + }).to.throw(invalidRegistrationTokensArgumentError); + }); }); it('should throw given no registration token(s) argument', () => { @@ -3004,225 +3010,209 @@ describe('Messaging', () => { it('should be fulfilled given a valid registration token and topic (topic name not prefixed ' + 'with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ); - }); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ); + }); it('should be fulfilled given a valid registration token and topic (topic name prefixed ' + 'with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ); - }); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ); + }); it('should be fulfilled given a valid array of registration tokens and topic (topic name not ' + 'prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topic, - ); - }); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topic, + ); + }); it('should be fulfilled given a valid array of registration tokens and topic (topic name ' + 'prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topicWithPrefix, - ); - }); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topicWithPrefix, + ); + }); it('should be fulfilled with the server response given a single registration token and topic ' + '(topic name not prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 1, - errors: [], + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).should.eventually.deep.equal({ + failureCount: 0, + successCount: 1, + errors: [], + }); }); - }); it('should be fulfilled with the server response given a single registration token and topic ' + '(topic name prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 1, - errors: [], + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ).should.eventually.deep.equal({ + failureCount: 0, + successCount: 1, + errors: [], + }); }); - }); it('should be fulfilled with the server response given an array of registration tokens ' + 'and topic (topic name not prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topic, - ).then((response: MessagingTopicManagementResponse) => { - expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); - expect(response.failureCount).to.equal(2); - expect(response.successCount).to.equal(1); - expect(response.errors).to.have.length(2); - expect(response.errors[0]).to.have.keys(['index', 'error']); - expect(response.errors[0].index).to.equal(1); - expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); - expect(response.errors[1]).to.have.keys(['index', 'error']); - expect(response.errors[1].index).to.equal(2); - expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); + expect(response.failureCount).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.errors).to.have.length(2); + expect(response.errors[0]).to.have.keys(['index', 'error']); + expect(response.errors[0].index).to.equal(1); + expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); + expect(response.errors[1]).to.have.keys(['index', 'error']); + expect(response.errors[1].index).to.equal(2); + expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); + }); }); - }); it('should be fulfilled with the server response given an array of registration tokens ' + 'and topic (topic name prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topicWithPrefix, - ).then((response: MessagingTopicManagementResponse) => { - expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); - expect(response.failureCount).to.equal(2); - expect(response.successCount).to.equal(1); - expect(response.errors).to.have.length(2); - expect(response.errors[0]).to.have.keys(['index', 'error']); - expect(response.errors[0].index).to.equal(1); - expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); - expect(response.errors[1]).to.have.keys(['index', 'error']); - expect(response.errors[1].index).to.equal(2); - expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topicWithPrefix, + ).then((response: MessagingTopicManagementResponse) => { + expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); + expect(response.failureCount).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.errors).to.have.length(2); + expect(response.errors[0]).to.have.keys(['index', 'error']); + expect(response.errors[0].index).to.equal(1); + expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); + expect(response.errors[1]).to.have.keys(['index', 'error']); + expect(response.errors[1].index).to.equal(2); + expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); + }); }); - }); it('should set the appropriate request data given a single registration token and topic ' + '(topic name not prefixed with "/topics/")', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: [mocks.messaging.registrationToken], - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); + mockTopicSubscriptionRequest(methodName, 1); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(1); + const req = http2Mocker.requests[0]; + const isSub = methodName === 'subscribeToTopic'; + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + expect(req.headers[':path']).to.contain(expectedSuffix); }); - }); + }); it('should set the appropriate request data given a single registration token and topic ' + '(topic name prefixed with "/topics/")', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: [mocks.messaging.registrationToken], - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); + mockTopicSubscriptionRequest(methodName, 1); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(1); + const req = http2Mocker.requests[0]; + const isSub = methodName === 'subscribeToTopic'; + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + expect(req.headers[':path']).to.contain(expectedSuffix); }); - }); + }); it('should set the appropriate request data given an array of registration tokens and ' + 'topic (topic name not prefixed with "/topics/")', () => { - const registrationTokens = [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ]; + const registrationTokens = [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ]; - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - registrationTokens, - mocks.messaging.topic, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: registrationTokens, - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); + mockTopicSubscriptionRequest(methodName, 3); + return messagingService[methodName]( + registrationTokens, + mocks.messaging.topic, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(3); + const isSub = methodName === 'subscribeToTopic'; + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + http2Mocker.requests.forEach((req, idx) => { + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + expect(req.headers[':path']).to.contain(registrationTokens[idx]); + expect(req.headers[':path']).to.contain(expectedSuffix); + }); }); - }); + }); it('should set the appropriate request data given an array of registration tokens and ' + 'topic (topic name prefixed with "/topics/")', () => { - const registrationTokens = [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ]; + const registrationTokens = [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ]; - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - registrationTokens, - mocks.messaging.topicWithPrefix, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: registrationTokens, - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); + mockTopicSubscriptionRequest(methodName, 3); + return messagingService[methodName]( + registrationTokens, + mocks.messaging.topicWithPrefix, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(3); + const isSub = methodName === 'subscribeToTopic'; + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + http2Mocker.requests.forEach((req, idx) => { + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + expect(req.headers[':path']).to.contain(registrationTokens[idx]); + expect(req.headers[':path']).to.contain(expectedSuffix); + }); }); - }); + }); } describe('subscribeToTopic()', () => { From 83f1db7429567b7fa8b41158c7f3f90410609b39 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 10 Jul 2026 14:27:52 -0400 Subject: [PATCH 2/2] added legacy methods --- etc/firebase-admin.messaging.api.md | 4 + src/messaging/messaging.ts | 248 +++- test/unit/messaging/messaging.spec.ts | 1834 ++++++++++++++----------- 3 files changed, 1208 insertions(+), 878 deletions(-) diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index 9de4e09d13..470b4d5419 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -206,7 +206,11 @@ export class Messaging { sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise; sendEachForMulticast(message: FidMulticastMessage, dryRun?: boolean): Promise; subscribeToTopic(registrationTokenOrTokens: string | string[], topic: string): Promise; + // @deprecated + subscribeToTopicLegacy(registrationTokenOrTokens: string | string[], topic: string): Promise; unsubscribeFromTopic(registrationTokenOrTokens: string | string[], topic: string): Promise; + // @deprecated + unsubscribeFromTopicLegacy(registrationTokenOrTokens: string | string[], topic: string): Promise; } // @public diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 7efc60e247..c3a5399150 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -41,10 +41,49 @@ import { Http2SessionHandler, RequestResponseError } from '../utils/api-request' // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; +const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; +const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; +const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; // Maximum messages that can be included in a batch request. const FCM_MAX_BATCH_SIZE = 500; +/** + * Maps a raw FCM server response to a `MessagingTopicManagementResponse` object. + * + * @param response - The raw FCM server response to map. + * + * @returns The mapped `MessagingTopicManagementResponse` object. + */ +function mapRawResponseToTopicManagementResponse(response: object): MessagingTopicManagementResponse { + // Add the success and failure counts. + const result: MessagingTopicManagementResponse = { + successCount: 0, + failureCount: 0, + errors: [], + }; + + if ('results' in response) { + (response as any).results.forEach((tokenManagementResult: any, index: number) => { + // Map the FCM server's error strings to actual error objects. + if ('error' in tokenManagementResult) { + result.failureCount += 1; + const newError = FirebaseMessagingError.fromTopicManagementServerError( + tokenManagementResult.error, /* message */ undefined, tokenManagementResult.error, + ); + + result.errors.push({ + index, + error: newError, + }); + } else { + result.successCount += 1; + } + }); + } + return result; +} + /** * Messaging service bound to the provided app. @@ -341,7 +380,6 @@ export class Messaging { registrationTokenOrTokens, topic, 'subscribeToTopic', - '', ); } @@ -368,7 +406,56 @@ export class Messaging { registrationTokenOrTokens, topic, 'unsubscribeFromTopic', - '', + ); + } + + /** + * Subscribes a device or list of devices to an FCM topic using the legacy Instance ID API. + * Served as a backup/legacy endpoint. + * + * @param registrationTokenOrTokens - A token or array of registration tokens + * for the devices to subscribe to the topic. + * @param topic - The topic to which to subscribe. + * + * @returns A promise fulfilled with the server's response after the device has been + * subscribed to the topic. + * + * @deprecated Use {@link Messaging.subscribeToTopic} instead. + */ + public subscribeToTopicLegacy( + registrationTokenOrTokens: string | string[], + topic: string, + ): Promise { + return this.sendTopicManagementRequestLegacy( + registrationTokenOrTokens, + topic, + 'subscribeToTopicLegacy', + FCM_TOPIC_MANAGEMENT_ADD_PATH, + ); + } + + /** + * Unsubscribes a device or list of devices from an FCM topic using the legacy Instance ID API. + * Served as a backup/legacy endpoint. + * + * @param registrationTokenOrTokens - A device registration token or an array of + * device registration tokens to unsubscribe from the topic. + * @param topic - The topic from which to unsubscribe. + * + * @returns A promise fulfilled with the server's response after the device has been + * unsubscribed from the topic. + * + * @deprecated Use {@link Messaging.unsubscribeFromTopic} instead. + */ + public unsubscribeFromTopicLegacy( + registrationTokenOrTokens: string | string[], + topic: string, + ): Promise { + return this.sendTopicManagementRequestLegacy( + registrationTokenOrTokens, + topic, + 'unsubscribeFromTopicLegacy', + FCM_TOPIC_MANAGEMENT_REMOVE_PATH, ); } @@ -401,7 +488,6 @@ export class Messaging { * registration tokens to unsubscribe from the topic. * @param topic - The topic to which to subscribe. * @param methodName - The name of the original method called. - * @param path - The endpoint path to use for the request. * * @returns A Promise fulfilled with the parsed server * response. @@ -410,7 +496,6 @@ export class Messaging { registrationTokenOrTokens: string | string[], topic: string, methodName: string, - _path: string, ): Promise { this.validateRegistrationTokensType(registrationTokenOrTokens, methodName); this.validateTopicType(topic, methodName); @@ -434,7 +519,7 @@ export class Messaging { return utils.findProjectId(this.app).then((projectId) => { if (!validator.isNonEmptyString(projectId)) { throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_ARGUMENT, + messagingClientErrorCode.INVALID_ARGUMENT, 'Failed to determine project ID for Messaging. Initialize the ' + 'SDK with service account credentials or set project ID as an app option. ' + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', @@ -447,68 +532,59 @@ export class Messaging { const http2SessionHandler = new Http2SessionHandler('https://fcm.googleapis.com'); - let settledPromise: Promise[]>; - return new Promise((resolve, reject) => { - http2SessionHandler.invoke().catch((error) => { - reject(new FirebaseMessagingSessionError(error, undefined, undefined)); - }); + const requests = registrationTokensArray.map((registrationId) => { + let requestPath = `/v1/projects/${projectId}/registrations/${registrationId}/topicSubscriptions`; + if (isSubscribe) { + requestPath += `?topic_name=${topicName}`; + } else { + requestPath += `/${topicName}?allow_missing=true`; + } + return this.messagingRequestHandler.invokeHttp2RequestHandler( + 'fcm.googleapis.com', + requestPath, + httpMethod, + isSubscribe ? {} : undefined, + http2SessionHandler + ); + }); - const requests = registrationTokensArray.map(async (registrationId) => { - let requestPath = `/v1/projects/${projectId}/registrations/${registrationId}/topicSubscriptions`; - if (isSubscribe) { - requestPath += `?topic_name=${topicName}`; + return Promise.allSettled(requests).then((results) => { + if (results.length > 0 && results.every((r) => r.status === 'rejected')) { + const firstReason = (results[0] as PromiseRejectedResult).reason; + if (firstReason instanceof RequestResponseError) { + throw createFirebaseError(firstReason); } else { - requestPath += `/${topicName}?allow_missing=true`; + throw firstReason; } - return this.messagingRequestHandler.invokeHttp2RequestHandler( - 'fcm.googleapis.com', - requestPath, - httpMethod, - isSubscribe ? {} : undefined, - http2SessionHandler - ); - }); + } - settledPromise = Promise.allSettled(requests); - settledPromise.then((results) => { - if (results.length > 0 && results.every((r) => r.status === 'rejected')) { - const firstReason = (results[0] as PromiseRejectedResult).reason; - if (firstReason instanceof RequestResponseError) { - reject(createFirebaseError(firstReason)); - } else { - reject(firstReason); - } - return; - } + const response: MessagingTopicManagementResponse = { + successCount: 0, + failureCount: 0, + errors: [], + }; - const response: MessagingTopicManagementResponse = { - successCount: 0, - failureCount: 0, - errors: [], - }; - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - response.successCount += 1; - } else { - response.failureCount += 1; - const err = result.reason; - const errorCode = err.response?.isJson() ? getErrorCode(err.response.data) : null; - const errorMessage = err.response?.isJson() ? err.response.data?.error?.message : err.message; - const newError = FirebaseMessagingError.fromTopicManagementServerError( - errorCode || 'UNKNOWN', - errorMessage, - err.response?.isJson() ? err.response.data : undefined - ); - response.errors.push({ - index, - error: newError, - }); - } - }); - - resolve(response); + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + response.successCount += 1; + } else { + response.failureCount += 1; + const err = (result as PromiseRejectedResult).reason; + const errorCode = err.response?.isJson() ? getErrorCode(err.response.data) : null; + const errorMessage = err.response?.isJson() ? err.response.data?.error?.message : err.message; + const newError = FirebaseMessagingError.fromTopicManagementServerError( + errorCode || 'UNKNOWN', + errorMessage, + err.response?.isJson() ? err.response.data : undefined + ); + response.errors.push({ + index, + error: newError, + }); + } }); + + return response; }).finally(() => { http2SessionHandler.close(); }); @@ -516,6 +592,56 @@ export class Messaging { }); } + /** + * Helper method which sends and handles topic subscription management requests using the legacy Instance ID API. + * + * @param registrationTokenOrTokens - The registration token or an array of + * registration tokens to unsubscribe from the topic. + * @param topic - The topic to which to subscribe. + * @param methodName - The name of the original method called. + * @param path - The endpoint path to use for the request. + * + * @returns A Promise fulfilled with the parsed server response. + */ + private sendTopicManagementRequestLegacy( + registrationTokenOrTokens: string | string[], + topic: string, + methodName: string, + path: string, + ): Promise { + this.validateRegistrationTokensType(registrationTokenOrTokens, methodName); + this.validateTopicType(topic, methodName); + + // Prepend the topic with /topics/ if necessary. + topic = this.normalizeTopic(topic); + + return Promise.resolve() + .then(() => { + // Validate the contents of the input arguments. Because we are now in a promise, any thrown + // error will cause this method to return a rejected promise. + this.validateRegistrationTokens(registrationTokenOrTokens, methodName); + this.validateTopic(topic, methodName); + + // Ensure the registration token(s) input argument is an array. + let registrationTokensArray: string[] = registrationTokenOrTokens as string[]; + if (validator.isString(registrationTokenOrTokens)) { + registrationTokensArray = [registrationTokenOrTokens as string]; + } + + const request = { + to: topic, + registration_tokens: registrationTokensArray, + }; + + return this.messagingRequestHandler.invokeRequestHandler( + FCM_TOPIC_MANAGEMENT_HOST, path, request, + ); + }) + .then((response) => { + return mapRawResponseToTopicManagementResponse(response); + }); + } + /** * Validates the type of the provided registration token(s). If invalid, an error will be thrown. * diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index 04824661c8..35da77f278 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -44,6 +44,9 @@ const expect = chai.expect; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; +const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; +const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; +const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; const mockServerErrorResponse = { json: { @@ -264,6 +267,57 @@ describe('Messaging', () => { return { done: () => { } } as any; } + function mockTopicSubscriptionLegacyRequest( + methodName: string, + successCount = 1, + failureCount = 0, + ): nock.Scope { + const mockedResults = []; + + for (let i = 0; i < successCount; i++) { + mockedResults.push({}); + } + + for (let i = 0; i < failureCount; i++) { + mockedResults.push({ error: 'TOO_MANY_TOPICS' }); + } + + const path = (methodName === 'subscribeToTopicLegacy') ? + FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; + + return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) + .post(path) + .reply(200, { + results: mockedResults, + }); + } + + function mockTopicSubscriptionLegacyRequestWithError( + methodName: string, + statusCode: number, + errorFormat: 'json' | 'text', + responseOverride?: any, + ): nock.Scope { + let response; + let contentType; + if (errorFormat === 'json') { + response = responseOverride || mockServerErrorResponse.json; + contentType = 'application/json; charset=UTF-8'; + } else { + response = responseOverride || mockServerErrorResponse.text; + contentType = 'text/html; charset=UTF-8'; + } + + const path = (methodName === 'subscribeToTopicLegacy') ? + FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; + + return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) + .post(path) + .reply(statusCode, response, { + 'Content-Type': contentType, + }); + } + describe('Constructor', () => { const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; invalidApps.forEach((invalidApp) => { @@ -673,14 +727,14 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens', () => { - return nullAccessTokenMessaging.sendEach( - [validMessage, validMessage], - ).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); - }); + return nullAccessTokenMessaging.sendEach( + [validMessage, validMessage], + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); }); + }); it('should expose the FCM error code in a detailed error via BatchResponse', () => { const messageIds = [ @@ -881,13 +935,13 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens using HTTP/2', () => { - return nullAccessTokenMessaging.sendEach( - [validMessage, validMessage], false).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); - }); + return nullAccessTokenMessaging.sendEach( + [validMessage, validMessage], false).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); }); + }); it('should expose the FCM error code in a detailed error via BatchResponse using HTTP/2', () => { const messageIds = [ @@ -984,40 +1038,40 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse containing AggregateError when multiple session errors occur' + ' using HTTP/2', () => { - const sessionError1 = 'MOCK_SESSION_ERROR_1'; - const sessionError2 = 'MOCK_SESSION_ERROR_2'; - - mockedHttp2Responses.push(mockHttp2Error( - new Error('MOCK_STREAM_ERROR_1'), - new Error(sessionError1) - )); - mockedHttp2Responses.push(mockHttp2Error( - new Error('MOCK_STREAM_ERROR_2'), - new Error(sessionError2) - )); - - http2Mocker.http2Stub(mockedHttp2Responses); - - return messaging.sendEach( - [validMessage, validMessage], true - ).then((response: BatchResponse) => { - expect(http2Mocker.requests.length).to.equal(2); - expect(response.failureCount).to.equal(2); - - const failure = response.responses[0]; - expect(failure.success).to.be.false; - expect(failure.error!.code).to.equal('messaging/unknown-error'); - - const cause = failure.error!.cause; - expect(cause).to.not.be.undefined; - expect(cause!.constructor.name).to.equal('AggregateError'); - expect((cause as any).errors).to.be.an.instanceOf(Array); - expect((cause as any).errors.length).to.equal(3); - expect((cause as any).errors[0].message).to.contain('MOCK_STREAM_ERROR'); - expect((cause as any).errors[1].message).to.contain(sessionError1); - expect((cause as any).errors[2].message).to.contain(sessionError2); - }); + const sessionError1 = 'MOCK_SESSION_ERROR_1'; + const sessionError2 = 'MOCK_SESSION_ERROR_2'; + + mockedHttp2Responses.push(mockHttp2Error( + new Error('MOCK_STREAM_ERROR_1'), + new Error(sessionError1) + )); + mockedHttp2Responses.push(mockHttp2Error( + new Error('MOCK_STREAM_ERROR_2'), + new Error(sessionError2) + )); + + http2Mocker.http2Stub(mockedHttp2Responses); + + return messaging.sendEach( + [validMessage, validMessage], true + ).then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + expect(response.failureCount).to.equal(2); + + const failure = response.responses[0]; + expect(failure.success).to.be.false; + expect(failure.error!.code).to.equal('messaging/unknown-error'); + + const cause = failure.error!.cause; + expect(cause).to.not.be.undefined; + expect(cause!.constructor.name).to.equal('AggregateError'); + expect((cause as any).errors).to.be.an.instanceOf(Array); + expect((cause as any).errors.length).to.equal(3); + expect((cause as any).errors[0].message).to.contain('MOCK_STREAM_ERROR'); + expect((cause as any).errors[1].message).to.contain(sessionError1); + expect((cause as any).errors[2].message).to.contain(sessionError2); }); + }); // This test was added to also verify https://github.com/firebase/firebase-admin-node/issues/1146 it('should be fulfilled when called with different message types using HTTP/2', () => { @@ -1416,14 +1470,14 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens', () => { - return nullAccessTokenMessaging.sendEachForMulticast( - { tokens: ['a', 'a'] }, - ).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); - }); + return nullAccessTokenMessaging.sendEachForMulticast( + { tokens: ['a', 'a'] }, + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); }); + }); it('should expose the FCM error code in a detailed error via BatchResponse', () => { const messageIds = [ @@ -1562,14 +1616,14 @@ describe('Messaging', () => { it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens using HTTP/2', () => { - return nullAccessTokenMessaging.sendEachForMulticast( - { tokens: ['a', 'a'] }, false - ).then((response: BatchResponse) => { - expect(response.failureCount).to.equal(2); - response.responses.forEach(resp => checkSendResponseFailure( - resp, 'app/invalid-credential')); - }); + return nullAccessTokenMessaging.sendEachForMulticast( + { tokens: ['a', 'a'] }, false + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); }); + }); it('should expose the FCM error code in a detailed error via BatchResponse using HTTP/2', () => { const messageIds = [ @@ -2047,757 +2101,757 @@ describe('Messaging', () => { req: any; expectedReq?: any; }> = [ - { - label: 'Generic data message', - req: { + { + label: 'Generic data message', + req: { + data: { + k1: 'v1', + k2: 'v2', + }, + }, + }, + { + label: 'Generic notification message', + req: { + notification: { + title: 'test.title', + body: 'test.body', + imageUrl: 'https://example.com/image.png', + }, + }, + expectedReq: { + notification: { + title: 'test.title', + body: 'test.body', + image: 'https://example.com/image.png', + }, + }, + }, + { + label: 'Generic fcmOptions message', + req: { + fcmOptions: { + analyticsLabel: 'test.analytics', + }, + }, + }, + { + label: 'Android data message', + req: { + android: { data: { k1: 'v1', k2: 'v2', }, }, }, - { - label: 'Generic notification message', - req: { + }, + { + label: 'Android notification message', + req: { + android: { notification: { title: 'test.title', body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', imageUrl: 'https://example.com/image.png', + ticker: 'test.ticker', + sticky: true, + visibility: 'private', + proxy: 'deny', }, }, - expectedReq: { + }, + expectedReq: { + android: { notification: { title: 'test.title', body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', image: 'https://example.com/image.png', + ticker: 'test.ticker', + sticky: true, + visibility: 'PRIVATE', + proxy: 'DENY' }, }, }, - { - label: 'Generic fcmOptions message', - req: { - fcmOptions: { - analyticsLabel: 'test.analytics', + }, + { + label: 'Android camel cased properties', + req: { + android: { + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + directBootOk: true, + bandwidthConstrainedOk: true, + restrictedSatelliteOk: true, + notification: { + clickAction: 'test.click.action', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + bodyLocKey: 'body.loc.key', + bodyLocArgs: ['arg1', 'arg2'], + channelId: 'test.channel', + eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), + localOnly: true, + priority: 'high', + vibrateTimingsMillis: [100, 50, 250], + defaultVibrateTimings: false, + defaultSound: true, + lightSettings: { + color: '#AABBCCDD', + lightOnDurationMillis: 200, + lightOffDurationMillis: 300, + }, + defaultLightSettings: false, + notificationCount: 1, }, }, }, - { - label: 'Android data message', - req: { - android: { - data: { - k1: 'v1', - k2: 'v2', + expectedReq: { + android: { + collapse_key: 'test.key', + restricted_package_name: 'test.package', + direct_boot_ok: true, + bandwidth_constrained_ok: true, + restricted_satellite_ok: true, + notification: { + click_action: 'test.click.action', + title_loc_key: 'title.loc.key', + title_loc_args: ['arg1', 'arg2'], + body_loc_key: 'body.loc.key', + body_loc_args: ['arg1', 'arg2'], + channel_id: 'test.channel', + event_time: '2019-10-20T18:30:00.000Z', + local_only: true, + notification_priority: 'PRIORITY_HIGH', + vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], + default_vibrate_timings: false, + default_sound: true, + light_settings: { + color: { + red: 0.6666666666666666, + green: 0.7333333333333333, + blue: 0.8, + alpha: 0.8666666666666667, + }, + light_on_duration: '0.200000000s', + light_off_duration: '0.300000000s', }, + default_light_settings: false, + notification_count: 1, }, }, }, - { - label: 'Android notification message', - req: { - android: { - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', - imageUrl: 'https://example.com/image.png', - ticker: 'test.ticker', - sticky: true, - visibility: 'private', - proxy: 'deny', - }, - }, + }, + { + label: 'Android TTL', + req: { + android: { + priority: 'high', + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + ttl: 5000, }, - expectedReq: { - android: { - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', - image: 'https://example.com/image.png', - ticker: 'test.ticker', - sticky: true, - visibility: 'PRIVATE', - proxy: 'DENY' - }, - }, + }, + expectedReq: { + android: { + priority: 'high', + collapse_key: 'test.key', + restricted_package_name: 'test.package', + ttl: '5s', }, }, - { - label: 'Android camel cased properties', - req: { - android: { - collapseKey: 'test.key', - restrictedPackageName: 'test.package', - directBootOk: true, - bandwidthConstrainedOk: true, - restrictedSatelliteOk: true, - notification: { - clickAction: 'test.click.action', - titleLocKey: 'title.loc.key', - titleLocArgs: ['arg1', 'arg2'], - bodyLocKey: 'body.loc.key', - bodyLocArgs: ['arg1', 'arg2'], - channelId: 'test.channel', - eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), - localOnly: true, - priority: 'high', - vibrateTimingsMillis: [100, 50, 250], - defaultVibrateTimings: false, - defaultSound: true, - lightSettings: { - color: '#AABBCCDD', - lightOnDurationMillis: 200, - lightOffDurationMillis: 300, - }, - defaultLightSettings: false, - notificationCount: 1, + }, + { + label: 'All Android properties', + req: { + android: { + priority: 'high', + collapseKey: 'test.key', + restrictedPackageName: 'test.package', + directBootOk: true, + bandwidthConstrainedOk: true, + restrictedSatelliteOk: true, + ttl: 5, + data: { + k1: 'v1', + k2: 'v2', + }, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + imageUrl: 'https://example.com/image.png', + clickAction: 'test.click.action', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + bodyLocKey: 'body.loc.key', + bodyLocArgs: ['arg1', 'arg2'], + channelId: 'test.channel', + ticker: 'test.ticker', + sticky: true, + visibility: 'private', + eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), + localOnly: true, + priority: 'high', + vibrateTimingsMillis: [100, 50, 250], + defaultVibrateTimings: false, + defaultSound: true, + lightSettings: { + color: '#AABBCC', + lightOnDurationMillis: 200, + lightOffDurationMillis: 300, }, + defaultLightSettings: false, + notificationCount: 1, + proxy: 'if_priority_lowered', + }, + fcmOptions: { + analyticsLabel: 'test.analytics', }, }, - expectedReq: { - android: { - collapse_key: 'test.key', - restricted_package_name: 'test.package', - direct_boot_ok: true, - bandwidth_constrained_ok: true, - restricted_satellite_ok: true, - notification: { - click_action: 'test.click.action', - title_loc_key: 'title.loc.key', - title_loc_args: ['arg1', 'arg2'], - body_loc_key: 'body.loc.key', - body_loc_args: ['arg1', 'arg2'], - channel_id: 'test.channel', - event_time: '2019-10-20T18:30:00.000Z', - local_only: true, - notification_priority: 'PRIORITY_HIGH', - vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], - default_vibrate_timings: false, - default_sound: true, - light_settings: { - color: { - red: 0.6666666666666666, - green: 0.7333333333333333, - blue: 0.8, - alpha: 0.8666666666666667, - }, - light_on_duration: '0.200000000s', - light_off_duration: '0.300000000s', + }, + expectedReq: { + android: { + priority: 'high', + collapse_key: 'test.key', + restricted_package_name: 'test.package', + direct_boot_ok: true, + bandwidth_constrained_ok: true, + restricted_satellite_ok: true, + ttl: '0.005000000s', // 5 ms = 5,000,000 ns + data: { + k1: 'v1', + k2: 'v2', + }, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + color: '#112233', + sound: 'test.sound', + tag: 'test.tag', + image: 'https://example.com/image.png', + click_action: 'test.click.action', + title_loc_key: 'title.loc.key', + title_loc_args: ['arg1', 'arg2'], + body_loc_key: 'body.loc.key', + body_loc_args: ['arg1', 'arg2'], + channel_id: 'test.channel', + ticker: 'test.ticker', + sticky: true, + visibility: 'PRIVATE', + event_time: '2019-10-20T18:30:00.000Z', + local_only: true, + notification_priority: 'PRIORITY_HIGH', + vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], + default_vibrate_timings: false, + default_sound: true, + light_settings: { + color: { + red: 0.6666666666666666, + green: 0.7333333333333333, + blue: 0.8, + alpha: 1, }, - default_light_settings: false, - notification_count: 1, + light_on_duration: '0.200000000s', + light_off_duration: '0.300000000s', }, + default_light_settings: false, + notification_count: 1, + proxy: 'IF_PRIORITY_LOWERED', + }, + fcmOptions: { + analyticsLabel: 'test.analytics', }, }, }, - { - label: 'Android TTL', - req: { - android: { - priority: 'high', - collapseKey: 'test.key', - restrictedPackageName: 'test.package', - ttl: 5000, + }, + { + label: 'Webpush data message', + req: { + webpush: { + data: { + k1: 'v1', + k2: 'v2', }, }, - expectedReq: { - android: { - priority: 'high', - collapse_key: 'test.key', - restricted_package_name: 'test.package', - ttl: '5s', + }, + }, + { + label: 'Webpush notification message', + req: { + webpush: { + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', }, }, }, - { - label: 'All Android properties', - req: { - android: { - priority: 'high', - collapseKey: 'test.key', - restrictedPackageName: 'test.package', - directBootOk: true, - bandwidthConstrainedOk: true, - restrictedSatelliteOk: true, - ttl: 5, - data: { - k1: 'v1', - k2: 'v2', - }, - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', - imageUrl: 'https://example.com/image.png', - clickAction: 'test.click.action', - titleLocKey: 'title.loc.key', - titleLocArgs: ['arg1', 'arg2'], - bodyLocKey: 'body.loc.key', - bodyLocArgs: ['arg1', 'arg2'], - channelId: 'test.channel', - ticker: 'test.ticker', - sticky: true, - visibility: 'private', - eventTimestamp: new Date('2019-10-20T12:00:00-06:30'), - localOnly: true, - priority: 'high', - vibrateTimingsMillis: [100, 50, 250], - defaultVibrateTimings: false, - defaultSound: true, - lightSettings: { - color: '#AABBCC', - lightOnDurationMillis: 200, - lightOffDurationMillis: 300, - }, - defaultLightSettings: false, - notificationCount: 1, - proxy: 'if_priority_lowered', - }, - fcmOptions: { - analyticsLabel: 'test.analytics', - }, + }, + { + label: 'All Webpush properties', + req: { + webpush: { + headers: { + h1: 'v1', + h2: 'v2', }, - }, - expectedReq: { - android: { - priority: 'high', - collapse_key: 'test.key', - restricted_package_name: 'test.package', - direct_boot_ok: true, - bandwidth_constrained_ok: true, - restricted_satellite_ok: true, - ttl: '0.005000000s', // 5 ms = 5,000,000 ns + data: { + k1: 'v1', + k2: 'v2', + }, + notification: { + title: 'test.title', + body: 'test.body', + icon: 'test.icon', + actions: [{ + action: 'test.action.1', + title: 'test.action.1.title', + icon: 'test.action.1.icon', + }, { + action: 'test.action.2', + title: 'test.action.2.title', + icon: 'test.action.2.icon', + }], + badge: 'test.badge', data: { - k1: 'v1', - k2: 'v2', - }, - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - color: '#112233', - sound: 'test.sound', - tag: 'test.tag', - image: 'https://example.com/image.png', - click_action: 'test.click.action', - title_loc_key: 'title.loc.key', - title_loc_args: ['arg1', 'arg2'], - body_loc_key: 'body.loc.key', - body_loc_args: ['arg1', 'arg2'], - channel_id: 'test.channel', - ticker: 'test.ticker', - sticky: true, - visibility: 'PRIVATE', - event_time: '2019-10-20T18:30:00.000Z', - local_only: true, - notification_priority: 'PRIORITY_HIGH', - vibrate_timings: ['0.100000000s', '0.050000000s', '0.250000000s'], - default_vibrate_timings: false, - default_sound: true, - light_settings: { - color: { - red: 0.6666666666666666, - green: 0.7333333333333333, - blue: 0.8, - alpha: 1, - }, - light_on_duration: '0.200000000s', - light_off_duration: '0.300000000s', - }, - default_light_settings: false, - notification_count: 1, - proxy: 'IF_PRIORITY_LOWERED', - }, - fcmOptions: { - analyticsLabel: 'test.analytics', + key: 'value', }, + dir: 'auto', + image: 'test.image', + requireInteraction: true, + }, + fcmOptions: { + link: 'https://example.com', }, }, }, - { - label: 'Webpush data message', - req: { - webpush: { - data: { - k1: 'v1', - k2: 'v2', - }, + }, + { + label: 'APNS headers only', + req: { + apns: { + headers: { + k1: 'v1', + k2: 'v2', }, }, }, - { - label: 'Webpush notification message', - req: { - webpush: { - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', + }, + { + label: 'APNS string alert', + req: { + apns: { + payload: { + aps: { + alert: 'test.alert', }, }, }, }, - { - label: 'All Webpush properties', - req: { - webpush: { - headers: { - h1: 'v1', - h2: 'v2', - }, - data: { - k1: 'v1', - k2: 'v2', - }, - notification: { - title: 'test.title', - body: 'test.body', - icon: 'test.icon', - actions: [{ - action: 'test.action.1', - title: 'test.action.1.title', - icon: 'test.action.1.icon', - }, { - action: 'test.action.2', - title: 'test.action.2.title', - icon: 'test.action.2.icon', - }], - badge: 'test.badge', - data: { - key: 'value', + }, + { + label: 'All APNS properties', + req: { + apns: { + headers: { + h1: 'v1', + h2: 'v2', + }, + payload: { + aps: { + alert: { + title: 'title', + subtitle: 'subtitle', + body: 'body', + titleLocKey: 'title.loc.key', + titleLocArgs: ['arg1', 'arg2'], + subtitleLocKey: 'subtitle.loc.key', + subtitleLocArgs: ['arg1', 'arg2'], + locKey: 'body.loc.key', + locArgs: ['arg1', 'arg2'], + actionLocKey: 'action.loc.key', + launchImage: 'image', }, - dir: 'auto', - image: 'test.image', - requireInteraction: true, - }, - fcmOptions: { - link: 'https://example.com', + badge: 42, + sound: 'test.sound', + category: 'test.category', + contentAvailable: true, + mutableContent: true, + threadId: 'thread.id', }, + customKey1: 'custom.value', + customKey2: { nested: 'value' }, + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + imageUrl: 'https://example.com/image.png', }, }, }, - { - label: 'APNS headers only', - req: { - apns: { - headers: { - k1: 'v1', - k2: 'v2', + expectedReq: { + apns: { + headers: { + h1: 'v1', + h2: 'v2', + }, + payload: { + aps: { + 'alert': { + 'title': 'title', + 'subtitle': 'subtitle', + 'body': 'body', + 'title-loc-key': 'title.loc.key', + 'title-loc-args': ['arg1', 'arg2'], + 'subtitle-loc-key': 'subtitle.loc.key', + 'subtitle-loc-args': ['arg1', 'arg2'], + 'loc-key': 'body.loc.key', + 'loc-args': ['arg1', 'arg2'], + 'action-loc-key': 'action.loc.key', + 'launch-image': 'image', + }, + 'badge': 42, + 'sound': 'test.sound', + 'category': 'test.category', + 'content-available': 1, + 'mutable-content': 1, + 'thread-id': 'thread.id', }, + customKey1: 'custom.value', + customKey2: { nested: 'value' }, + }, + fcmOptions: { + analyticsLabel: 'test.analytics', + image: 'https://example.com/image.png', }, }, }, - { - label: 'APNS string alert', - req: { - apns: { - payload: { - aps: { - alert: 'test.alert', + }, + { + label: 'APNS critical sound', + req: { + apns: { + payload: { + aps: { + sound: { + critical: true, + name: 'test.sound', + volume: 0.5, }, }, }, }, }, - { - label: 'All APNS properties', - req: { - apns: { - headers: { - h1: 'v1', - h2: 'v2', - }, - payload: { - aps: { - alert: { - title: 'title', - subtitle: 'subtitle', - body: 'body', - titleLocKey: 'title.loc.key', - titleLocArgs: ['arg1', 'arg2'], - subtitleLocKey: 'subtitle.loc.key', - subtitleLocArgs: ['arg1', 'arg2'], - locKey: 'body.loc.key', - locArgs: ['arg1', 'arg2'], - actionLocKey: 'action.loc.key', - launchImage: 'image', - }, - badge: 42, - sound: 'test.sound', - category: 'test.category', - contentAvailable: true, - mutableContent: true, - threadId: 'thread.id', + expectedReq: { + apns: { + payload: { + aps: { + sound: { + critical: 1, + name: 'test.sound', + volume: 0.5, }, - customKey1: 'custom.value', - customKey2: { nested: 'value' }, - }, - fcmOptions: { - analyticsLabel: 'test.analytics', - imageUrl: 'https://example.com/image.png', }, }, }, - expectedReq: { - apns: { - headers: { - h1: 'v1', - h2: 'v2', - }, - payload: { - aps: { - 'alert': { - 'title': 'title', - 'subtitle': 'subtitle', - 'body': 'body', - 'title-loc-key': 'title.loc.key', - 'title-loc-args': ['arg1', 'arg2'], - 'subtitle-loc-key': 'subtitle.loc.key', - 'subtitle-loc-args': ['arg1', 'arg2'], - 'loc-key': 'body.loc.key', - 'loc-args': ['arg1', 'arg2'], - 'action-loc-key': 'action.loc.key', - 'launch-image': 'image', - }, - 'badge': 42, - 'sound': 'test.sound', - 'category': 'test.category', - 'content-available': 1, - 'mutable-content': 1, - 'thread-id': 'thread.id', + }, + }, + { + label: 'APNS critical sound name only', + req: { + apns: { + payload: { + aps: { + sound: { + name: 'test.sound', }, - customKey1: 'custom.value', - customKey2: { nested: 'value' }, - }, - fcmOptions: { - analyticsLabel: 'test.analytics', - image: 'https://example.com/image.png', }, }, }, }, - { - label: 'APNS critical sound', - req: { - apns: { - payload: { - aps: { - sound: { - critical: true, - name: 'test.sound', - volume: 0.5, - }, + expectedReq: { + apns: { + payload: { + aps: { + sound: { + name: 'test.sound', }, }, }, }, - expectedReq: { - apns: { - payload: { - aps: { - sound: { - critical: 1, - name: 'test.sound', - volume: 0.5, - }, + }, + }, + { + label: 'APNS critical sound explicitly false', + req: { + apns: { + payload: { + aps: { + sound: { + critical: false, + name: 'test.sound', + volume: 0.5, }, }, }, }, }, - { - label: 'APNS critical sound name only', - req: { - apns: { - payload: { - aps: { - sound: { - name: 'test.sound', - }, + expectedReq: { + apns: { + payload: { + aps: { + sound: { + name: 'test.sound', + volume: 0.5, }, }, }, }, - expectedReq: { - apns: { - payload: { - aps: { - sound: { - name: 'test.sound', - }, - }, + }, + }, + { + label: 'APNS contentAvailable explicitly false', + req: { + apns: { + payload: { + aps: { + contentAvailable: false, }, }, }, }, - { - label: 'APNS critical sound explicitly false', - req: { - apns: { - payload: { - aps: { - sound: { - critical: false, - name: 'test.sound', - volume: 0.5, - }, - }, - }, + expectedReq: { + apns: { + payload: { + aps: {}, }, }, - expectedReq: { - apns: { - payload: { - aps: { - sound: { - name: 'test.sound', - volume: 0.5, - }, - }, + }, + }, + { + label: 'APNS content-available set explicitly', + req: { + apns: { + payload: { + aps: { + 'content-available': 1, }, }, }, }, - { - label: 'APNS contentAvailable explicitly false', - req: { - apns: { - payload: { - aps: { - contentAvailable: false, - }, - }, + expectedReq: { + apns: { + payload: { + aps: { 'content-available': 1 }, }, }, - expectedReq: { - apns: { - payload: { - aps: {}, + }, + }, + { + label: 'APNS mutableContent explicitly false', + req: { + apns: { + payload: { + aps: { + mutableContent: false, }, }, }, }, - { - label: 'APNS content-available set explicitly', - req: { - apns: { - payload: { - aps: { - 'content-available': 1, - }, - }, - }, - }, - expectedReq: { - apns: { - payload: { - aps: { 'content-available': 1 }, - }, + expectedReq: { + apns: { + payload: { + aps: {}, }, }, }, - { - label: 'APNS mutableContent explicitly false', - req: { - apns: { - payload: { - aps: { - mutableContent: false, - }, + }, + { + label: 'APNS custom fields', + req: { + apns: { + payload: { + aps: { + k1: 'v1', + k2: true, }, }, }, - expectedReq: { - apns: { - payload: { - aps: {}, + }, + expectedReq: { + apns: { + payload: { + aps: { + k1: 'v1', + k2: true, }, }, }, }, - { - label: 'APNS custom fields', - req: { - apns: { - payload: { - aps: { - k1: 'v1', - k2: true, - }, - }, + }, + { + label: 'APNS Start LiveActivity', + req: { + apns: { + liveActivityToken: 'live-activity-token', + headers: { + 'apns-priority': '10' }, - }, - expectedReq: { - apns: { - payload: { - aps: { - k1: 'v1', - k2: true, + payload: { + aps: { + timestamp: 1746475860808, + event: 'start', + 'content-state': { + 'demo': 1 }, + 'attributes-type': 'DemoAttributes', + 'attributes': { + 'demoAttribute': 1, + }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, }, }, }, - { - label: 'APNS Start LiveActivity', - req: { - apns: { - liveActivityToken: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'start', - 'content-state': { - 'demo': 1 - }, - 'attributes-type': 'DemoAttributes', - 'attributes': { - 'demoAttribute': 1, - }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } - }, - }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + headers: { + 'apns-priority': '10' }, - }, - expectedReq: { - apns: { - live_activity_token: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'start', - 'content-state': { - 'demo': 1 - }, - 'attributes-type': 'DemoAttributes', - 'attributes': { - 'demoAttribute': 1, - }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } + payload: { + aps: { + timestamp: 1746475860808, + event: 'start', + 'content-state': { + 'demo': 1 + }, + 'attributes-type': 'DemoAttributes', + 'attributes': { + 'demoAttribute': 1, }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, }, }, }, - { - label: 'APNS Update LiveActivity', - req: { - apns: { - liveActivityToken: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'update', - 'content-state': { - 'test1': 100, - 'test2': 'demo' - }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } + }, + { + label: 'APNS Update LiveActivity', + req: { + apns: { + liveActivityToken: 'live-activity-token', + headers: { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'update', + 'content-state': { + 'test1': 100, + 'test2': 'demo' }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, }, }, - expectedReq: { - apns: { - live_activity_token: 'live-activity-token', - headers: { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - event: 'update', - 'content-state': { - 'test1': 100, - 'test2': 'demo' - }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + headers: { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + event: 'update', + 'content-state': { + 'test1': 100, + 'test2': 'demo' }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, }, }, }, - { - label: 'APNS End LiveActivity', - req: { - apns: { - liveActivityToken: 'live-activity-token', - 'headers': { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - 'dismissal-date': 1746475860808 + 60, - event: 'end', - 'content-state': { - 'test1': 100, - 'test2': 'demo' - }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } + }, + { + label: 'APNS End LiveActivity', + req: { + apns: { + liveActivityToken: 'live-activity-token', + 'headers': { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + 'dismissal-date': 1746475860808 + 60, + event: 'end', + 'content-state': { + 'test1': 100, + 'test2': 'demo' }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, }, }, - expectedReq: { - apns: { - live_activity_token: 'live-activity-token', - 'headers': { - 'apns-priority': '10' - }, - payload: { - aps: { - timestamp: 1746475860808, - 'dismissal-date': 1746475860808 + 60, - event: 'end', - 'content-state': { - 'test1': 100, - 'test2': 'demo' - }, - 'alert': { - 'title': 'test title', - 'body': 'test body' - } + }, + expectedReq: { + apns: { + live_activity_token: 'live-activity-token', + 'headers': { + 'apns-priority': '10' + }, + payload: { + aps: { + timestamp: 1746475860808, + 'dismissal-date': 1746475860808 + 60, + event: 'end', + 'content-state': { + 'test1': 100, + 'test2': 'demo' }, + 'alert': { + 'title': 'test title', + 'body': 'test body' + } }, }, }, }, - ]; + }, + ]; validMessages.forEach((config) => { it(`should serialize well-formed Message: ${config.label}`, () => { @@ -2849,10 +2903,10 @@ describe('Messaging', () => { invalidRegistrationTokens.forEach((invalidRegistrationToken) => { it('should throw given invalid type for registration token(s) argument: ' + JSON.stringify(invalidRegistrationToken), () => { - expect(() => { - messagingService[methodName](invalidRegistrationToken as string, mocks.messaging.topic); - }).to.throw(invalidRegistrationTokensArgumentError); - }); + expect(() => { + messagingService[methodName](invalidRegistrationToken as string, mocks.messaging.topic); + }).to.throw(invalidRegistrationTokensArgumentError); + }); }); it('should throw given no registration token(s) argument', () => { @@ -3010,209 +3064,347 @@ describe('Messaging', () => { it('should be fulfilled given a valid registration token and topic (topic name not prefixed ' + 'with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ); - }); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ); + }); it('should be fulfilled given a valid registration token and topic (topic name prefixed ' + 'with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ); - }); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ); + }); it('should be fulfilled given a valid array of registration tokens and topic (topic name not ' + 'prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topic, - ); - }); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topic, + ); + }); it('should be fulfilled given a valid array of registration tokens and topic (topic name ' + 'prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topicWithPrefix, - ); - }); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topicWithPrefix, + ); + }); it('should be fulfilled with the server response given a single registration token and topic ' + '(topic name not prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 1, - errors: [], - }); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).should.eventually.deep.equal({ + failureCount: 0, + successCount: 1, + errors: [], }); + }); it('should be fulfilled with the server response given a single registration token and topic ' + '(topic name prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 1, - errors: [], - }); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ).should.eventually.deep.equal({ + failureCount: 0, + successCount: 1, + errors: [], }); + }); it('should be fulfilled with the server response given an array of registration tokens ' + 'and topic (topic name not prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topic, - ).then((response: MessagingTopicManagementResponse) => { - expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); - expect(response.failureCount).to.equal(2); - expect(response.successCount).to.equal(1); - expect(response.errors).to.have.length(2); - expect(response.errors[0]).to.have.keys(['index', 'error']); - expect(response.errors[0].index).to.equal(1); - expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); - expect(response.errors[1]).to.have.keys(['index', 'error']); - expect(response.errors[1].index).to.equal(2); - expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); - }); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); + expect(response.failureCount).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.errors).to.have.length(2); + expect(response.errors[0]).to.have.keys(['index', 'error']); + expect(response.errors[0].index).to.equal(1); + expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); + expect(response.errors[1]).to.have.keys(['index', 'error']); + expect(response.errors[1].index).to.equal(2); + expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); }); + }); it('should be fulfilled with the server response given an array of registration tokens ' + 'and topic (topic name prefixed with "/topics/")', () => { - mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); + mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); - return messagingService[methodName]( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.topicWithPrefix, - ).then((response: MessagingTopicManagementResponse) => { - expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); - expect(response.failureCount).to.equal(2); - expect(response.successCount).to.equal(1); - expect(response.errors).to.have.length(2); - expect(response.errors[0]).to.have.keys(['index', 'error']); - expect(response.errors[0].index).to.equal(1); - expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); - expect(response.errors[1]).to.have.keys(['index', 'error']); - expect(response.errors[1].index).to.equal(2); - expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); - }); + return messagingService[methodName]( + [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ], + mocks.messaging.topicWithPrefix, + ).then((response: MessagingTopicManagementResponse) => { + expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); + expect(response.failureCount).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.errors).to.have.length(2); + expect(response.errors[0]).to.have.keys(['index', 'error']); + expect(response.errors[0].index).to.equal(1); + expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); + expect(response.errors[1]).to.have.keys(['index', 'error']); + expect(response.errors[1].index).to.equal(2); + expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); }); + }); it('should set the appropriate request data given a single registration token and topic ' + '(topic name not prefixed with "/topics/")', () => { - mockTopicSubscriptionRequest(methodName, 1); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ).then(() => { - expect(http2Mocker.requests.length).to.equal(1); - const req = http2Mocker.requests[0]; - const isSub = methodName === 'subscribeToTopic'; - expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); - const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; - expect(req.headers[':path']).to.contain(expectedSuffix); - }); + mockTopicSubscriptionRequest(methodName, 1); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(1); + const req = http2Mocker.requests[0]; + const isSub = methodName === 'subscribeToTopic'; + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + expect(req.headers[':path']).to.contain(expectedSuffix); }); + }); it('should set the appropriate request data given a single registration token and topic ' + '(topic name prefixed with "/topics/")', () => { - mockTopicSubscriptionRequest(methodName, 1); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ).then(() => { - expect(http2Mocker.requests.length).to.equal(1); - const req = http2Mocker.requests[0]; - const isSub = methodName === 'subscribeToTopic'; - expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); - const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; - expect(req.headers[':path']).to.contain(expectedSuffix); - }); + mockTopicSubscriptionRequest(methodName, 1); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(1); + const req = http2Mocker.requests[0]; + const isSub = methodName === 'subscribeToTopic'; + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + expect(req.headers[':path']).to.contain(expectedSuffix); }); + }); it('should set the appropriate request data given an array of registration tokens and ' + 'topic (topic name not prefixed with "/topics/")', () => { - const registrationTokens = [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ]; + const registrationTokens = [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ]; - mockTopicSubscriptionRequest(methodName, 3); - return messagingService[methodName]( - registrationTokens, - mocks.messaging.topic, - ).then(() => { - expect(http2Mocker.requests.length).to.equal(3); - const isSub = methodName === 'subscribeToTopic'; - const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; - http2Mocker.requests.forEach((req, idx) => { - expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); - expect(req.headers[':path']).to.contain(registrationTokens[idx]); - expect(req.headers[':path']).to.contain(expectedSuffix); - }); + mockTopicSubscriptionRequest(methodName, 3); + return messagingService[methodName]( + registrationTokens, + mocks.messaging.topic, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(3); + const isSub = methodName === 'subscribeToTopic'; + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + http2Mocker.requests.forEach((req, idx) => { + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + expect(req.headers[':path']).to.contain(registrationTokens[idx]); + expect(req.headers[':path']).to.contain(expectedSuffix); }); }); + }); it('should set the appropriate request data given an array of registration tokens and ' + 'topic (topic name prefixed with "/topics/")', () => { - const registrationTokens = [ + const registrationTokens = [ + mocks.messaging.registrationToken + '0', + mocks.messaging.registrationToken + '1', + mocks.messaging.registrationToken + '2', + ]; + + mockTopicSubscriptionRequest(methodName, 3); + return messagingService[methodName]( + registrationTokens, + mocks.messaging.topicWithPrefix, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(3); + const isSub = methodName === 'subscribeToTopic'; + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + http2Mocker.requests.forEach((req, idx) => { + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + expect(req.headers[':path']).to.contain(registrationTokens[idx]); + expect(req.headers[':path']).to.contain(expectedSuffix); + }); + }); + }); + } + + function tokenSubscriptionLegacyTests(methodName: string): void { + const invalidRegistrationTokensArgumentError = 'Registration token(s) provided to ' + + `${methodName}() must be a non-empty string or a non-empty array`; + + const invalidRegistrationTokens = [null, NaN, 0, 1, true, false, {}, { a: 1 }, _.noop]; + invalidRegistrationTokens.forEach((invalidRegistrationToken) => { + it('should throw given invalid type for registration token(s) argument: ' + + JSON.stringify(invalidRegistrationToken), () => { + expect(() => { + messagingService[methodName](invalidRegistrationToken as string, mocks.messaging.topic); + }).to.throw(invalidRegistrationTokensArgumentError); + }); + }); + + it('should throw given no registration token(s) argument', () => { + expect(() => { + messagingService[methodName](undefined as any, mocks.messaging.topic); + }).to.throw(invalidRegistrationTokensArgumentError); + }); + + it('should throw given empty string for registration token(s) argument', () => { + expect(() => { + messagingService[methodName]('', mocks.messaging.topic); + }).to.throw(invalidRegistrationTokensArgumentError); + }); + + it('should throw given empty array for registration token(s) argument', () => { + expect(() => { + messagingService[methodName]([], mocks.messaging.topic); + }).to.throw(invalidRegistrationTokensArgumentError); + }); + + it('should be rejected given empty string within array for registration token(s) argument', () => { + return messagingService[methodName](['foo', 'bar', ''], mocks.messaging.topic) + .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); + }); + + it('should be rejected given non-string value within array for registration token(s) argument', () => { + return messagingService[methodName](['foo', true as any, 'bar'], mocks.messaging.topic) + .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); + }); + + it('should be rejected given an array containing more than 1,000 registration tokens', () => { + mockedRequests.push(mockTopicSubscriptionLegacyRequest(methodName, /* successCount */ 1000)); + + const registrationTokens = (Array(1000) as any).fill(mocks.messaging.registrationToken); + + return messagingService[methodName](registrationTokens, mocks.messaging.topic) + .then(() => { + registrationTokens.push(mocks.messaging.registrationToken); + + return messagingService[methodName](registrationTokens, mocks.messaging.topic) + .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); + }); + }); + + const invalidTopicArgumentError = `Topic provided to ${methodName}() must be a string which matches`; + + const invalidTopics = [null, NaN, 0, 1, true, false, [], ['a', 1], {}, { a: 1 }, _.noop]; + invalidTopics.forEach((invalidTopic) => { + it(`should throw given invalid type for topic argument: ${JSON.stringify(invalidTopic)}`, () => { + expect(() => { + messagingService[methodName](mocks.messaging.registrationToken, invalidTopic as string); + }).to.throw(invalidTopicArgumentError); + }); + }); + + it('should throw given no topic argument', () => { + expect(() => { + messagingService[methodName](mocks.messaging.registrationToken, undefined as any); + }).to.throw(invalidTopicArgumentError); + }); + + it('should throw given empty string for topic argument', () => { + expect(() => { + messagingService[methodName](mocks.messaging.registrationToken, ''); + }).to.throw(invalidTopicArgumentError); + }); + + it('should be rejected given topic argument which has invalid characters: f*o*o', () => { + return messagingService[methodName](mocks.messaging.registrationToken, 'f*o*o') + .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); + }); + + it('should be rejected given a 200 JSON server response with a known error', () => { + mockedRequests.push(mockTopicSubscriptionLegacyRequestWithError(methodName, 200, 'json')); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); + }); + + it('should be rejected given a non-2xx JSON server response', () => { + mockedRequests.push(mockTopicSubscriptionLegacyRequestWithError(methodName, 400, 'json')); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); + }); + + it('should be fulfilled with the server response given a single registration token and topic', () => { + mockedRequests.push(mockTopicSubscriptionLegacyRequest(methodName, /* successCount */ 1)); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).should.eventually.deep.equal({ + failureCount: 0, + successCount: 1, + errors: [], + }); + }); + + it('should be fulfilled with the server response given an array of registration tokens', () => { + mockedRequests.push(mockTopicSubscriptionLegacyRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); + + return messagingService[methodName]( + [ mocks.messaging.registrationToken + '0', mocks.messaging.registrationToken + '1', mocks.messaging.registrationToken + '2', - ]; - - mockTopicSubscriptionRequest(methodName, 3); - return messagingService[methodName]( - registrationTokens, - mocks.messaging.topicWithPrefix, - ).then(() => { - expect(http2Mocker.requests.length).to.equal(3); - const isSub = methodName === 'subscribeToTopic'; - const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; - http2Mocker.requests.forEach((req, idx) => { - expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); - expect(req.headers[':path']).to.contain(registrationTokens[idx]); - expect(req.headers[':path']).to.contain(expectedSuffix); - }); - }); + ], + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response).to.have.keys(['failureCount', 'successCount', 'errors']); + expect(response.failureCount).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.errors).to.have.length(2); + expect(response.errors[0].index).to.equal(1); + expect(response.errors[0].error).to.have.property('code', 'messaging/too-many-topics'); + expect(response.errors[1].index).to.equal(2); + expect(response.errors[1].error).to.have.property('code', 'messaging/too-many-topics'); }); + }); } describe('subscribeToTopic()', () => { @@ -3222,4 +3414,12 @@ describe('Messaging', () => { describe('unsubscribeFromTopic()', () => { tokenSubscriptionTests('unsubscribeFromTopic'); }); + + describe('subscribeToTopicLegacy()', () => { + tokenSubscriptionLegacyTests('subscribeToTopicLegacy'); + }); + + describe('unsubscribeFromTopicLegacy()', () => { + tokenSubscriptionLegacyTests('unsubscribeFromTopicLegacy'); + }); });