diff --git a/etc/firebase-admin.api.md b/etc/firebase-admin.api.md index e4675c2009..c669bbd46a 100644 --- a/etc/firebase-admin.api.md +++ b/etc/firebase-admin.api.md @@ -374,6 +374,8 @@ export namespace messaging { export type DataMessagePayload = DataMessagePayload; // Warning: (ae-forgotten-export) The symbol "FcmOptions" needs to be exported by the entry point default-namespace.d.ts export type FcmOptions = FcmOptions; + // Warning: (ae-forgotten-export) The symbol "FidMessage" needs to be exported by the entry point default-namespace.d.ts + export type FidMessage = FidMessage; // Warning: (ae-forgotten-export) The symbol "LightSettings" needs to be exported by the entry point default-namespace.d.ts export type LightSettings = LightSettings; // Warning: (ae-forgotten-export) The symbol "Message" needs to be exported by the entry point default-namespace.d.ts @@ -395,6 +397,8 @@ export namespace messaging { // Warning: (ae-forgotten-export) The symbol "SendResponse" needs to be exported by the entry point default-namespace.d.ts export type SendResponse = SendResponse; // Warning: (ae-forgotten-export) The symbol "TokenMessage" needs to be exported by the entry point default-namespace.d.ts + // + // @deprecated export type TokenMessage = TokenMessage; // Warning: (ae-forgotten-export) The symbol "TopicMessage" needs to be exported by the entry point default-namespace.d.ts export type TopicMessage = TopicMessage; diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index b93ba7e58e..62f977ad16 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -168,6 +168,12 @@ export interface FcmOptions { analyticsLabel?: string; } +// @public (undocumented) +export interface FidMessage extends BaseMessage { + // (undocumented) + fid: string; +} + // Warning: (ae-forgotten-export) The symbol "PrefixedFirebaseError" needs to be exported by the entry point index.d.ts // // @public @@ -187,7 +193,7 @@ export interface LightSettings { } // @public -export type Message = TokenMessage | TopicMessage | ConditionMessage; +export type Message = FidMessage | TokenMessage | TopicMessage | ConditionMessage; // @public export class Messaging { @@ -329,8 +335,9 @@ export interface MessagingTopicManagementResponse { // @public export interface MulticastMessage extends BaseMessage { - // (undocumented) - tokens: string[]; + fids?: string[]; + // @deprecated + tokens?: string[]; } // @public @@ -366,7 +373,7 @@ export interface SendResponse { success: boolean; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface TokenMessage extends BaseMessage { // (undocumented) token: string; diff --git a/src/messaging/index.ts b/src/messaging/index.ts index 16054c2c38..c2653f316e 100644 --- a/src/messaging/index.ts +++ b/src/messaging/index.ts @@ -42,6 +42,7 @@ export { CriticalSound, ConditionMessage, FcmOptions, + FidMessage, LightSettings, Message, MessagingTopicManagementResponse, diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index 516c07bb44..e91c4b0540 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -26,6 +26,13 @@ export interface BaseMessage { fcmOptions?: FcmOptions; } +export interface FidMessage extends BaseMessage { + fid: string; +} + +/** + * @deprecated Use {@link FidMessage} instead. + */ export interface TokenMessage extends BaseMessage { token: string; } @@ -40,16 +47,26 @@ export interface ConditionMessage extends BaseMessage { /** * Payload for the {@link Messaging.send} operation. The payload contains all the fields - * in the BaseMessage type, and exactly one of token, topic or condition. + * in the BaseMessage type, and exactly one of fid, token, topic or condition. */ -export type Message = TokenMessage | TopicMessage | ConditionMessage; +export type Message = FidMessage | TokenMessage | TopicMessage | ConditionMessage; /** * Payload for the {@link Messaging.sendEachForMulticast} method. The payload contains all the fields - * in the BaseMessage type, and a list of tokens. + * in the BaseMessage type, and a list of tokens and/or fids. */ export interface MulticastMessage extends BaseMessage { - tokens: string[]; + /** + * A list of Firebase Installation IDs (FIDs) to target. + */ + fids?: string[]; + + /** + * A list of registration tokens to target. + * + * @deprecated Use {@link MulticastMessage.fids} instead. + */ + tokens?: string[]; } /** diff --git a/src/messaging/messaging-internal.ts b/src/messaging/messaging-internal.ts index 6427b98e0a..87e801e2ea 100644 --- a/src/messaging/messaging-internal.ts +++ b/src/messaging/messaging-internal.ts @@ -58,11 +58,11 @@ export function validateMessage(message: Message): void { } } - const targets = [anyMessage.token, anyMessage.topic, anyMessage.condition]; + const targets = [anyMessage.fid, anyMessage.token, anyMessage.topic, anyMessage.condition]; if (targets.filter((v) => validator.isNonEmptyString(v)).length !== 1) { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_PAYLOAD, - 'Exactly one of topic, token or condition is required'); + 'Exactly one of fid, topic, token or condition is required'); } validateStringMap(message.data, 'data'); diff --git a/src/messaging/messaging-namespace.ts b/src/messaging/messaging-namespace.ts index 43627c60ed..40189c1edd 100644 --- a/src/messaging/messaging-namespace.ts +++ b/src/messaging/messaging-namespace.ts @@ -29,6 +29,7 @@ import { CriticalSound as TCriticalSound, ConditionMessage as TConditionMessage, FcmOptions as TFcmOptions, + FidMessage as TFidMessage, LightSettings as TLightSettings, Message as TMessage, MessagingTopicManagementResponse as TMessagingTopicManagementResponse, @@ -174,8 +175,15 @@ export namespace messaging { */ export type SendResponse = TSendResponse; + /** + * Type alias to {@link firebase-admin.messaging#FidMessage}. + */ + export type FidMessage = TFidMessage; + /** * Type alias to {@link firebase-admin.messaging#TokenMessage}. + * + * @deprecated Use {@link firebase-admin.messaging#FidMessage} instead. */ export type TokenMessage = TTokenMessage; diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 05367128f4..5ac8c3cd0b 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -297,18 +297,33 @@ export class Messaging { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_ARGUMENT, 'MulticastMessage must be a non-null object'); } - if (!validator.isNonEmptyArray(copy.tokens)) { + + const tokens: string[] = copy.tokens || []; + const fids: string[] = copy.fids || []; + + if ('tokens' in copy && !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) { + if ('fids' in copy && !validator.isNonEmptyArray(copy.fids)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, 'fids must be a non-empty array'); + } + if (tokens.length === 0 && fids.length === 0) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, 'Either tokens or fids must be a non-empty array'); + } + + const totalLength = tokens.length + fids.length; + if (totalLength > FCM_MAX_BATCH_SIZE) { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_ARGUMENT, - `tokens list must not contain more than ${FCM_MAX_BATCH_SIZE} items`); + `tokens and fids list must not contain more than ${FCM_MAX_BATCH_SIZE} items in total`); } - const messages: Message[] = copy.tokens.map((token) => { - return { + const messages: Message[] = []; + tokens.forEach((token) => { + messages.push({ token, android: copy.android, apns: copy.apns, @@ -316,8 +331,20 @@ export class Messaging { notification: copy.notification, webpush: copy.webpush, fcmOptions: copy.fcmOptions, - }; + }); }); + fids.forEach((fid) => { + messages.push({ + fid, + 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/integration/messaging.spec.ts b/test/integration/messaging.spec.ts index 94304edce8..97aca9a19e 100644 --- a/test/integration/messaging.spec.ts +++ b/test/integration/messaging.spec.ts @@ -34,6 +34,8 @@ const topic = 'mock-topic'; const invalidTopic = 'topic-$%#^'; +const mockFid = 'mock-fid'; + const message: Message = { data: { foo: 'bar', @@ -80,6 +82,13 @@ const message: Message = { topic: 'foo-bar', }; +const fidMessage: Message = { + data: message.data, + android: message.android, + apns: message.apns, + fid: mockFid, +}; + describe('admin.messaging', () => { it('send(message, dryRun) returns a message ID', () => { return getMessaging().send(message, true) @@ -88,6 +97,11 @@ describe('admin.messaging', () => { }); }); + it('send(message with fid, dryRun) fails with invalid-argument', () => { + return getMessaging().send(fidMessage, true) + .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); + }); + it('sendEach()', () => { const messages: Message[] = [message, message, message]; return getMessaging().sendEach(messages, true) @@ -138,6 +152,45 @@ describe('admin.messaging', () => { }); }); + it('sendEachForMulticast() with fids', () => { + const multicastMessage: MulticastMessage = { + data: message.data, + android: message.android, + fids: ['not-a-fid', 'also-not-a-fid'], + }; + 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('sendEachForMulticast() with mixed tokens and fids', () => { + const multicastMessage: MulticastMessage = { + data: message.data, + android: message.android, + tokens: ['not-a-token'], + fids: ['not-a-fid'], + }; + 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('subscribeToTopic() returns a response with success count', () => { return getMessaging().subscribeToTopic(registrationToken, topic) .then((response) => { diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index af4dad1d4d..346f8b71a5 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -29,7 +29,7 @@ import { FirebaseApp } from '../../../src/app/firebase-app'; import { Message, MessagingTopicManagementResponse, BatchResponse, - SendResponse, MulticastMessage, Messaging, TokenMessage, TopicMessage, ConditionMessage, + SendResponse, MulticastMessage, Messaging, TokenMessage, TopicMessage, ConditionMessage, FidMessage, } from '../../../src/messaging/index'; import { HttpClient } from '../../../src/utils/api-request'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils/index'; @@ -317,13 +317,17 @@ describe('Messaging', () => { }); const noTarget = [ - {}, { token: null }, { token: '' }, { topic: null }, { topic: '' }, { condition: null }, { condition: '' }, + {}, + { token: null }, { token: '' }, + { topic: null }, { topic: '' }, + { condition: null }, { condition: '' }, + { fid: null }, { fid: '' }, ]; noTarget.forEach((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'); + }).to.throw('Exactly one of fid, topic, token or condition is required'); }); }); @@ -332,12 +336,16 @@ describe('Messaging', () => { { token: 'a', condition: 'b' }, { condition: 'a', topic: 'b' }, { token: 'a', topic: 'b', condition: 'c' }, + { fid: 'a', token: 'b' }, + { fid: 'a', topic: 'b' }, + { fid: 'a', condition: 'b' }, + { fid: 'a', token: 'b', topic: 'c', condition: 'd' }, ]; multipleTargets.forEach((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'); + }).to.throw('Exactly one of fid, topic, token or condition is required'); }); }); @@ -362,6 +370,7 @@ describe('Messaging', () => { const targetMessages = [ { token: 'mock-token' }, { topic: 'mock-topic' }, { topic: '/topics/mock-topic' }, { condition: '"foo" in topics' }, + { fid: 'mock-fid' }, ]; targetMessages.forEach((message) => { it(`should be fulfilled with a message ID given a valid message: ${JSON.stringify(message)}`, () => { @@ -511,7 +520,7 @@ describe('Messaging', () => { 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'); + .should.eventually.be.rejectedWith('Exactly one of fid, topic, token or condition is required'); }); it('should reject a message when it does not pass local validation, but still try the other messages', () => { @@ -707,17 +716,19 @@ describe('Messaging', () => { 'projects/projec_id/messages/1', 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', + 'projects/projec_id/messages/4', ]; const tokenMessage: TokenMessage = { token: 'test' }; const topicMessage: TopicMessage = { topic: 'test' }; const conditionMessage: ConditionMessage = { condition: 'test' }; - const messages: Message[] = [tokenMessage, topicMessage, conditionMessage]; + const fidMessage: FidMessage = { fid: 'test' }; + const messages: Message[] = [tokenMessage, topicMessage, conditionMessage, fidMessage]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) return legacyMessaging.sendEach(messages) .then((response: BatchResponse) => { - expect(response.successCount).to.equal(3); + expect(response.successCount).to.equal(4); expect(response.failureCount).to.equal(0); response.responses.forEach((resp, idx) => { expect(resp.success).to.be.true; @@ -1007,20 +1018,43 @@ describe('Messaging', () => { }).to.throw('MulticastMessage must be a non-null object'); expect(() => { messaging.sendEachForMulticast({} as any); - }).to.throw('tokens must be a non-empty array'); + }).to.throw('Either tokens or fids must be a non-empty array'); expect(() => { messaging.sendEachForMulticast({ tokens: [] }); }).to.throw('tokens must be a non-empty array'); + expect(() => { + messaging.sendEachForMulticast({ fids: [] }); + }).to.throw('fids must be a non-empty array'); }); - it('should throw when called with more than 500 messages', () => { + it('should throw when called with more than 500 messages in total', () => { 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'); + }).to.throw('tokens and fids list must not contain more than 500 items in total'); + + const fids: string[] = []; + for (let i = 0; i < 501; i++) { + fids.push(`fid${i}`); + } + expect(() => { + messaging.sendEachForMulticast({ fids }); + }).to.throw('tokens and fids list must not contain more than 500 items in total'); + + const mixedTokens: string[] = []; + const mixedFids: string[] = []; + for (let i = 0; i < 250; i++) { + mixedTokens.push(`token${i}`); + } + for (let i = 0; i < 251; i++) { + mixedFids.push(`fid${i}`); + } + expect(() => { + messaging.sendEachForMulticast({ tokens: mixedTokens, fids: mixedFids }); + }).to.throw('tokens and fids list must not contain more than 500 items in total'); }); const invalidDryRun = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; @@ -1099,6 +1133,88 @@ describe('Messaging', () => { }); }); + it('should create multiple messages using fids', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const fids = ['f1', 'f2', 'f3']; + const multicast: MulticastMessage = { + fids, + 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; + expect(stub!.args[0][2]).to.be.undefined; + messages.forEach((message, idx) => { + expect((message as FidMessage).fid).to.equal(fids[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 create multiple messages using mixed tokens and fids', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const tokens = ['t1', 't2']; + const fids = ['f1']; + const multicast: MulticastMessage = { + tokens, + fids, + 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; + expect(stub!.args[0][2]).to.be.undefined; + + expect((messages[0] as TokenMessage).token).to.equal('t1'); + expect((messages[1] as TokenMessage).token).to.equal('t2'); + expect((messages[2] as FidMessage).fid).to.equal('f1'); + + messages.forEach((message) => { + 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 when using fids', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const fids = ['f1', 'f2', 'f3']; + return messaging.sendEachForMulticast({ fids }, true) + .then((response: BatchResponse) => { + expect(response).to.deep.equal(mockResponse); + expect(stub).to.have.been.calledOnce; + expect(stub!.args[0][1]).to.be.true; + expect(stub!.args[0][2]).to.be.undefined; + }); + }); + it('should be fulfilled with a BatchResponse given valid message', () => { const messageIds = [ 'projects/projec_id/messages/1',