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..7ebc23fb1b 100644 --- a/src/messaging/messaging-api-request-internal.ts +++ b/src/messaging/messaging-api-request-internal.ts @@ -24,6 +24,13 @@ import { import { createFirebaseError, getErrorCode } from './messaging-errors-internal'; import { getSdkVersion } from '../utils/index'; import { SendResponse } from './messaging-api'; +import * as validator from '../utils/validator'; +import { FirebaseMessagingError } from './error'; + +export interface TopicSubscriptionResponse { + success: boolean; + error?: FirebaseMessagingError; +} // FCM backend constants @@ -170,4 +177,139 @@ export class FirebaseMessagingRequestHandler { error: createFirebaseError(err) }; } + + /** + * Invokes the HTTP/1.1 request handler for a topic management operation. + * + * @param host - The host to which to send the request. + * @param path - The path to which to send the request. + * @param methodName - The name of the calling method (subscribeToTopic or unsubscribeFromTopic). + * @param requestData - The request data (or undefined for DELETE). + * @returns A promise that resolves with the {@link TopicSubscriptionResponse}. + */ + public invokeHttpRequestHandlerForTopicSubscriptionResponse( + host: string, path: string, methodName: string, requestData?: object + ): Promise { + const request: HttpRequestConfig = { + method: methodName === 'subscribeToTopic' ? 'POST' : 'DELETE', + url: `https://${host}${path}`, + data: requestData, + headers: FIREBASE_MESSAGING_HEADERS, + timeout: FIREBASE_MESSAGING_TIMEOUT, + }; + return this.httpClient.send(request).then((response) => { + return this.buildTopicSubscriptionResponse(response, methodName); + }) + .catch((err) => { + if (err instanceof RequestResponseError) { + return this.buildTopicSubscriptionResponseFromError(err, methodName); + } + // Re-throw the error if it already has the proper format. + throw err; + }); + } + + /** + * Invokes the HTTP/2 request handler for a topic management operation. + * + * @param host - The host to which to send the request. + * @param path - The path to which to send the request. + * @param methodName - The name of the calling method (subscribeToTopic or unsubscribeFromTopic). + * @param requestData - The request data (or undefined for DELETE). + * @param http2SessionHandler - The HTTP/2 session handler. + * @returns A promise that resolves with the {@link TopicSubscriptionResponse}. + */ + public invokeHttp2RequestHandlerForTopicSubscriptionResponse( + host: string, path: string, methodName: string, requestData: object | undefined, http2SessionHandler: Http2SessionHandler + ): Promise { + const request: Http2RequestConfig = { + method: methodName === 'subscribeToTopic' ? 'POST' : 'DELETE', + url: `https://${host}${path}`, + data: requestData, + headers: FIREBASE_MESSAGING_HEADERS, + timeout: FIREBASE_MESSAGING_TIMEOUT, + http2SessionHandler: http2SessionHandler + }; + return this.http2Client.send(request).then((response) => { + return this.buildTopicSubscriptionResponse(response, methodName); + }) + .catch((err) => { + if (err instanceof RequestResponseError) { + return this.buildTopicSubscriptionResponseFromError(err, methodName); + } + // Re-throw the error if it already has the proper format. + throw err; + }); + } + + private buildTopicSubscriptionResponse( + response: RequestResponse, methodName: string + ): TopicSubscriptionResponse { + if (response.status === 200) { + return { success: true }; + } + return this.buildTopicSubscriptionResponseFromError(new RequestResponseError(response), methodName); + } + + private buildTopicSubscriptionResponseFromError( + err: RequestResponseError, methodName: string + ): TopicSubscriptionResponse { + if (err.response.isJson()) { + const json = err.response.data; + const errorCode = getErrorCode(json); + if (methodName === 'subscribeToTopic' && (errorCode === 'ALREADY_EXISTS' || err.response.status === 409)) { + return { success: true }; + } + const errorMessage = (validator.isNonNullObject(json) && 'error' in json && validator.isNonEmptyString((json as any).error.message)) + ? (json as any).error.message + : undefined; + return { + success: false, + error: FirebaseMessagingError.fromTopicManagementServerError( + errorCode || 'UNKNOWN_ERROR', + errorMessage, + err, + ), + }; + } + + // Non-JSON response + if (methodName === 'subscribeToTopic' && err.response.status === 409) { + return { success: true }; + } + + let serverErrorCode = 'UNKNOWN_ERROR'; + switch (err.response.status) { + case 400: + serverErrorCode = 'INVALID_ARGUMENT'; + break; + case 401: + case 403: + serverErrorCode = 'PERMISSION_DENIED'; + break; + case 404: + serverErrorCode = 'NOT_FOUND'; + break; + case 429: + serverErrorCode = 'RESOURCE_EXHAUSTED'; + break; + case 500: + serverErrorCode = 'INTERNAL'; + break; + case 503: + serverErrorCode = 'DEADLINE_EXCEEDED'; + break; + default: + serverErrorCode = 'UNKNOWN_ERROR'; + } + + return { + success: false, + error: FirebaseMessagingError.fromTopicManagementServerError( + serverErrorCode, + `Server responded with status ${err.response.status}.`, + err, + ), + }; + } } diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index a78fdb71ce..97b7ce37d3 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -24,7 +24,43 @@ import { ErrorInfo } from '../utils/error'; import * as utils from '../utils'; import * as validator from '../utils/validator'; import { validateMessage } from './messaging-internal'; -import { FirebaseMessagingRequestHandler } from './messaging-api-request-internal'; +import { FirebaseMessagingRequestHandler, TopicSubscriptionResponse } from './messaging-api-request-internal'; + +/** + * Runs a list of async tasks with a maximum concurrency limit. + * + * @param tasks - Array of functions that return a Promise. + * @param limit - Maximum number of tasks to run concurrently. + * @returns A Promise that resolves with an array of PromiseSettledResult in the same order as the tasks. + */ +async function runWithConcurrencyLimit( + tasks: (() => Promise)[], + limit = 100, +): Promise[]> { + const results: PromiseSettledResult[] = new Array(tasks.length); + let currentIndex = 0; + + const worker = async (): Promise => { + while (currentIndex < tasks.length) { + const index = currentIndex++; + try { + const value = await tasks[index](); + results[index] = { status: 'fulfilled', value }; + } catch (reason) { + results[index] = { status: 'rejected', reason }; + } + } + }; + + const workers: Promise[] = []; + const workerCount = Math.min(limit, tasks.length); + for (let i = 0; i < workerCount; i++) { + workers.push(worker()); + } + + await Promise.all(workers); + return results; +} import { BatchResponse, @@ -90,6 +126,7 @@ function mapRawResponseToTopicManagementResponse(response: object): MessagingTop export class Messaging { private urlPath: string; + private projectId?: string; private readonly appInternal: App; private readonly messagingRequestHandler: FirebaseMessagingRequestHandler; private useLegacyTransport = false; @@ -371,14 +408,53 @@ export class Messaging { * @returns A promise fulfilled with the server's response after the device has been * subscribed to the topic. */ + /** + * Subscribes a device to an FCM topic. + * + * See {@link https://firebase.google.com/docs/cloud-messaging/manage-topics#suscribe_and_unsubscribe_using_the | + * Subscribe to a topic} + * for code samples and detailed documentation. Optionally, you can provide an + * array of tokens to subscribe multiple devices. + * + * @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. + */ public subscribeToTopic( registrationTokenOrTokens: string | string[], topic: string, ): Promise { - return this.sendTopicManagementRequest( + return this.sendTopicManagementRequestV1( registrationTokenOrTokens, topic, 'subscribeToTopic', + ); + } + + /** + * 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.sendTopicManagementRequest( + registrationTokenOrTokens, + topic, + 'subscribeToTopicLegacy', FCM_TOPIC_MANAGEMENT_ADD_PATH, ); } @@ -391,7 +467,7 @@ export class Messaging { * for code samples and detailed documentation. Optionally, you can provide an * array of tokens to unsubscribe multiple devices. * - * @param registrationTokens - A device registration token or an array of + * @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. * @@ -402,17 +478,41 @@ export class Messaging { registrationTokenOrTokens: string | string[], topic: string, ): Promise { - return this.sendTopicManagementRequest( + return this.sendTopicManagementRequestV1( registrationTokenOrTokens, topic, 'unsubscribeFromTopic', + ); + } + + /** + * 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.sendTopicManagementRequest( + registrationTokenOrTokens, + topic, + 'unsubscribeFromTopicLegacy', FCM_TOPIC_MANAGEMENT_REMOVE_PATH, ); } - private getUrlPath(): Promise { - if (this.urlPath) { - return Promise.resolve(this.urlPath); + private getProjectId(): Promise { + if (this.projectId) { + return Promise.resolve(this.projectId); } return utils.findProjectId(this.app) @@ -427,11 +527,135 @@ export class Messaging { ); } + this.projectId = projectId; + return this.projectId; + }); + } + + private getUrlPath(): Promise { + if (this.urlPath) { + return Promise.resolve(this.urlPath); + } + + return this.getProjectId() + .then((projectId) => { this.urlPath = `/v1/projects/${projectId}/messages:send`; return this.urlPath; }); } + /** + * Helper method which sends and handles topic subscription management requests via FCM v1 API. + * + * @param registrationTokenOrTokens - The registration token or an array of + * registration tokens to subscribe/unsubscribe. + * @param topic - The topic to subscribe/unsubscribe. + * @param methodName - The name of the original method called. + * + * @returns A Promise fulfilled with the parsed server response. + */ + private sendTopicManagementRequestV1( + registrationTokenOrTokens: string | string[], + topic: string, + methodName: 'subscribeToTopic' | 'unsubscribeFromTopic', + ): Promise { + this.validateRegistrationTokensType(registrationTokenOrTokens, methodName); + this.validateTopicType(topic, methodName); + + const normalizedTopic = this.normalizeTopic(topic); + + return Promise.resolve() + .then(() => { + this.validateRegistrationTokens(registrationTokenOrTokens, methodName); + this.validateTopic(normalizedTopic, methodName); + + let registrationTokensArray: string[] = registrationTokenOrTokens as string[]; + if (validator.isString(registrationTokenOrTokens)) { + registrationTokensArray = [registrationTokenOrTokens as string]; + } + + const topicName = normalizedTopic.replace(/^\/topics\//, ''); + const http2SessionHandler = this.useLegacyTransport ? undefined : new Http2SessionHandler(`https://${FCM_SEND_HOST}`); + + return this.getProjectId().then((projectId) => { + if (http2SessionHandler) { + const tasks = registrationTokensArray.map((token) => async () => { + const path = methodName === 'subscribeToTopic' + ? `/v1/projects/${projectId}/registrations/${encodeURIComponent(token)}/topicSubscriptions?topic_name=${encodeURIComponent(topicName)}` + : `/v1/projects/${projectId}/registrations/${encodeURIComponent(token)}/topicSubscriptions/${encodeURIComponent(topicName)}?allow_missing=true`; + const requestData = methodName === 'subscribeToTopic' ? {} : undefined; + return this.messagingRequestHandler.invokeHttp2RequestHandlerForTopicSubscriptionResponse( + FCM_SEND_HOST, path, methodName, requestData, http2SessionHandler, + ); + }); + return runWithConcurrencyLimit(tasks, 100); + } else { + const tasks = registrationTokensArray.map((token) => async () => { + const path = methodName === 'subscribeToTopic' + ? `/v1/projects/${projectId}/registrations/${encodeURIComponent(token)}/topicSubscriptions?topic_name=${encodeURIComponent(topicName)}` + : `/v1/projects/${projectId}/registrations/${encodeURIComponent(token)}/topicSubscriptions/${encodeURIComponent(topicName)}?allow_missing=true`; + const requestData = methodName === 'subscribeToTopic' ? {} : undefined; + return this.messagingRequestHandler.invokeHttpRequestHandlerForTopicSubscriptionResponse( + FCM_SEND_HOST, path, methodName, requestData, + ); + }); + return runWithConcurrencyLimit(tasks, 100); + } + }) + .then((results) => { + return this.parseTopicManagementResponses(results); + }) + .finally(() => { + http2SessionHandler?.close(); + }); + }); + } + + private parseTopicManagementResponses( + results: PromiseSettledResult[], + ): MessagingTopicManagementResponse { + const response: MessagingTopicManagementResponse = { + successCount: 0, + failureCount: 0, + errors: [], + }; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const tokenResponse = result.value; + if (tokenResponse.success) { + response.successCount += 1; + } else { + response.failureCount += 1; + const error = tokenResponse.error || FirebaseMessagingError.fromTopicManagementServerError( + 'UNKNOWN_ERROR', 'Unknown error during topic management.', + ); + response.errors.push({ + index, + error, + }); + } + } else { + response.failureCount += 1; + let error: FirebaseMessagingError; + if (result.reason instanceof FirebaseMessagingError) { + error = result.reason; + } else { + error = FirebaseMessagingError.fromTopicManagementServerError( + 'UNKNOWN_ERROR', + result.reason?.message || result.reason?.toString() || 'Unknown error during topic management.', + ); + } + response.errors.push({ + index, + error, + }); + } + }); + + return response; + } + /** * Helper method which sends and handles topic subscription management requests. * diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index bf6e0808e2..fcbbbac649 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -167,7 +167,7 @@ function mockTopicSubscriptionRequest( mockedResults.push({ error: 'TOO_MANY_TOPICS' }); } - const path = (methodName === 'subscribeToTopic') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; + const path = (methodName === 'subscribeToTopic' || methodName === 'subscribeToTopicLegacy') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) .post(path) @@ -192,7 +192,7 @@ function mockTopicSubscriptionRequestWithError( contentType = 'text/html; charset=UTF-8'; } - const path = (methodName === 'subscribeToTopic') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; + const path = (methodName === 'subscribeToTopic' || methodName === 'subscribeToTopicLegacy') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) .post(path) @@ -3225,11 +3225,183 @@ describe('Messaging', () => { }); } + function tokenSubscriptionTestsV1(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', () => { + const registrationTokens: string[] = []; + for (let i = 0; i < 1001; i++) { + registrationTokens.push(mocks.messaging.registrationToken + i); + } + 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 ` + + 'the format "/topics/[a-zA-Z0-9-_.~%]+"'; + + 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); + }); + + const topicsWithInvalidCharacters = ['f*o*o', '/topics/f+o+o', 'foo/topics/foo', '$foo', '/topics/foo&']; + topicsWithInvalidCharacters.forEach((invalidTopic) => { + it(`should be rejected given topic argument which has invalid characters: ${invalidTopic}`, () => { + return messagingService[methodName](mocks.messaging.registrationToken, invalidTopic) + .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); + }); + }); + + it('should be fulfilled with server response given a single registration token and topic using HTTP/2', () => { + mockedHttp2Responses.push(mockHttp2SendRequestResponse('projects/projec_id/registrations/reg_token/topicSubscriptions/topic_name')); + http2Mocker.http2Stub(mockedHttp2Responses); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(0); + expect(response.errors).to.be.empty; + }); + }); + + it('should handle ALREADY_EXISTS (409) as success for subscribeToTopic', () => { + if (methodName === 'subscribeToTopic') { + mockedHttp2Responses.push(mockHttp2SendRequestError(409, 'json', { error: { status: 'ALREADY_EXISTS', message: 'Already exists' } })); + http2Mocker.http2Stub(mockedHttp2Responses); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(0); + }); + } else { + mockedHttp2Responses.push(mockHttp2SendRequestError(404, 'json', { error: { status: 'NOT_FOUND', message: 'Not found' } })); + http2Mocker.http2Stub(mockedHttp2Responses); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response.successCount).to.equal(0); + expect(response.failureCount).to.equal(1); + expect(response.errors[0].error.code).to.equal('messaging/registration-token-not-registered'); + }); + } + }); + + it('should be fulfilled with server response given multiple registration tokens and topic using HTTP/2', () => { + const tokens = ['token_1', 'token_2', 'token_3']; + mockedHttp2Responses.push(mockHttp2SendRequestResponse('1')); + mockedHttp2Responses.push(mockHttp2SendRequestError(404, 'json', { error: { status: 'NOT_FOUND', message: 'Not found' } })); + mockedHttp2Responses.push(mockHttp2SendRequestResponse('3')); + http2Mocker.http2Stub(mockedHttp2Responses); + + return messagingService[methodName]( + tokens, + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response.successCount).to.equal(2); + expect(response.failureCount).to.equal(1); + expect(response.errors.length).to.equal(1); + expect(response.errors[0].index).to.equal(1); + expect(response.errors[0].error.code).to.equal('messaging/registration-token-not-registered'); + }); + }); + + it('should be fulfilled when legacy HTTP transport is enabled', () => { + messagingService.enableLegacyHttpTransport(); + const path = methodName === 'subscribeToTopic' + ? `/v1/projects/project_id/registrations/${encodeURIComponent(mocks.messaging.registrationToken)}/topicSubscriptions?topic_name=${encodeURIComponent(mocks.messaging.topic)}` + : `/v1/projects/project_id/registrations/${encodeURIComponent(mocks.messaging.registrationToken)}/topicSubscriptions/${encodeURIComponent(mocks.messaging.topic)}?allow_missing=true`; + + const scope = nock(`https://${FCM_SEND_HOST}:443`) + [methodName === 'subscribeToTopic' ? 'post' : 'delete'](path) + .reply(200, {}); + + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then((response: MessagingTopicManagementResponse) => { + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(0); + expect(scope.isDone()).to.be.true; + }); + }); + } + describe('subscribeToTopic()', () => { - tokenSubscriptionTests('subscribeToTopic'); + tokenSubscriptionTestsV1('subscribeToTopic'); }); describe('unsubscribeFromTopic()', () => { - tokenSubscriptionTests('unsubscribeFromTopic'); + tokenSubscriptionTestsV1('unsubscribeFromTopic'); + }); + + describe('subscribeToTopicLegacy()', () => { + tokenSubscriptionTests('subscribeToTopicLegacy'); + }); + + describe('unsubscribeFromTopicLegacy()', () => { + tokenSubscriptionTests('unsubscribeFromTopicLegacy'); }); });