From 2725f8c415ec5de8fc994f6968759b13b162e870 Mon Sep 17 00:00:00 2001 From: Doris-Ge Date: Fri, 3 Mar 2023 09:41:58 -0800 Subject: [PATCH 1/4] Deprecate sendAll and sendMulticast (#2094) 1. Deprecate sendAll and sendMulticast 2. Add dummy implementation for sendEach and sendEachForMulticast to avoid errors reported by api-extractor --- etc/firebase-admin.messaging.api.md | 4 +++ src/messaging/messaging.ts | 54 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index c37466734c..437a4f97f4 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -184,7 +184,11 @@ export type Message = TokenMessage | TopicMessage | ConditionMessage; export class Messaging { get app(): App; send(message: Message, dryRun?: boolean): Promise; + // @deprecated sendAll(messages: Message[], dryRun?: boolean): Promise; + sendEach(messages: Message[], dryRun?: boolean): Promise; + sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise; + // @deprecated sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise; sendToCondition(condition: string, payload: MessagingPayload, options?: MessagingOptions): Promise; sendToDevice(registrationTokenOrTokens: string | string[], payload: MessagingPayload, options?: MessagingOptions): Promise; diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index c208a34a79..a7de961687 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -250,6 +250,56 @@ export class Messaging { }); } + // TODO: Update the comment based on the implementation + /** + * Sends each message in the given array via Firebase Cloud Messaging. + * + * Unlike {@link Messaging.sendAll}, this method makes a single RPC call for each message in the given array. + * + * The responses list obtained from the return value + * corresponds to the order of `messages`. An error + * from this method indicates a total failure -- i.e. none of the messages in + * the list could be sent. Partial failures are indicated by a `BatchResponse` + * return value. + * + * @param messages - A non-empty array + * containing up to 500 messages. + * @param dryRun - Whether to send the messages in the dry-run + * (validation only) mode. + * @returns A Promise fulfilled with an object representing the result of the + * send operation. + */ + public sendEach(messages: Message[], dryRun?: boolean): Promise { + //TODO: add implementation + console.log(messages, dryRun); + return Promise.resolve({ responses: [], successCount: 0, failureCount: 0 }); + } + + // TODO: Update the comment based on the implementation + /** + * Sends the given multicast message to all the FCM registration tokens + * specified in it. + * + * This method uses the {@link Messaging.sendEach} API under the hood to send the given + * message to all the target recipients. The responses list obtained from the + * return value corresponds to the order of tokens in the `MulticastMessage`. + * An error from this method indicates a total failure -- i.e. the message was + * not sent to any of the tokens in the list. Partial failures are indicated by + * a `BatchResponse` return value. + * + * @param message - A multicast message + * containing up to 500 tokens. + * @param dryRun - Whether to send the message in the dry-run + * (validation only) mode. + * @returns A Promise fulfilled with an object representing the result of the + * send operation. + */ + public sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise { + //TODO: add implementation + console.log(message, dryRun); + return Promise.resolve({ responses: [], successCount: 0, failureCount: 0 }); + } + /** * Sends all the messages in the given array via Firebase Cloud Messaging. * Employs batching to send the entire list as a single RPC call. Compared @@ -268,6 +318,8 @@ export class Messaging { * (validation only) mode. * @returns A Promise fulfilled with an object representing the result of the * send operation. + * + * @deprecated Use {@link Messaging.sendEach} instead. */ public sendAll(messages: Message[], dryRun?: boolean): Promise { if (validator.isArray(messages) && messages.constructor !== Array) { @@ -326,6 +378,8 @@ export class Messaging { * (validation only) mode. * @returns A Promise fulfilled with an object representing the result of the * send operation. + * + * @deprecated Use {@link Messaging.sendEachForMulticast} instead. */ public sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise { const copy: MulticastMessage = deepCopy(message); From a0a02f2f71845871ee7634b5f6ea6674cc4197ad Mon Sep 17 00:00:00 2001 From: Doris-Ge Date: Thu, 30 Mar 2023 16:53:43 -0700 Subject: [PATCH 2/4] Implement `sendEach` and `sendEachForMulticast` (#2097) `sendEach` vs `sendAll` 1. `sendEach` sends one HTTP request to V1 Send endpoint for each message in the array. `sendAll` sends only one HTTP request to V1 Batch Send endpoint to send all messages in the array. 2. `sendEach` calls `Promise.allSettled` to wait for all `httpClient.send` calls to complete and construct a `BatchResponse`. An `httpClient.send` call to V1 Send endpoint either completes with a success or throws an error. So if an error is thrown out, the error will be caught in `sendEach` and turned into a `SendResponse` with an error. Therefore, unlike `sendAll`, `sendEach` does not always throw an error for a total failure. It can also return a `BatchResponse` with only errors in it. `sendEachForMulticast` calls `sendEach` under the hood. --- .../messaging-api-request-internal.ts | 35 ++ src/messaging/messaging.ts | 103 +++- test/unit/messaging/messaging.spec.ts | 540 +++++++++++++++++- 3 files changed, 651 insertions(+), 27 deletions(-) diff --git a/src/messaging/messaging-api-request-internal.ts b/src/messaging/messaging-api-request-internal.ts index 9097ef403c..90be03181f 100644 --- a/src/messaging/messaging-api-request-internal.ts +++ b/src/messaging/messaging-api-request-internal.ts @@ -96,6 +96,34 @@ export class FirebaseMessagingRequestHandler { }); } + /** + * Invokes the request handler with the provided request data. + * + * @param host - The host to which to send the request. + * @param path - The path to which to send the request. + * @param requestData - The request data. + * @returns A promise that resolves with the {@link SendResponse}. + */ + public invokeRequestHandlerForSendResponse(host: string, path: string, requestData: object): Promise { + const request: HttpRequestConfig = { + method: FIREBASE_MESSAGING_HTTP_METHOD, + url: `https://${host}${path}`, + data: requestData, + headers: LEGACY_FIREBASE_MESSAGING_HEADERS, + timeout: FIREBASE_MESSAGING_TIMEOUT, + }; + return this.httpClient.send(request).then((response) => { + return this.buildSendResponse(response); + }) + .catch((err) => { + if (err instanceof HttpError) { + return this.buildSendResponseFromError(err); + } + // Re-throw the error if it already has the proper format. + throw err; + }); + } + /** * Sends the given array of sub requests as a single batch to FCM, and parses the result into * a BatchResponse object. @@ -136,4 +164,11 @@ export class FirebaseMessagingRequestHandler { } return result; } + + private buildSendResponseFromError(err: HttpError): SendResponse { + return { + success: false, + error: createFirebaseError(err) + }; + } } diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index a7de961687..3354e74b55 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -39,6 +39,7 @@ import { MessagingConditionResponse, DataMessagePayload, NotificationMessagePayload, + SendResponse, } from './messaging-api'; // FCM endpoints @@ -250,17 +251,16 @@ export class Messaging { }); } - // TODO: Update the comment based on the implementation /** * Sends each message in the given array via Firebase Cloud Messaging. * - * Unlike {@link Messaging.sendAll}, this method makes a single RPC call for each message in the given array. + * Unlike {@link Messaging.sendAll}, this method makes a single RPC call for each message + * in the given array. * - * The responses list obtained from the return value - * corresponds to the order of `messages`. An error - * from this method indicates a total failure -- i.e. none of the messages in - * the list could be sent. Partial failures are indicated by a `BatchResponse` - * return value. + * The responses list obtained from the return value corresponds to the order of `messages`. + * An error from this method or a `BatchResponse` with all failures indicates a total failure + * -- i.e. none of the messages in the list could be sent. Partial failures or no failures + * are only indicated by a `BatchResponse` return value. * * @param messages - A non-empty array * containing up to 500 messages. @@ -270,12 +270,57 @@ export class Messaging { * send operation. */ public sendEach(messages: Message[], dryRun?: boolean): Promise { - //TODO: add implementation - console.log(messages, dryRun); - return Promise.resolve({ responses: [], successCount: 0, failureCount: 0 }); + if (validator.isArray(messages) && messages.constructor !== Array) { + // In more recent JS specs, an array-like object might have a constructor that is not of + // Array type. Our deepCopy() method doesn't handle them properly. Convert such objects to + // a regular array here before calling deepCopy(). See issue #566 for details. + messages = Array.from(messages); + } + + const copy: Message[] = deepCopy(messages); + if (!validator.isNonEmptyArray(copy)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, 'messages must be a non-empty array'); + } + if (copy.length > FCM_MAX_BATCH_SIZE) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, + `messages list must not contain more than ${FCM_MAX_BATCH_SIZE} items`); + } + if (typeof dryRun !== 'undefined' && !validator.isBoolean(dryRun)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, 'dryRun must be a boolean'); + } + + return this.getUrlPath() + .then((urlPath) => { + const requests: Promise[] = copy.map((message) => { + validateMessage(message); + const request: { message: Message; validate_only?: boolean } = { message }; + if (dryRun) { + request.validate_only = true; + } + return this.messagingRequestHandler.invokeRequestHandlerForSendResponse(FCM_SEND_HOST, urlPath, request); + }); + return Promise.allSettled(requests); + }).then((results) => { + const responses: SendResponse[] = []; + results.forEach(result => { + if (result.status === 'fulfilled') { + responses.push(result.value); + } else { // rejected + responses.push({ success: false, error: result.reason }) + } + }) + const successCount: number = responses.filter((resp) => resp.success).length; + return { + responses, + successCount, + failureCount: responses.length - successCount, + }; + }); } - // TODO: Update the comment based on the implementation /** * Sends the given multicast message to all the FCM registration tokens * specified in it. @@ -283,9 +328,9 @@ export class Messaging { * This method uses the {@link Messaging.sendEach} API under the hood to send the given * message to all the target recipients. The responses list obtained from the * return value corresponds to the order of tokens in the `MulticastMessage`. - * An error from this method indicates a total failure -- i.e. the message was - * not sent to any of the tokens in the list. Partial failures are indicated by - * a `BatchResponse` return value. + * An error from this method or a `BatchResponse` with all failures indicates a total failure + * -- i.e. none of the messages in the list could be sent. Partial failures or no failures + * are only indicated by a `BatchResponse` return value. * * @param message - A multicast message * containing up to 500 tokens. @@ -295,9 +340,33 @@ export class Messaging { * send operation. */ public sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise { - //TODO: add implementation - console.log(message, dryRun); - return Promise.resolve({ responses: [], successCount: 0, failureCount: 0 }); + const copy: MulticastMessage = deepCopy(message); + if (!validator.isNonNullObject(copy)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, 'MulticastMessage must be a non-null object'); + } + if (!validator.isNonEmptyArray(copy.tokens)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, 'tokens must be a non-empty array'); + } + if (copy.tokens.length > FCM_MAX_BATCH_SIZE) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, + `tokens list must not contain more than ${FCM_MAX_BATCH_SIZE} items`); + } + + const messages: Message[] = copy.tokens.map((token) => { + return { + token, + android: copy.android, + apns: copy.apns, + data: copy.data, + notification: copy.notification, + webpush: copy.webpush, + fcmOptions: copy.fcmOptions, + }; + }); + return this.sendEach(messages, dryRun); } /** diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index df459c6ec3..dc978c7866 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -72,11 +72,11 @@ const STATUS_CODE_TO_ERROR_MAP = { 503: 'messaging/server-unavailable', }; -function mockSendRequest(): nock.Scope { +function mockSendRequest(messageId = 'projects/projec_id/messages/message_id'): nock.Scope { return nock(`https://${FCM_SEND_HOST}:443`) .post('/v1/projects/project_id/messages:send') .reply(200, { - name: 'projects/projec_id/messages/message_id', + name: `${messageId}`, }); } @@ -159,7 +159,7 @@ function mockErrorResponse( } function mockSendToDeviceStringRequest(mockFailure = false): nock.Scope { - let deviceResult: object = { message_id: `0:${ mocks.messaging.messageId }` }; + let deviceResult: object = { message_id: `0:${mocks.messaging.messageId}` }; if (mockFailure) { deviceResult = { error: 'InvalidRegistration' }; } @@ -171,7 +171,7 @@ function mockSendToDeviceStringRequest(mockFailure = false): nock.Scope { success: mockFailure ? 0 : 1, failure: mockFailure ? 1 : 0, canonical_ids: 0, - results: [ deviceResult ], + results: [deviceResult], }); } @@ -185,7 +185,7 @@ function mockSendToDeviceArrayRequest(): nock.Scope { canonical_ids: 1, results: [ { - message_id: `0:${ mocks.messaging.messageId }`, + message_id: `0:${mocks.messaging.messageId}`, registration_id: mocks.messaging.registrationToken + '3', }, { error: 'some-error' }, @@ -313,8 +313,8 @@ describe('Messaging', () => { let getTokenStub: sinon.SinonStub; let nullAccessTokenMessaging: Messaging; - let messagingService: {[key: string]: any}; - let nullAccessTokenMessagingService: {[key: string]: any}; + let messagingService: { [key: string]: any }; + let nullAccessTokenMessagingService: { [key: string]: any }; const mockAccessToken: string = utils.generateRandomAccessToken(); const expectedHeaders = { @@ -351,7 +351,7 @@ describe('Messaging', () => { describe('Constructor', () => { const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; invalidApps.forEach((invalidApp) => { - it(`should throw given invalid app: ${ JSON.stringify(invalidApp) }`, () => { + it(`should throw given invalid app: ${JSON.stringify(invalidApp)}`, () => { expect(() => { const messagingAny: any = Messaging; return new messagingAny(invalidApp); @@ -410,7 +410,7 @@ describe('Messaging', () => { {}, { token: null }, { token: '' }, { topic: null }, { topic: '' }, { condition: null }, { condition: '' }, ]; noTarget.forEach((message) => { - it(`should throw given message without target: ${ JSON.stringify(message) }`, () => { + it(`should throw given message without target: ${JSON.stringify(message)}`, () => { expect(() => { messaging.send(message as any); }).to.throw('Exactly one of topic, token or condition is required'); @@ -424,7 +424,7 @@ describe('Messaging', () => { { token: 'a', topic: 'b', condition: 'c' }, ]; multipleTargets.forEach((message) => { - it(`should throw given message without target: ${ JSON.stringify(message)}`, () => { + it(`should throw given message without target: ${JSON.stringify(message)}`, () => { expect(() => { messaging.send(message as any); }).to.throw('Exactly one of topic, token or condition is required'); @@ -558,6 +558,526 @@ describe('Messaging', () => { }); }); + describe('sendEach()', () => { + const validMessage: Message = { token: 'a' }; + + function checkSendResponseSuccess(response: SendResponse, messageId: string): void { + expect(response.success).to.be.true; + expect(response.messageId).to.equal(messageId); + expect(response.error).to.be.undefined; + } + + function checkSendResponseFailure(response: SendResponse, code: string, msg?: string): void { + expect(response.success).to.be.false; + expect(response.messageId).to.be.undefined; + expect(response.error).to.have.property('code', code); + if (msg) { + expect(response.error!.toString()).to.contain(msg); + } + } + + it('should throw given no messages', () => { + expect(() => { + messaging.sendEach(undefined as any); + }).to.throw('messages must be a non-empty array'); + expect(() => { + messaging.sendEach(null as any); + }).to.throw('messages must be a non-empty array'); + expect(() => { + messaging.sendEach([]); + }).to.throw('messages must be a non-empty array'); + }); + + it('should throw when called with more than 500 messages', () => { + const messages: Message[] = []; + for (let i = 0; i < 501; i++) { + messages.push(validMessage); + } + expect(() => { + messaging.sendEach(messages); + }).to.throw('messages list must not contain more than 500 items'); + }); + + it('should reject when a message is invalid', () => { + const invalidMessage: Message = {} as any; + messaging.sendEach([validMessage, invalidMessage]) + .should.eventually.be.rejectedWith('Exactly one of topic, token or condition is required'); + }); + + const invalidDryRun = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidDryRun.forEach((dryRun) => { + it(`should throw given invalid dryRun parameter: ${JSON.stringify(dryRun)}`, () => { + expect(() => { + messaging.sendEach([{ token: 'a' }], dryRun as any); + }).to.throw('dryRun must be a boolean'); + }); + }); + + it('should be fulfilled with a BatchResponse given valid messages', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + return messaging.sendEach([validMessage, validMessage, validMessage]) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); + + it('should be fulfilled with a BatchResponse given array-like (issue #566)', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + const message = { + token: 'a', + android: { + ttl: 3600, + }, + }; + const arrayLike = new CustomArray(); + arrayLike.push(message); + arrayLike.push(message); + arrayLike.push(message); + // Explicitly patch the constructor so that down compiling to ES5 doesn't affect the test. + // See https://github.com/firebase/firebase-admin-node/issues/566#issuecomment-501974238 + // for more context. + arrayLike.constructor = CustomArray; + + return messaging.sendEach(arrayLike) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); + + it('should be fulfilled with a BatchResponse given valid messages in dryRun mode', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + return messaging.sendEach([validMessage, validMessage, validMessage], true) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + expect(response.responses.length).to.equal(3); + response.responses.forEach((resp, idx) => { + checkSendResponseSuccess(resp, messageIds[idx]); + }); + }); + }); + + it('should be fulfilled with a BatchResponse for partial failures', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + }, + }, + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + errors.forEach(error => mockedRequests.push(mockSendError(400, 'json', error))) + return messaging.sendEach([validMessage, validMessage, validMessage], true) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(2); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(3); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseSuccess(responses[1], messageIds[1]); + checkSendResponseFailure( + responses[2], 'messaging/invalid-argument', 'test error message'); + }); + }); + + 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')); + }); + }); + + it('should expose the FCM error code in a detailed error via BatchResponse', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + details: [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + }, + }, + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + errors.forEach(error => mockedRequests.push(mockSendError(404, 'json', error))) + return messaging.sendEach([validMessage, validMessage], true) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(2); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseFailure( + responses[1], 'messaging/registration-token-not-registered'); + }); + }); + + it('should map server error code to client-side error', () => { + const error = { + error: { + status: 'NOT_FOUND', + message: 'test error message', + } + }; + mockedRequests.push(mockSendError(404, 'json', error)); + mockedRequests.push(mockSendError(400, 'json', { error: 'test error message' })); + mockedRequests.push(mockSendError(400, 'text', 'foo bar')); + return messaging.sendEach( + [validMessage, validMessage, validMessage], + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(3); + const responses = response.responses; + checkSendResponseFailure(responses[0], 'messaging/registration-token-not-registered'); + checkSendResponseFailure(responses[1], 'messaging/unknown-error'); + checkSendResponseFailure(responses[2], 'messaging/invalid-argument'); + }); + }); + + // 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', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + const tokenMessage: TokenMessage = { token: 'test' }; + const topicMessage: TopicMessage = { topic: 'test' }; + const conditionMessage: ConditionMessage = { condition: 'test' }; + const messages: Message[] = [tokenMessage, topicMessage, conditionMessage]; + + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + + return messaging.sendEach(messages) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); + }); + + describe('sendEachForMulticast()', () => { + const mockResponse: BatchResponse = { + successCount: 3, + failureCount: 0, + responses: [ + { success: true, messageId: 'projects/projec_id/messages/1' }, + { success: true, messageId: 'projects/projec_id/messages/2' }, + { success: true, messageId: 'projects/projec_id/messages/3' }, + ], + }; + + let stub: sinon.SinonStub | null; + + afterEach(() => { + if (stub) { + stub.restore(); + } + stub = null; + }); + + it('should throw given no messages', () => { + expect(() => { + messaging.sendEachForMulticast(undefined as any); + }).to.throw('MulticastMessage must be a non-null object'); + expect(() => { + messaging.sendEachForMulticast({} as any); + }).to.throw('tokens must be a non-empty array'); + expect(() => { + messaging.sendEachForMulticast({ tokens: [] }); + }).to.throw('tokens must be a non-empty array'); + }); + + it('should throw when called with more than 500 messages', () => { + const tokens: string[] = []; + for (let i = 0; i < 501; i++) { + tokens.push(`token${i}`); + } + expect(() => { + messaging.sendEachForMulticast({ tokens }); + }).to.throw('tokens list must not contain more than 500 items'); + }); + + const invalidDryRun = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidDryRun.forEach((dryRun) => { + it(`should throw given invalid dryRun parameter: ${JSON.stringify(dryRun)}`, () => { + expect(() => { + messaging.sendEachForMulticast({ tokens: ['a'] }, dryRun as any); + }).to.throw('dryRun must be a boolean'); + }); + }); + + it('should create multiple messages using the empty multicast payload', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const tokens = ['a', 'b', 'c']; + return messaging.sendEachForMulticast({ tokens }) + .then((response: BatchResponse) => { + expect(response).to.deep.equal(mockResponse); + expect(stub).to.have.been.calledOnce; + const messages: Message[] = stub!.args[0][0]; + expect(messages.length).to.equal(3); + expect(stub!.args[0][1]).to.be.undefined; + messages.forEach((message, idx) => { + expect((message as TokenMessage).token).to.equal(tokens[idx]); + expect(message.android).to.be.undefined; + expect(message.apns).to.be.undefined; + expect(message.data).to.be.undefined; + expect(message.notification).to.be.undefined; + expect(message.webpush).to.be.undefined; + }); + }); + }); + + it('should create multiple messages using the multicast payload', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const tokens = ['a', 'b', 'c']; + const multicast: MulticastMessage = { + tokens, + android: { ttl: 100 }, + apns: { payload: { aps: { badge: 42 } } }, + data: { key: 'value' }, + notification: { title: 'test title' }, + webpush: { data: { webKey: 'webValue' } }, + fcmOptions: { analyticsLabel: 'label' }, + }; + return messaging.sendEachForMulticast(multicast) + .then((response: BatchResponse) => { + expect(response).to.deep.equal(mockResponse); + expect(stub).to.have.been.calledOnce; + const messages: Message[] = stub!.args[0][0]; + expect(messages.length).to.equal(3); + expect(stub!.args[0][1]).to.be.undefined; + messages.forEach((message, idx) => { + expect((message as TokenMessage).token).to.equal(tokens[idx]); + expect(message.android).to.deep.equal(multicast.android); + expect(message.apns).to.be.deep.equal(multicast.apns); + expect(message.data).to.be.deep.equal(multicast.data); + expect(message.notification).to.deep.equal(multicast.notification); + expect(message.webpush).to.deep.equal(multicast.webpush); + expect(message.fcmOptions).to.deep.equal(multicast.fcmOptions); + }); + }); + }); + + it('should pass dryRun argument through', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const tokens = ['a', 'b', 'c']; + return messaging.sendEachForMulticast({ tokens }, true) + .then((response: BatchResponse) => { + expect(response).to.deep.equal(mockResponse); + expect(stub).to.have.been.calledOnce; + expect(stub!.args[0][1]).to.be.true; + }); + }); + + it('should be fulfilled with a BatchResponse given valid message', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + return messaging.sendEachForMulticast({ + tokens: ['a', 'b', 'c'], + android: { ttl: 100 }, + apns: { payload: { aps: { badge: 42 } } }, + data: { key: 'value' }, + notification: { title: 'test title' }, + webpush: { data: { webKey: 'webValue' } }, + }).then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); + + it('should be fulfilled with a BatchResponse given valid message in dryRun mode', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + return messaging.sendEachForMulticast({ + tokens: ['a', 'b', 'c'], + android: { ttl: 100 }, + apns: { payload: { aps: { badge: 42 } } }, + data: { key: 'value' }, + notification: { title: 'test title' }, + webpush: { data: { webKey: 'webValue' } }, + }, true).then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + expect(response.responses.length).to.equal(3); + response.responses.forEach((resp, idx) => { + checkSendResponseSuccess(resp, messageIds[idx]); + }); + }); + }); + + it('should be fulfilled with a BatchResponse for partial failures', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + }, + }, + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))) + return messaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'] }) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(2); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(3); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseSuccess(responses[1], messageIds[1]); + checkSendResponseFailure( + responses[2], 'messaging/invalid-argument', 'test error message'); + }); + }); + + 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')); + }); + }); + + it('should expose the FCM error code in a detailed error via BatchResponse', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + details: [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + }, + }, + ]; + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))) + return messaging.sendEachForMulticast({ tokens: ['a', 'b'] }) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(2); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseFailure( + responses[1], 'messaging/registration-token-not-registered'); + }); + }); + + it('should map server error code to client-side error', () => { + const error = { + error: { + status: 'NOT_FOUND', + message: 'test error message', + } + }; + mockedRequests.push(mockSendError(404, 'json', error)); + mockedRequests.push(mockSendError(400, 'json', { error: 'test error message' })); + mockedRequests.push(mockSendError(400, 'text', 'foo bar')); + return messaging.sendEachForMulticast( + { tokens: ['a', 'a', 'a'] }, + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(3); + const responses = response.responses; + checkSendResponseFailure(responses[0], 'messaging/registration-token-not-registered'); + checkSendResponseFailure(responses[1], 'messaging/unknown-error'); + checkSendResponseFailure(responses[2], 'messaging/invalid-argument'); + }); + }); + + function checkSendResponseSuccess(response: SendResponse, messageId: string): void { + expect(response.success).to.be.true; + expect(response.messageId).to.equal(messageId); + expect(response.error).to.be.undefined; + } + + function checkSendResponseFailure(response: SendResponse, code: string, msg?: string): void { + expect(response.success).to.be.false; + expect(response.messageId).to.be.undefined; + expect(response.error).to.have.property('code', code); + if (msg) { + expect(response.error!.toString()).to.contain(msg); + } + } + }); + describe('sendAll()', () => { const validMessage: Message = { token: 'a' }; From 6fa3c00996c3ea8bdc53ca71e4170af65f027e73 Mon Sep 17 00:00:00 2001 From: Doris-Ge Date: Thu, 30 Mar 2023 22:37:07 -0700 Subject: [PATCH 3/4] Add integration tests for `sendEach` and `sendMulticast` (#2130) --- test/integration/messaging.spec.ts | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/integration/messaging.spec.ts b/test/integration/messaging.spec.ts index bc3533893e..0a17a2d750 100644 --- a/test/integration/messaging.spec.ts +++ b/test/integration/messaging.spec.ts @@ -108,6 +108,37 @@ describe('admin.messaging', () => { }); }); + it('sendEach()', () => { + const messages: Message[] = [message, message, message]; + return getMessaging().sendEach(messages, true) + .then((response) => { + expect(response.responses.length).to.equal(messages.length); + expect(response.successCount).to.equal(messages.length); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp) => { + expect(resp.success).to.be.true; + expect(resp.messageId).matches(/^projects\/.*\/messages\/.*$/); + }); + }); + }); + + it('sendEach(500)', () => { + const messages: Message[] = []; + for (let i = 0; i < 500; i++) { + messages.push({ topic: `foo-bar-${i % 10}` }); + } + return getMessaging().sendEach(messages, true) + .then((response) => { + expect(response.responses.length).to.equal(messages.length); + expect(response.successCount).to.equal(messages.length); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp) => { + expect(resp.success).to.be.true; + expect(resp.messageId).matches(/^projects\/.*\/messages\/.*$/); + }); + }); + }); + it('sendAll()', () => { const messages: Message[] = [message, message, message]; return getMessaging().sendAll(messages, true) @@ -139,6 +170,25 @@ describe('admin.messaging', () => { }); }); + it('sendEachForMulticast()', () => { + const multicastMessage: MulticastMessage = { + data: message.data, + android: message.android, + tokens: ['not-a-token', 'also-not-a-token'], + }; + return getMessaging().sendEachForMulticast(multicastMessage, true) + .then((response) => { + expect(response.responses.length).to.equal(2); + expect(response.successCount).to.equal(0); + expect(response.failureCount).to.equal(2); + response.responses.forEach((resp) => { + expect(resp.success).to.be.false; + expect(resp.messageId).to.be.undefined; + expect(resp.error).to.have.property('code', 'messaging/invalid-argument'); + }); + }); + }); + it('sendMulticast()', () => { const multicastMessage: MulticastMessage = { data: message.data, From 11eb987c6ae81263e011b4a9a3a3cbae108e67e5 Mon Sep 17 00:00:00 2001 From: Doris Ge Date: Tue, 11 Apr 2023 15:57:06 -0700 Subject: [PATCH 4/4] Avoid using "-- i.e." in the function comment --- src/messaging/messaging.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 3354e74b55..1ad5a036da 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -258,9 +258,9 @@ export class Messaging { * in the given array. * * The responses list obtained from the return value corresponds to the order of `messages`. - * An error from this method or a `BatchResponse` with all failures indicates a total failure - * -- i.e. none of the messages in the list could be sent. Partial failures or no failures - * are only indicated by a `BatchResponse` return value. + * An error from this method or a `BatchResponse` with all failures indicates a total failure, + * meaning that none of the messages in the list could be sent. Partial failures or no + * failures are only indicated by a `BatchResponse` return value. * * @param messages - A non-empty array * containing up to 500 messages. @@ -328,9 +328,9 @@ export class Messaging { * This method uses the {@link Messaging.sendEach} API under the hood to send the given * message to all the target recipients. The responses list obtained from the * return value corresponds to the order of tokens in the `MulticastMessage`. - * An error from this method or a `BatchResponse` with all failures indicates a total failure - * -- i.e. none of the messages in the list could be sent. Partial failures or no failures - * are only indicated by a `BatchResponse` return value. + * An error from this method or a `BatchResponse` with all failures indicates a total + * failure, meaning that the messages in the list could be sent. Partial failures or + * failures are only indicated by a `BatchResponse` return value. * * @param message - A multicast message * containing up to 500 tokens. @@ -377,8 +377,8 @@ export class Messaging { * * The responses list obtained from the return value * corresponds to the order of tokens in the `MulticastMessage`. An error - * from this method indicates a total failure -- i.e. none of the messages in - * the list could be sent. Partial failures are indicated by a `BatchResponse` + * from this method indicates a total failure, meaning that none of the messages + * in the list could be sent. Partial failures are indicated by a `BatchResponse` * return value. * * @param messages - A non-empty array @@ -437,9 +437,9 @@ export class Messaging { * This method uses the `sendAll()` API under the hood to send the given * message to all the target recipients. The responses list obtained from the * return value corresponds to the order of tokens in the `MulticastMessage`. - * An error from this method indicates a total failure -- i.e. the message was - * not sent to any of the tokens in the list. Partial failures are indicated by - * a `BatchResponse` return value. + * An error from this method indicates a total failure, meaning that the message + * was not sent to any of the tokens in the list. Partial failures are indicated + * by a `BatchResponse` return value. * * @param message - A multicast message * containing up to 500 tokens.