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-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..c3a5399150 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,7 +37,7 @@ 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'; @@ -50,9 +51,9 @@ 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. + * @param response - The raw FCM server response to map. * - * @returns {MessagingTopicManagementResponse} The mapped `MessagingTopicManagementResponse` object. + * @returns The mapped `MessagingTopicManagementResponse` object. */ function mapRawResponseToTopicManagementResponse(response: object): MessagingTopicManagementResponse { // Add the success and failure counts. @@ -379,7 +380,6 @@ export class Messaging { registrationTokenOrTokens, topic, 'subscribeToTopic', - FCM_TOPIC_MANAGEMENT_ADD_PATH, ); } @@ -406,6 +406,55 @@ 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, ); } @@ -439,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. @@ -448,6 +496,117 @@ export class Messaging { registrationTokenOrTokens: string | string[], topic: string, methodName: 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]; + } + + 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.', + ); + } + + const topicName = topic.replace(/^\/topics\//, ''); + const isSubscribe = methodName === 'subscribeToTopic'; + const httpMethod = isSubscribe ? 'POST' : 'DELETE'; + + const http2SessionHandler = new Http2SessionHandler('https://fcm.googleapis.com'); + + 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 + ); + }); + + 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 { + throw firstReason; + } + } + + 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 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(); + }); + }); + }); + } + + /** + * 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); diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index bf6e0808e2..35da77f278 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -152,57 +152,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 +181,7 @@ describe('Messaging', () => { 'X-Goog-Api-Client': getMetricsHeader(), 'access_token_auth': 'true', }; - const emptyResponse = utils.responseFrom({}); + after(() => { nock.cleanAll(); @@ -257,6 +210,113 @@ 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; + } + + 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]; @@ -3130,44 +3190,34 @@ describe('Messaging', () => { 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 ' + @@ -3178,23 +3228,20 @@ describe('Messaging', () => { 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 ' + @@ -3205,24 +3252,159 @@ describe('Messaging', () => { 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, - ); - }) + 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(() => { - 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); + 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', + ], + 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()', () => { @@ -3232,4 +3414,12 @@ describe('Messaging', () => { describe('unsubscribeFromTopic()', () => { tokenSubscriptionTests('unsubscribeFromTopic'); }); + + describe('subscribeToTopicLegacy()', () => { + tokenSubscriptionLegacyTests('subscribeToTopicLegacy'); + }); + + describe('unsubscribeFromTopicLegacy()', () => { + tokenSubscriptionLegacyTests('unsubscribeFromTopicLegacy'); + }); });