From 9846af3324ecaf5d4ce23f8d157ae0e4dd1bfe65 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Wed, 13 May 2026 12:05:35 -0400 Subject: [PATCH 1/6] Add FidMessage and deprecate TokenMessage --- src/messaging/index.ts | 1 + src/messaging/messaging-api.ts | 11 +++++++++-- src/messaging/messaging-internal.ts | 4 ++-- src/messaging/messaging-namespace.ts | 8 ++++++++ test/unit/messaging/messaging.spec.ts | 21 ++++++++++++++------- 5 files changed, 34 insertions(+), 11 deletions(-) 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..feda5888f6 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,9 +47,9 @@ 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 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/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index af4dad1d4d..ed1117ec60 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,13 @@ 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 +332,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 +366,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 +516,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 +712,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; From 97ac410d04c0f7165ae0e5eafa7afd59c01277fb Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Wed, 13 May 2026 15:13:31 -0400 Subject: [PATCH 2/6] Add fids to MulticastMessage interface --- src/messaging/messaging-api.ts | 14 ++- src/messaging/messaging.ts | 39 +++++++-- test/unit/messaging/messaging.spec.ts | 117 +++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 12 deletions(-) diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index feda5888f6..e91c4b0540 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -53,10 +53,20 @@ export type Message = FidMessage | TokenMessage | TopicMessage | ConditionMessag /** * 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.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/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index ed1117ec60..346f8b71a5 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -317,7 +317,11 @@ describe('Messaging', () => { }); const noTarget = [ - {}, { token: null }, { token: '' }, { topic: null }, { topic: '' }, { condition: null }, { condition: '' }, { fid: null }, { fid: '' }, + {}, + { 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)}`, () => { @@ -1014,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]; @@ -1106,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', From 7bdf972342c6a3e798524d506d39512668078bb9 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Wed, 13 May 2026 16:12:29 -0400 Subject: [PATCH 3/6] Add integration tests --- test/integration/messaging.spec.ts | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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) => { From 8b5e5641896250d3dfb2b97c33009d792ecaa3c4 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Wed, 13 May 2026 16:16:02 -0400 Subject: [PATCH 4/6] Extract API docstring --- etc/firebase-admin.api.md | 4 ++++ etc/firebase-admin.messaging.api.md | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) 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; From 96de3324830a3aab225dae2488762b16f92b048f Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 14 May 2026 13:54:42 -0400 Subject: [PATCH 5/6] Change tokens back to required field, add extra docstrings and refactor the messages array construction in MulticastMessage --- etc/firebase-admin.messaging.api.md | 5 +-- src/messaging/messaging-api.ts | 8 +++- src/messaging/messaging.ts | 38 +++++------------ test/integration/messaging.spec.ts | 1 + test/unit/messaging/messaging.spec.ts | 59 ++++++++++++++++++++++----- 5 files changed, 69 insertions(+), 42 deletions(-) diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index 62f977ad16..757030b20b 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -168,9 +168,8 @@ export interface FcmOptions { analyticsLabel?: string; } -// @public (undocumented) +// @public export interface FidMessage extends BaseMessage { - // (undocumented) fid: string; } @@ -337,7 +336,7 @@ export interface MessagingTopicManagementResponse { export interface MulticastMessage extends BaseMessage { fids?: string[]; // @deprecated - tokens?: string[]; + tokens: string[]; } // @public diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index e91c4b0540..97cfbc1683 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -26,7 +26,13 @@ export interface BaseMessage { fcmOptions?: FcmOptions; } +/** + * Interface representing a message that targets a Firebase Installation ID (FID). + */ export interface FidMessage extends BaseMessage { + /** + * The Firebase Installation ID (FID) to which the message should be sent. + */ fid: string; } @@ -66,7 +72,7 @@ export interface MulticastMessage extends BaseMessage { * * @deprecated Use {@link MulticastMessage.fids} instead. */ - tokens?: string[]; + tokens: string[]; } /** diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 5ac8c3cd0b..44a5a4fdaf 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -301,13 +301,13 @@ export class Messaging { const tokens: string[] = copy.tokens || []; const fids: string[] = copy.fids || []; - if ('tokens' in copy && !validator.isNonEmptyArray(copy.tokens)) { + if ('tokens' in copy && !validator.isArray(copy.tokens)) { throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_ARGUMENT, 'tokens must be a non-empty array'); + MessagingClientErrorCode.INVALID_ARGUMENT, 'tokens must be a valid array'); } - if ('fids' in copy && !validator.isNonEmptyArray(copy.fids)) { + if ('fids' in copy && !validator.isArray(copy.fids)) { throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_ARGUMENT, 'fids must be a non-empty array'); + MessagingClientErrorCode.INVALID_ARGUMENT, 'fids must be a valid array'); } if (tokens.length === 0 && fids.length === 0) { throw new FirebaseMessagingError( @@ -318,32 +318,14 @@ export class Messaging { if (totalLength > FCM_MAX_BATCH_SIZE) { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_ARGUMENT, - `tokens and fids list must not contain more than ${FCM_MAX_BATCH_SIZE} items in total`); + `The total number of tokens and fids must not exceed ${FCM_MAX_BATCH_SIZE}.`); } - const messages: Message[] = []; - tokens.forEach((token) => { - messages.push({ - token, - android: copy.android, - apns: copy.apns, - data: copy.data, - 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, - }); - }); + const { tokens: _, fids: __, ...baseMessage } = copy; + const messages: Message[] = [ + ...tokens.map((token) => ({ ...baseMessage, token } as Message)), + ...fids.map((fid) => ({ ...baseMessage, fid } as Message)), + ]; return this.sendEach(messages, dryRun); } diff --git a/test/integration/messaging.spec.ts b/test/integration/messaging.spec.ts index 97aca9a19e..d0ac92c083 100644 --- a/test/integration/messaging.spec.ts +++ b/test/integration/messaging.spec.ts @@ -156,6 +156,7 @@ describe('admin.messaging', () => { const multicastMessage: MulticastMessage = { data: message.data, android: message.android, + tokens: [], fids: ['not-a-fid', 'also-not-a-fid'], }; return getMessaging().sendEachForMulticast(multicastMessage, true) diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index 346f8b71a5..496fbc49f6 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -1012,7 +1012,7 @@ describe('Messaging', () => { stub = null; }); - it('should throw given no messages', () => { + it('should throw given invalid multicast messages or missing required properties', () => { expect(() => { messaging.sendEachForMulticast(undefined as any); }).to.throw('MulticastMessage must be a non-null object'); @@ -1020,11 +1020,17 @@ describe('Messaging', () => { messaging.sendEachForMulticast({} as any); }).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'); + messaging.sendEachForMulticast({ fids: [] } as any); + }).to.throw('Either tokens or fids must be a non-empty array'); + expect(() => { + messaging.sendEachForMulticast({ tokens: 'invalid' as any }); + }).to.throw('tokens must be a valid array'); expect(() => { - messaging.sendEachForMulticast({ fids: [] }); - }).to.throw('fids must be a non-empty array'); + messaging.sendEachForMulticast({ tokens: [], fids: 'invalid' as any }); + }).to.throw('fids must be a valid array'); + expect(() => { + messaging.sendEachForMulticast({ tokens: [], fids: [] }); + }).to.throw('Either tokens or fids must be a non-empty array'); }); it('should throw when called with more than 500 messages in total', () => { @@ -1034,15 +1040,15 @@ describe('Messaging', () => { } expect(() => { messaging.sendEachForMulticast({ tokens }); - }).to.throw('tokens and fids list must not contain more than 500 items in total'); + }).to.throw('The total number of tokens and fids must not exceed 500.'); 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'); + messaging.sendEachForMulticast({ tokens: [], fids }); + }).to.throw('The total number of tokens and fids must not exceed 500.'); const mixedTokens: string[] = []; const mixedFids: string[] = []; @@ -1054,7 +1060,7 @@ describe('Messaging', () => { } expect(() => { messaging.sendEachForMulticast({ tokens: mixedTokens, fids: mixedFids }); - }).to.throw('tokens and fids list must not contain more than 500 items in total'); + }).to.throw('The total number of tokens and fids must not exceed 500.'); }); const invalidDryRun = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; @@ -1137,6 +1143,7 @@ describe('Messaging', () => { stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); const fids = ['f1', 'f2', 'f3']; const multicast: MulticastMessage = { + tokens: [], fids, android: { ttl: 100 }, apns: { payload: { aps: { badge: 42 } } }, @@ -1165,6 +1172,38 @@ describe('Messaging', () => { }); }); + it('should create multiple messages using only fids when tokens is omitted at runtime', () => { + stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); + const fids = ['f1', 'f2', 'f3']; + const multicast = { + 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 as any) + .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']; @@ -1206,7 +1245,7 @@ describe('Messaging', () => { 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) + return messaging.sendEachForMulticast({ tokens: [], fids }, true) .then((response: BatchResponse) => { expect(response).to.deep.equal(mockResponse); expect(stub).to.have.been.calledOnce; From ec3fdc41bd364aba7f15fa2073b1b770bc335de2 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 14 May 2026 15:09:56 -0400 Subject: [PATCH 6/6] Fix the lint error --- src/messaging/messaging.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 44a5a4fdaf..948c03c876 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -298,8 +298,10 @@ export class Messaging { MessagingClientErrorCode.INVALID_ARGUMENT, 'MulticastMessage must be a non-null object'); } - const tokens: string[] = copy.tokens || []; - const fids: string[] = copy.fids || []; + const { tokens, fids, ...baseMessage } = copy; + + const tokenList: string[] = tokens || []; + const fidList: string[] = fids || []; if ('tokens' in copy && !validator.isArray(copy.tokens)) { throw new FirebaseMessagingError( @@ -309,22 +311,21 @@ export class Messaging { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_ARGUMENT, 'fids must be a valid array'); } - if (tokens.length === 0 && fids.length === 0) { + if (tokenList.length === 0 && fidList.length === 0) { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_ARGUMENT, 'Either tokens or fids must be a non-empty array'); } - const totalLength = tokens.length + fids.length; + const totalLength = tokenList.length + fidList.length; if (totalLength > FCM_MAX_BATCH_SIZE) { throw new FirebaseMessagingError( MessagingClientErrorCode.INVALID_ARGUMENT, `The total number of tokens and fids must not exceed ${FCM_MAX_BATCH_SIZE}.`); } - const { tokens: _, fids: __, ...baseMessage } = copy; const messages: Message[] = [ - ...tokens.map((token) => ({ ...baseMessage, token } as Message)), - ...fids.map((fid) => ({ ...baseMessage, fid } as Message)), + ...tokenList.map((token) => ({ ...baseMessage, token } as Message)), + ...fidList.map((fid) => ({ ...baseMessage, fid } as Message)), ]; return this.sendEach(messages, dryRun);