diff --git a/etc/firebase-admin.api.md b/etc/firebase-admin.api.md index 6d0670cc75..e9d4bb0e49 100644 --- a/etc/firebase-admin.api.md +++ b/etc/firebase-admin.api.md @@ -380,22 +380,12 @@ export namespace messaging { export type Message = Message; // Warning: (ae-forgotten-export) The symbol "Messaging" needs to be exported by the entry point default-namespace.d.ts export type Messaging = Messaging; - // Warning: (ae-forgotten-export) The symbol "MessagingConditionResponse" needs to be exported by the entry point default-namespace.d.ts - export type MessagingConditionResponse = MessagingConditionResponse; - // Warning: (ae-forgotten-export) The symbol "MessagingDeviceGroupResponse" needs to be exported by the entry point default-namespace.d.ts - export type MessagingDeviceGroupResponse = MessagingDeviceGroupResponse; - // Warning: (ae-forgotten-export) The symbol "MessagingDeviceResult" needs to be exported by the entry point default-namespace.d.ts - export type MessagingDeviceResult = MessagingDeviceResult; - // Warning: (ae-forgotten-export) The symbol "MessagingDevicesResponse" needs to be exported by the entry point default-namespace.d.ts - export type MessagingDevicesResponse = MessagingDevicesResponse; // Warning: (ae-forgotten-export) The symbol "MessagingOptions" needs to be exported by the entry point default-namespace.d.ts export type MessagingOptions = MessagingOptions; // Warning: (ae-forgotten-export) The symbol "MessagingPayload" needs to be exported by the entry point default-namespace.d.ts export type MessagingPayload = MessagingPayload; // Warning: (ae-forgotten-export) The symbol "MessagingTopicManagementResponse" needs to be exported by the entry point default-namespace.d.ts export type MessagingTopicManagementResponse = MessagingTopicManagementResponse; - // Warning: (ae-forgotten-export) The symbol "MessagingTopicResponse" needs to be exported by the entry point default-namespace.d.ts - export type MessagingTopicResponse = MessagingTopicResponse; // Warning: (ae-forgotten-export) The symbol "MulticastMessage" needs to be exported by the entry point default-namespace.d.ts export type MulticastMessage = MulticastMessage; // Warning: (ae-forgotten-export) The symbol "Notification" needs to be exported by the entry point default-namespace.d.ts diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index 493fd9127a..b4ee30a9f0 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -191,20 +191,8 @@ export class Messaging { // @deprecated enableLegacyHttpTransport(): void; 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; - // @deprecated - sendToCondition(condition: string, payload: MessagingPayload, options?: MessagingOptions): Promise; - // @deprecated - sendToDevice(registrationTokenOrTokens: string | string[], payload: MessagingPayload, options?: MessagingOptions): Promise; - // @deprecated - sendToDeviceGroup(notificationKey: string, payload: MessagingPayload, options?: MessagingOptions): Promise; - // @deprecated - sendToTopic(topic: string, payload: MessagingPayload, options?: MessagingOptions): Promise; subscribeToTopic(registrationTokenOrTokens: string | string[], topic: string): Promise; unsubscribeFromTopic(registrationTokenOrTokens: string | string[], topic: string): Promise; } @@ -308,40 +296,6 @@ export class MessagingClientErrorCode { }; } -// @public -export interface MessagingConditionResponse { - messageId: number; -} - -// @public @deprecated -export interface MessagingDeviceGroupResponse { - failedRegistrationTokens: string[]; - failureCount: number; - successCount: number; -} - -// @public @deprecated -export interface MessagingDeviceResult { - canonicalRegistrationToken?: string; - // Warning: (ae-forgotten-export) The symbol "FirebaseError" needs to be exported by the entry point index.d.ts - error?: FirebaseError; - messageId?: string; -} - -// @public @deprecated -export interface MessagingDevicesResponse { - // (undocumented) - canonicalRegistrationTokenCount: number; - // (undocumented) - failureCount: number; - // (undocumented) - multicastId: number; - // (undocumented) - results: MessagingDeviceResult[]; - // (undocumented) - successCount: number; -} - // @public export interface MessagingOptions { // (undocumented) @@ -369,11 +323,6 @@ export interface MessagingTopicManagementResponse { successCount: number; } -// @public -export interface MessagingTopicResponse { - messageId: number; -} - // @public export interface MulticastMessage extends BaseMessage { // (undocumented) @@ -407,6 +356,7 @@ export interface NotificationMessagePayload { // @public export interface SendResponse { + // Warning: (ae-forgotten-export) The symbol "FirebaseError" needs to be exported by the entry point index.d.ts error?: FirebaseError; messageId?: string; success: boolean; diff --git a/src/messaging/batch-request-internal.ts b/src/messaging/batch-request-internal.ts deleted file mode 100644 index e876fdcbd4..0000000000 --- a/src/messaging/batch-request-internal.ts +++ /dev/null @@ -1,141 +0,0 @@ -/*! - * Copyright 2019 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - HttpClient, HttpRequestConfig, RequestResponse, parseHttpResponse, -} from '../utils/api-request'; -import { FirebaseAppError, AppErrorCodes } from '../utils/error'; - -const PART_BOUNDARY = '__END_OF_PART__'; -const FIFTEEN_SECONDS_IN_MILLIS = 15000; - -/** - * Represents a request that can be sent as part of an HTTP batch request. - */ -export interface SubRequest { - url: string; - body: object; - headers?: {[key: string]: any}; -} - -/** - * An HTTP client that can be used to make batch requests. This client is not tied to any service - * (FCM or otherwise). Therefore it can be used to make batch requests to any service that allows - * it. If this requirement ever arises we can move this implementation to the utils module - * where it can be easily shared among other modules. - */ -export class BatchRequestClient { - - /** - * @param {HttpClient} httpClient The client that will be used to make HTTP calls. - * @param {string} batchUrl The URL that accepts batch requests. - * @param {object=} commonHeaders Optional headers that will be included in all requests. - * - * @constructor - */ - constructor( - private readonly httpClient: HttpClient, - private readonly batchUrl: string, - private readonly commonHeaders?: object) { - } - - /** - * Sends the given array of sub requests as a single batch, and parses the results into an array - * of `RequestResponse` objects. - * - * @param requests - An array of sub requests to send. - * @returns A promise that resolves when the send operation is complete. - */ - public send(requests: SubRequest[]): Promise { - requests = requests.map((req) => { - req.headers = Object.assign({}, this.commonHeaders, req.headers); - return req; - }); - const requestHeaders = { - 'Content-Type': `multipart/mixed; boundary=${PART_BOUNDARY}`, - }; - const request: HttpRequestConfig = { - method: 'POST', - url: this.batchUrl, - data: this.getMultipartPayload(requests), - headers: Object.assign({}, this.commonHeaders, requestHeaders), - timeout: FIFTEEN_SECONDS_IN_MILLIS, - }; - return this.httpClient.send(request).then((response) => { - if (!response.multipart) { - throw new FirebaseAppError(AppErrorCodes.INTERNAL_ERROR, 'Expected a multipart response.'); - } - return response.multipart.map((buff) => { - return parseHttpResponse(buff, request); - }); - }); - } - - private getMultipartPayload(requests: SubRequest[]): Buffer { - let buffer = ''; - requests.forEach((request: SubRequest, idx: number) => { - buffer += createPart(request, PART_BOUNDARY, idx); - }); - buffer += `--${PART_BOUNDARY}--\r\n`; - return Buffer.from(buffer, 'utf-8'); - } -} - -/** - * Creates a single part in a multipart HTTP request body. The part consists of several headers - * followed by the serialized sub request as the body. As per the requirements of the FCM batch - * API, sets the content-type header to application/http, and the content-transfer-encoding to - * binary. - * - * @param request - A sub request that will be used to populate the part. - * @param boundary - Multipart boundary string. - * @param idx - An index number that is used to set the content-id header. - * @returns The part as a string that can be included in the HTTP body. - */ -function createPart(request: SubRequest, boundary: string, idx: number): string { - const serializedRequest: string = serializeSubRequest(request); - let part = `--${boundary}\r\n`; - part += `Content-Length: ${serializedRequest.length}\r\n`; - part += 'Content-Type: application/http\r\n'; - part += `content-id: ${idx + 1}\r\n`; - part += 'content-transfer-encoding: binary\r\n'; - part += '\r\n'; - part += `${serializedRequest}\r\n`; - return part; -} - -/** - * Serializes a sub request into a string that can be embedded in a multipart HTTP request. The - * format of the string is the wire format of a typical HTTP request, consisting of a header and a - * body. - * - * @param request - The sub request to be serialized. - * @returns String representation of the SubRequest. - */ -function serializeSubRequest(request: SubRequest): string { - const requestBody: string = JSON.stringify(request.body); - let messagePayload = `POST ${request.url} HTTP/1.1\r\n`; - messagePayload += `Content-Length: ${requestBody.length}\r\n`; - messagePayload += 'Content-Type: application/json; charset=UTF-8\r\n'; - if (request.headers) { - Object.keys(request.headers).forEach((key) => { - messagePayload += `${key}: ${request.headers![key]}\r\n`; - }); - } - messagePayload += '\r\n'; - messagePayload += requestBody; - return messagePayload; -} diff --git a/src/messaging/index.ts b/src/messaging/index.ts index 298a2d5f10..d0a39df2fb 100644 --- a/src/messaging/index.ts +++ b/src/messaging/index.ts @@ -56,13 +56,8 @@ export { // Legacy APIs DataMessagePayload, - MessagingConditionResponse, - MessagingDeviceGroupResponse, - MessagingDeviceResult, - MessagingDevicesResponse, MessagingOptions, MessagingPayload, - MessagingTopicResponse, NotificationMessagePayload, } from './messaging-api'; diff --git a/src/messaging/messaging-api-request-internal.ts b/src/messaging/messaging-api-request-internal.ts index 4af320f20c..07621978f3 100644 --- a/src/messaging/messaging-api-request-internal.ts +++ b/src/messaging/messaging-api-request-internal.ts @@ -22,20 +22,14 @@ import { AuthorizedHttp2Client, Http2SessionHandler, Http2RequestConfig, } from '../utils/api-request'; import { createFirebaseError, getErrorCode } from './messaging-errors-internal'; -import { SubRequest, BatchRequestClient } from './batch-request-internal'; import { getSdkVersion } from '../utils/index'; -import { SendResponse, BatchResponse } from './messaging-api'; +import { SendResponse } from './messaging-api'; // FCM backend constants const FIREBASE_MESSAGING_TIMEOUT = 15000; -const FIREBASE_MESSAGING_BATCH_URL = 'https://fcm.googleapis.com/batch'; const FIREBASE_MESSAGING_HTTP_METHOD: HttpMethod = 'POST'; const FIREBASE_MESSAGING_HEADERS = { - 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, - 'X-Goog-Api-Client': `gl-node/${process.versions.node} fire-admin/${getSdkVersion()}` -}; -const LEGACY_FIREBASE_MESSAGING_HEADERS = { 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, 'X-Goog-Api-Client': `gl-node/${process.versions.node} fire-admin/${getSdkVersion()}`, 'access_token_auth': 'true', @@ -48,7 +42,6 @@ const LEGACY_FIREBASE_MESSAGING_HEADERS = { export class FirebaseMessagingRequestHandler { private readonly httpClient: AuthorizedHttpClient; private readonly http2Client: AuthorizedHttp2Client; - private readonly batchClient: BatchRequestClient; /** * @param app - The app used to fetch access tokens to sign API requests. @@ -57,8 +50,6 @@ export class FirebaseMessagingRequestHandler { constructor(app: App) { this.httpClient = new AuthorizedHttpClient(app as FirebaseApp); this.http2Client = new AuthorizedHttp2Client(app as FirebaseApp); - this.batchClient = new BatchRequestClient( - this.httpClient, FIREBASE_MESSAGING_BATCH_URL, FIREBASE_MESSAGING_HEADERS); } /** @@ -74,7 +65,7 @@ export class FirebaseMessagingRequestHandler { method: FIREBASE_MESSAGING_HTTP_METHOD, url: `https://${host}${path}`, data: requestData, - headers: LEGACY_FIREBASE_MESSAGING_HEADERS, + headers: FIREBASE_MESSAGING_HEADERS, timeout: FIREBASE_MESSAGING_TIMEOUT, }; return this.httpClient.send(request).then((response) => { @@ -116,7 +107,7 @@ export class FirebaseMessagingRequestHandler { method: FIREBASE_MESSAGING_HTTP_METHOD, url: `https://${host}${path}`, data: requestData, - headers: LEGACY_FIREBASE_MESSAGING_HEADERS, + headers: FIREBASE_MESSAGING_HEADERS, timeout: FIREBASE_MESSAGING_TIMEOUT, }; return this.httpClient.send(request).then((response) => { @@ -146,7 +137,7 @@ export class FirebaseMessagingRequestHandler { method: FIREBASE_MESSAGING_HTTP_METHOD, url: `https://${host}${path}`, data: requestData, - headers: LEGACY_FIREBASE_MESSAGING_HEADERS, + headers: FIREBASE_MESSAGING_HEADERS, timeout: FIREBASE_MESSAGING_TIMEOUT, http2SessionHandler: http2SessionHandler }; @@ -162,35 +153,6 @@ export class FirebaseMessagingRequestHandler { }); } - /** - * Sends the given array of sub requests as a single batch to FCM, and parses the result into - * a `BatchResponse` object. - * - * @param requests - An array of sub requests to send. - * @returns A promise that resolves when the send operation is complete. - */ - public sendBatchRequest(requests: SubRequest[]): Promise { - return this.batchClient.send(requests) - .then((responses: RequestResponse[]) => { - return responses.map((part: RequestResponse) => { - return this.buildSendResponse(part); - }); - }).then((responses: SendResponse[]) => { - const successCount: number = responses.filter((resp) => resp.success).length; - return { - responses, - successCount, - failureCount: responses.length - successCount, - }; - }).catch((err) => { - if (err instanceof RequestResponseError) { - throw createFirebaseError(err); - } - // Re-throw the error if it already has the proper format. - throw err; - }); - } - private buildSendResponse(response: RequestResponse): SendResponse { const result: SendResponse = { success: response.status === 200, diff --git a/src/messaging/messaging-api.ts b/src/messaging/messaging-api.ts index 8d85faa7a7..01c20c7291 100644 --- a/src/messaging/messaging-api.ts +++ b/src/messaging/messaging-api.ts @@ -45,7 +45,7 @@ export interface ConditionMessage extends BaseMessage { export type Message = TokenMessage | TopicMessage | ConditionMessage; /** - * Payload for the {@link Messaging.sendMulticast} method. The payload contains all the fields + * Payload for the {@link Messaging.sendEachForMulticast} method. The payload contains all the fields * in the BaseMessage type, and a list of tokens. */ export interface MulticastMessage extends BaseMessage { @@ -946,107 +946,6 @@ export interface MessagingOptions { [key: string]: any | undefined; } -/** - * Individual status response payload from single devices - * - * @deprecated Returned by {@link Messaging#sendToDevice}, which is also deprecated. - */ -export interface MessagingDeviceResult { - /** - * The error that occurred when processing the message for the recipient. - */ - error?: FirebaseError; - - /** - * A unique ID for the successfully processed message. - */ - messageId?: string; - - /** - * The canonical registration token for the client app that the message was - * processed and sent to. You should use this value as the registration token - * for future requests. Otherwise, future messages might be rejected. - */ - canonicalRegistrationToken?: string; -} - -/** - * Interface representing the status of a message sent to an individual device - * via the FCM legacy APIs. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/admin/send-messages#send_to_individual_devices | - * Send to individual devices} for code samples and detailed documentation. - * - * @deprecated Returned by {@link Messaging.sendToDevice}, which is also deprecated. - */ -export interface MessagingDevicesResponse { - canonicalRegistrationTokenCount: number; - failureCount: number; - multicastId: number; - results: MessagingDeviceResult[]; - successCount: number; -} - -/** - * Interface representing the server response from the {@link Messaging.sendToDeviceGroup} - * method. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/send-message?authuser=0#send_messages_to_device_groups | - * Send messages to device groups} for code samples and detailed documentation. - * - * @deprecated Returned by {@link Messaging.sendToDeviceGroup}, which is also deprecated. - */ -export interface MessagingDeviceGroupResponse { - - /** - * The number of messages that could not be processed and resulted in an error. - */ - successCount: number; - - /** - * The number of messages that could not be processed and resulted in an error. - */ - failureCount: number; - - /** - * An array of registration tokens that failed to receive the message. - */ - failedRegistrationTokens: string[]; -} - -/** - * Interface representing the server response from the legacy {@link Messaging.sendToTopic} method. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/admin/send-messages#send_to_a_topic | - * Send to a topic} for code samples and detailed documentation. - */ -export interface MessagingTopicResponse { - /** - * The message ID for a successfully received request which FCM will attempt to - * deliver to all subscribed devices. - */ - messageId: number; -} - -/** - * Interface representing the server response from the legacy - * {@link Messaging.sendToCondition} method. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/admin/send-messages#send_to_a_condition | - * Send to a condition} for code samples and detailed documentation. - */ -export interface MessagingConditionResponse { - /** - * The message ID for a successfully received request which FCM will attempt to - * deliver to all subscribed devices. - */ - messageId: number; -} - /** * Interface representing the server response from the * {@link Messaging.subscribeToTopic} and {@link Messaging.unsubscribeFromTopic} @@ -1078,7 +977,7 @@ export interface MessagingTopicManagementResponse { /** * Interface representing the server response from the - * {@link Messaging.sendAll} and {@link Messaging.sendMulticast} methods. + * {@link Messaging.sendEach} and {@link Messaging.sendEachForMulticast} methods. */ export interface BatchResponse { diff --git a/src/messaging/messaging-namespace.ts b/src/messaging/messaging-namespace.ts index 8171e9c212..40ca3a3ed2 100644 --- a/src/messaging/messaging-namespace.ts +++ b/src/messaging/messaging-namespace.ts @@ -43,13 +43,8 @@ import { // Legacy APIs DataMessagePayload as TDataMessagePayload, - MessagingConditionResponse as TMessagingConditionResponse, - MessagingDeviceGroupResponse as TMessagingDeviceGroupResponse, - MessagingDeviceResult as TMessagingDeviceResult, - MessagingDevicesResponse as TMessagingDevicesResponse, MessagingOptions as TMessagingOptions, MessagingPayload as TMessagingPayload, - MessagingTopicResponse as TMessagingTopicResponse, NotificationMessagePayload as TNotificationMessagePayload, } from './messaging-api'; @@ -211,26 +206,6 @@ export namespace messaging { */ export type DataMessagePayload = TDataMessagePayload; - /** - * Type alias to {@link firebase-admin.messaging#MessagingConditionResponse}. - */ - export type MessagingConditionResponse = TMessagingConditionResponse; - - /** - * Type alias to {@link firebase-admin.messaging#MessagingDeviceGroupResponse}. - */ - export type MessagingDeviceGroupResponse = TMessagingDeviceGroupResponse; - - /** - * Type alias to {@link firebase-admin.messaging#MessagingDeviceResult}. - */ - export type MessagingDeviceResult = TMessagingDeviceResult; - - /** - * Type alias to {@link firebase-admin.messaging#MessagingDevicesResponse}. - */ - export type MessagingDevicesResponse = TMessagingDevicesResponse; - /** * Type alias to {@link firebase-admin.messaging#MessagingOptions}. */ @@ -241,11 +216,6 @@ export namespace messaging { */ export type MessagingPayload = TMessagingPayload; - /** - * Type alias to {@link firebase-admin.messaging#MessagingTopicResponse}. - */ - export type MessagingTopicResponse = TMessagingTopicResponse; - /** * Type alias to {@link firebase-admin.messaging#NotificationMessagePayload}. */ diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 38c6edcc42..1c1e2a5887 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -16,12 +16,11 @@ */ import { App } from '../app'; -import { deepCopy, deepExtend } from '../utils/deep-copy'; -import { SubRequest } from './batch-request-internal'; +import { deepCopy } from '../utils/deep-copy'; import { ErrorInfo, MessagingClientErrorCode, FirebaseMessagingError } from '../utils/error'; import * as utils from '../utils'; import * as validator from '../utils/validator'; -import { validateMessage, BLACKLISTED_DATA_PAYLOAD_KEYS, BLACKLISTED_OPTIONS_KEYS } from './messaging-internal'; +import { validateMessage } from './messaging-internal'; import { FirebaseMessagingRequestHandler } from './messaging-api-request-internal'; import { @@ -31,21 +30,12 @@ import { MulticastMessage, // Legacy API types - MessagingDevicesResponse, - MessagingDeviceGroupResponse, - MessagingPayload, - MessagingOptions, - MessagingTopicResponse, - MessagingConditionResponse, - DataMessagePayload, - NotificationMessagePayload, SendResponse, } from './messaging-api'; import { Http2SessionHandler } from '../utils/api-request'; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; -const FCM_SEND_PATH = '/fcm/send'; const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; @@ -53,101 +43,6 @@ const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; // Maximum messages that can be included in a batch request. const FCM_MAX_BATCH_SIZE = 500; -// Key renames for the messaging notification payload object. -const CAMELCASED_NOTIFICATION_PAYLOAD_KEYS_MAP = { - bodyLocArgs: 'body_loc_args', - bodyLocKey: 'body_loc_key', - clickAction: 'click_action', - titleLocArgs: 'title_loc_args', - titleLocKey: 'title_loc_key', -}; - -// Key renames for the messaging options object. -const CAMELCASE_OPTIONS_KEYS_MAP = { - dryRun: 'dry_run', - timeToLive: 'time_to_live', - collapseKey: 'collapse_key', - mutableContent: 'mutable_content', - contentAvailable: 'content_available', - restrictedPackageName: 'restricted_package_name', -}; - -// Key renames for the MessagingDeviceResult object. -const MESSAGING_DEVICE_RESULT_KEYS_MAP = { - message_id: 'messageId', - registration_id: 'canonicalRegistrationToken', -}; - -// Key renames for the MessagingDevicesResponse object. -const MESSAGING_DEVICES_RESPONSE_KEYS_MAP = { - canonical_ids: 'canonicalRegistrationTokenCount', - failure: 'failureCount', - success: 'successCount', - multicast_id: 'multicastId', -}; - -// Key renames for the MessagingDeviceGroupResponse object. -const MESSAGING_DEVICE_GROUP_RESPONSE_KEYS_MAP = { - success: 'successCount', - failure: 'failureCount', - failed_registration_ids: 'failedRegistrationTokens', -}; - -// Key renames for the MessagingTopicResponse object. -const MESSAGING_TOPIC_RESPONSE_KEYS_MAP = { - message_id: 'messageId', -}; - -// Key renames for the MessagingConditionResponse object. -const MESSAGING_CONDITION_RESPONSE_KEYS_MAP = { - message_id: 'messageId', -}; - -/** - * Maps a raw FCM server response to a `MessagingDevicesResponse` object. - * - * @param response - The raw FCM server response to map. - * - * @returns The mapped `MessagingDevicesResponse` object. - */ -function mapRawResponseToDevicesResponse(response: object): MessagingDevicesResponse { - // Rename properties on the server response - utils.renameProperties(response, MESSAGING_DEVICES_RESPONSE_KEYS_MAP); - if ('results' in response) { - (response as any).results.forEach((messagingDeviceResult: any) => { - utils.renameProperties(messagingDeviceResult, MESSAGING_DEVICE_RESULT_KEYS_MAP); - - // Map the FCM server's error strings to actual error objects. - if ('error' in messagingDeviceResult) { - const newError = FirebaseMessagingError.fromServerError( - messagingDeviceResult.error, /* message */ undefined, messagingDeviceResult.error, - ); - messagingDeviceResult.error = newError; - } - }); - } - - return response as MessagingDevicesResponse; -} - -/** - * Maps a raw FCM server response to a `MessagingDeviceGroupResponse` object. - * - * @param response - The raw FCM server response to map. - * - * @returns The mapped `MessagingDeviceGroupResponse` object. - */ -function mapRawResponseToDeviceGroupResponse(response: object): MessagingDeviceGroupResponse { - // Rename properties on the server response - utils.renameProperties(response, MESSAGING_DEVICE_GROUP_RESPONSE_KEYS_MAP); - - // Add the 'failedRegistrationTokens' property if it does not exist on the response, which - // it won't when the 'failureCount' property has a value of 0) - (response as any).failedRegistrationTokens = (response as any).failedRegistrationTokens || []; - - return response as MessagingDeviceGroupResponse; -} - /** * Maps a raw FCM server response to a `MessagingTopicManagementResponse` object. * @@ -273,7 +168,7 @@ export class Messaging { /** * 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 + * 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`. @@ -401,389 +296,6 @@ export class Messaging { return this.sendEach(messages, dryRun); } - /** - * 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 - * to the `send()` method, this method is a significantly more efficient way - * to send multiple messages. - * - * 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, 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 - * 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. - * - * @deprecated Use {@link Messaging.sendEach} instead. - */ - public sendAll(messages: Message[], dryRun?: boolean): Promise { - 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: SubRequest[] = copy.map((message) => { - validateMessage(message); - const request: { message: Message; validate_only?: boolean } = { message }; - if (dryRun) { - request.validate_only = true; - } - return { - url: `https://${FCM_SEND_HOST}${urlPath}`, - body: request, - }; - }); - return this.messagingRequestHandler.sendBatchRequest(requests); - }); - } - - /** - * Sends the given multicast message to all the FCM registration tokens - * specified in it. - * - * 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, 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. - * @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. - * - * @deprecated Use {@link Messaging.sendEachForMulticast} instead. - */ - public sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise { - 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.sendAll(messages, dryRun); - } - - /** - * Sends an FCM message to a single device corresponding to the provided - * registration token. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/admin/legacy-fcm#send_to_individual_devices | - * Send to individual devices} - * for code samples and detailed documentation. Takes either a - * `registrationToken` to send to a single device or a - * `registrationTokens` parameter containing an array of tokens to send - * to multiple devices. - * - * @param registrationToken - A device registration token or an array of - * device registration tokens to which the message should be sent. - * @param payload - The message payload. - * @param options - Optional options to - * alter the message. - * - * @returns A promise fulfilled with the server's response after the message - * has been sent. - * - * @deprecated Use {@link Messaging.send} instead. - */ - public sendToDevice( - registrationTokenOrTokens: string | string[], - payload: MessagingPayload, - options: MessagingOptions = {}, - ): Promise { - // Validate the input argument types. Since these are common developer errors when getting - // started, throw an error instead of returning a rejected promise. - this.validateRegistrationTokensType( - registrationTokenOrTokens, 'sendToDevice', MessagingClientErrorCode.INVALID_RECIPIENT, - ); - this.validateMessagingPayloadAndOptionsTypes(payload, options); - - return Promise.resolve() - .then(() => { - // Validate the contents of the input arguments. Because we are now in a promise, any thrown - // error will cause this method to return a rejected promise. - this.validateRegistrationTokens( - registrationTokenOrTokens, 'sendToDevice', MessagingClientErrorCode.INVALID_RECIPIENT, - ); - const payloadCopy = this.validateMessagingPayload(payload); - const optionsCopy = this.validateMessagingOptions(options); - - const request: any = deepCopy(payloadCopy); - deepExtend(request, optionsCopy); - - if (validator.isString(registrationTokenOrTokens)) { - request.to = registrationTokenOrTokens; - } else { - request.registration_ids = registrationTokenOrTokens; - } - - return this.messagingRequestHandler.invokeRequestHandler(FCM_SEND_HOST, FCM_SEND_PATH, request); - }) - .then((response) => { - // The sendToDevice() and sendToDeviceGroup() methods both set the `to` query parameter in - // the underlying FCM request. If the provided registration token argument is actually a - // valid notification key, the response from the FCM server will be a device group response. - // If that is the case, we map the response to a MessagingDeviceGroupResponse. - // See b/35394951 for more context. - if ('multicast_id' in response) { - return mapRawResponseToDevicesResponse(response); - } else { - const groupResponse = mapRawResponseToDeviceGroupResponse(response); - return { - ...groupResponse, - canonicalRegistrationTokenCount: -1, - multicastId: -1, - results: [], - } - } - }); - } - - /** - * Sends an FCM message to a device group corresponding to the provided - * notification key. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/admin/legacy-fcm#send_to_a_device_group | - * Send to a device group} for code samples and detailed documentation. - * - * @param notificationKey - The notification key for the device group to - * which to send the message. - * @param payload - The message payload. - * @param options - Optional options to - * alter the message. - * - * @returns A promise fulfilled with the server's response after the message - * has been sent. - * - * @deprecated Use {@link Messaging.send} instead. - */ - public sendToDeviceGroup( - notificationKey: string, - payload: MessagingPayload, - options: MessagingOptions = {}, - ): Promise { - if (!validator.isNonEmptyString(notificationKey)) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_RECIPIENT, - 'Notification key provided to sendToDeviceGroup() must be a non-empty string.', - ); - } else if (notificationKey.indexOf(':') !== -1) { - // It is possible the developer provides a registration token instead of a notification key - // to this method. We can detect some of those cases by checking to see if the string contains - // a colon. Not all registration tokens will contain a colon (only newer ones will), but no - // notification keys will contain a colon, so we can use it as a rough heuristic. - // See b/35394951 for more context. - return Promise.reject(new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_RECIPIENT, - 'Notification key provided to sendToDeviceGroup() has the format of a registration token. ' + - 'You should use sendToDevice() instead.', - )); - } - - // Validate the types of the payload and options arguments. Since these are common developer - // errors, throw an error instead of returning a rejected promise. - this.validateMessagingPayloadAndOptionsTypes(payload, options); - - return Promise.resolve() - .then(() => { - // Validate the contents of the payload and options objects. Because we are now in a - // promise, any thrown error will cause this method to return a rejected promise. - const payloadCopy = this.validateMessagingPayload(payload); - const optionsCopy = this.validateMessagingOptions(options); - - const request: any = deepCopy(payloadCopy); - deepExtend(request, optionsCopy); - request.to = notificationKey; - - return this.messagingRequestHandler.invokeRequestHandler(FCM_SEND_HOST, FCM_SEND_PATH, request); - }) - .then((response) => { - // The sendToDevice() and sendToDeviceGroup() methods both set the `to` query parameter in - // the underlying FCM request. If the provided notification key argument has an invalid - // format (that is, it is either a registration token or some random string), the response - // from the FCM server will default to a devices response (which we detect by looking for - // the `multicast_id` property). If that is the case, we either throw an error saying the - // provided notification key is invalid (if the message failed to send) or map the response - // to a MessagingDevicesResponse (if the message succeeded). - // See b/35394951 for more context. - if ('multicast_id' in response) { - if ((response as any).success === 0) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_RECIPIENT, - 'Notification key provided to sendToDeviceGroup() is invalid.', - ); - } else { - const devicesResponse = mapRawResponseToDevicesResponse(response); - return { - ...devicesResponse, - failedRegistrationTokens: [], - } - } - } - - return mapRawResponseToDeviceGroupResponse(response); - }); - } - - /** - * Sends an FCM message to a topic. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/admin/legacy-fcm#send_to_a_topic | - * Send to a topic} for code samples and detailed documentation. - * - * @param topic - The topic to which to send the message. - * @param payload - The message payload. - * @param options - Optional options to - * alter the message. - * - * @returns A promise fulfilled with the server's response after the message - * has been sent. - * - * @deprecated Use {@link Messaging.send} instead. - */ - public sendToTopic( - topic: string, - payload: MessagingPayload, - options: MessagingOptions = {}, - ): Promise { - // Validate the input argument types. Since these are common developer errors when getting - // started, throw an error instead of returning a rejected promise. - this.validateTopicType(topic, 'sendToTopic', MessagingClientErrorCode.INVALID_RECIPIENT); - this.validateMessagingPayloadAndOptionsTypes(payload, options); - - // Prepend the topic with /topics/ if necessary. - topic = this.normalizeTopic(topic); - - return Promise.resolve() - .then(() => { - // Validate the contents of the payload and options objects. Because we are now in a - // promise, any thrown error will cause this method to return a rejected promise. - const payloadCopy = this.validateMessagingPayload(payload); - const optionsCopy = this.validateMessagingOptions(options); - this.validateTopic(topic, 'sendToTopic', MessagingClientErrorCode.INVALID_RECIPIENT); - - const request: any = deepCopy(payloadCopy); - deepExtend(request, optionsCopy); - request.to = topic; - - return this.messagingRequestHandler.invokeRequestHandler(FCM_SEND_HOST, FCM_SEND_PATH, request); - }) - .then((response) => { - // Rename properties on the server response - utils.renameProperties(response, MESSAGING_TOPIC_RESPONSE_KEYS_MAP); - - return response as MessagingTopicResponse; - }); - } - - /** - * Sends an FCM message to a condition. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/admin/legacy-fcm#send_to_a_condition | - * Send to a condition} - * for code samples and detailed documentation. - * - * @param condition - The condition determining to which topics to send - * the message. - * @param payload - The message payload. - * @param options - Optional options to - * alter the message. - * - * @returns A promise fulfilled with the server's response after the message - * has been sent. - * - * @deprecated Use {@link Messaging.send} instead. - */ - public sendToCondition( - condition: string, - payload: MessagingPayload, - options: MessagingOptions = {}, - ): Promise { - if (!validator.isNonEmptyString(condition)) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_RECIPIENT, - 'Condition provided to sendToCondition() must be a non-empty string.', - ); - } - // Validate the types of the payload and options arguments. Since these are common developer - // errors, throw an error instead of returning a rejected promise. - this.validateMessagingPayloadAndOptionsTypes(payload, options); - - // The FCM server rejects conditions which are surrounded in single quotes. When the condition - // is stringified over the wire, double quotes in it get converted to \" which the FCM server - // does not properly handle. We can get around this by replacing internal double quotes with - // single quotes. - condition = condition.replace(/"/g, '\''); - - return Promise.resolve() - .then(() => { - // Validate the contents of the payload and options objects. Because we are now in a - // promise, any thrown error will cause this method to return a rejected promise. - const payloadCopy = this.validateMessagingPayload(payload); - const optionsCopy = this.validateMessagingOptions(options); - - const request: any = deepCopy(payloadCopy); - deepExtend(request, optionsCopy); - request.condition = condition; - - return this.messagingRequestHandler.invokeRequestHandler(FCM_SEND_HOST, FCM_SEND_PATH, request); - }) - .then((response) => { - // Rename properties on the server response - utils.renameProperties(response, MESSAGING_CONDITION_RESPONSE_KEYS_MAP); - - return response as MessagingConditionResponse; - }); - } - /** * Subscribes a device to an FCM topic. * @@ -911,203 +423,6 @@ export class Messaging { }); } - /** - * Validates the types of the messaging payload and options. If invalid, an error will be thrown. - * - * @param payload - The messaging payload to validate. - * @param options - The messaging options to validate. - */ - private validateMessagingPayloadAndOptionsTypes( - payload: MessagingPayload, - options: MessagingOptions, - ): void { - // Validate the payload is an object - if (!validator.isNonNullObject(payload)) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - 'Messaging payload must be an object with at least one of the "data" or "notification" properties.', - ); - } - - // Validate the options argument is an object - if (!validator.isNonNullObject(options)) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - 'Messaging options must be an object.', - ); - } - } - - /** - * Validates the messaging payload. If invalid, an error will be thrown. - * - * @param payload - The messaging payload to validate. - * - * @returns A copy of the provided payload with whitelisted properties switched - * from camelCase to underscore_case. - */ - private validateMessagingPayload(payload: MessagingPayload): MessagingPayload { - const payloadCopy: MessagingPayload = deepCopy(payload); - - const payloadKeys = Object.keys(payloadCopy); - const validPayloadKeys = ['data', 'notification']; - - let containsDataOrNotificationKey = false; - payloadKeys.forEach((payloadKey) => { - // Validate the payload does not contain any invalid keys - if (validPayloadKeys.indexOf(payloadKey) === -1) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - `Messaging payload contains an invalid "${payloadKey}" property. Valid properties are ` + - '"data" and "notification".', - ); - } else { - containsDataOrNotificationKey = true; - } - }); - - // Validate the payload contains at least one of the "data" and "notification" keys - if (!containsDataOrNotificationKey) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - 'Messaging payload must contain at least one of the "data" or "notification" properties.', - ); - } - - const validatePayload = (payloadKey: string, value: DataMessagePayload | NotificationMessagePayload): void => { - // Validate each top-level key in the payload is an object - if (!validator.isNonNullObject(value)) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - `Messaging payload contains an invalid value for the "${payloadKey}" property. ` + - 'Value must be an object.', - ); - } - - Object.keys(value).forEach((subKey) => { - if (!validator.isString(value[subKey])) { - // Validate all sub-keys have a string value - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - `Messaging payload contains an invalid value for the "${payloadKey}.${subKey}" ` + - 'property. Values must be strings.', - ); - } else if (payloadKey === 'data' && /^google\./.test(subKey)) { - // Validate the data payload does not contain keys which start with 'google.'. - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - `Messaging payload contains the blacklisted "data.${subKey}" property.`, - ); - } - }); - }; - - if (payloadCopy.data !== undefined) { - validatePayload('data', payloadCopy.data); - } - if (payloadCopy.notification !== undefined) { - validatePayload('notification', payloadCopy.notification); - } - - // Validate the data payload object does not contain blacklisted properties - if ('data' in payloadCopy) { - BLACKLISTED_DATA_PAYLOAD_KEYS.forEach((blacklistedKey) => { - if (blacklistedKey in payloadCopy.data!) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_PAYLOAD, - `Messaging payload contains the blacklisted "data.${blacklistedKey}" property.`, - ); - } - }); - } - - // Convert whitelisted camelCase keys to underscore_case - if (payloadCopy.notification) { - utils.renameProperties(payloadCopy.notification, CAMELCASED_NOTIFICATION_PAYLOAD_KEYS_MAP); - } - - return payloadCopy; - } - - /** - * Validates the messaging options. If invalid, an error will be thrown. - * - * @param options - The messaging options to validate. - * - * @returns A copy of the provided options with whitelisted properties switched - * from camelCase to underscore_case. - */ - private validateMessagingOptions(options: MessagingOptions): MessagingOptions { - const optionsCopy: MessagingOptions = deepCopy(options); - - // Validate the options object does not contain blacklisted properties - BLACKLISTED_OPTIONS_KEYS.forEach((blacklistedKey) => { - if (blacklistedKey in optionsCopy) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains the blacklisted "${blacklistedKey}" property.`, - ); - } - }); - - // Convert whitelisted camelCase keys to underscore_case - utils.renameProperties(optionsCopy, CAMELCASE_OPTIONS_KEYS_MAP); - - // Validate the options object contains valid values for whitelisted properties - if ('collapse_key' in optionsCopy && !validator.isNonEmptyString((optionsCopy as any).collapse_key)) { - const keyName = ('collapseKey' in options) ? 'collapseKey' : 'collapse_key'; - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains an invalid value for the "${keyName}" property. Value must ` + - 'be a non-empty string.', - ); - } else if ('dry_run' in optionsCopy && !validator.isBoolean((optionsCopy as any).dry_run)) { - const keyName = ('dryRun' in options) ? 'dryRun' : 'dry_run'; - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains an invalid value for the "${keyName}" property. Value must ` + - 'be a boolean.', - ); - } else if ('priority' in optionsCopy && !validator.isNonEmptyString(optionsCopy.priority)) { - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - 'Messaging options contains an invalid value for the "priority" property. Value must ' + - 'be a non-empty string.', - ); - } else if ('restricted_package_name' in optionsCopy && - !validator.isNonEmptyString((optionsCopy as any).restricted_package_name)) { - const keyName = ('restrictedPackageName' in options) ? 'restrictedPackageName' : 'restricted_package_name'; - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains an invalid value for the "${keyName}" property. Value must ` + - 'be a non-empty string.', - ); - } else if ('time_to_live' in optionsCopy && !validator.isNumber((optionsCopy as any).time_to_live)) { - const keyName = ('timeToLive' in options) ? 'timeToLive' : 'time_to_live'; - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains an invalid value for the "${keyName}" property. Value must ` + - 'be a number.', - ); - } else if ('content_available' in optionsCopy && !validator.isBoolean((optionsCopy as any).content_available)) { - const keyName = ('contentAvailable' in options) ? 'contentAvailable' : 'content_available'; - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains an invalid value for the "${keyName}" property. Value must ` + - 'be a boolean.', - ); - } else if ('mutable_content' in optionsCopy && !validator.isBoolean((optionsCopy as any).mutable_content)) { - const keyName = ('mutableContent' in options) ? 'mutableContent' : 'mutable_content'; - throw new FirebaseMessagingError( - MessagingClientErrorCode.INVALID_OPTIONS, - `Messaging options contains an invalid value for the "${keyName}" property. Value must ` + - 'be a boolean.', - ); - } - - return optionsCopy; - } - /** * Validates the type of the provided registration token(s). If invalid, an error will be thrown. * diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 31efeaf979..53847f6d62 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -50,7 +50,6 @@ import './database/index.spec'; // Messaging import './messaging/index.spec'; import './messaging/messaging.spec'; -import './messaging/batch-requests.spec'; // Machine Learning import './machine-learning/index.spec'; diff --git a/test/unit/messaging/batch-requests.spec.ts b/test/unit/messaging/batch-requests.spec.ts deleted file mode 100644 index 4f3b09957d..0000000000 --- a/test/unit/messaging/batch-requests.spec.ts +++ /dev/null @@ -1,270 +0,0 @@ -/*! - * Copyright 2019 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -import * as chai from 'chai'; -import * as sinon from 'sinon'; -import * as sinonChai from 'sinon-chai'; -import * as chaiAsPromised from 'chai-as-promised'; - -import * as utils from '../utils'; - -import { HttpClient, RequestResponse, HttpRequestConfig, RequestResponseError } from '../../../src/utils/api-request'; -import { SubRequest, BatchRequestClient } from '../../../src/messaging/batch-request-internal'; - -chai.should(); -chai.use(sinonChai); -chai.use(chaiAsPromised); - -const expect = chai.expect; - -function parseHttpRequest(text: string | Buffer): any { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const httpMessageParser = require('http-message-parser'); - return httpMessageParser(text); -} - -function getParsedPartData(obj: object): string { - const json = JSON.stringify(obj); - return 'POST https://example.com HTTP/1.1\r\n' - + `Content-Length: ${json.length}\r\n` - + 'Content-Type: application/json; charset=UTF-8\r\n' - + '\r\n' - + `${json}`; -} - -function createMultipartResponse(success: object[], failures: object[] = []): RequestResponse { - const multipart: Buffer[] = []; - success.forEach((part) => { - let payload = ''; - payload += 'HTTP/1.1 200 OK\r\n'; - payload += 'Content-type: application/json\r\n\r\n'; - payload += `${JSON.stringify(part)}\r\n`; - multipart.push(Buffer.from(payload, 'utf-8')); - }); - failures.forEach((part) => { - let payload = ''; - payload += 'HTTP/1.1 500 Internal Server Error\r\n'; - payload += 'Content-type: application/json\r\n\r\n'; - payload += `${JSON.stringify(part)}\r\n`; - multipart.push(Buffer.from(payload, 'utf-8')); - }); - return { - status: 200, - headers: { 'Content-Type': 'multipart/mixed; boundary=boundary' }, - multipart, - text: '', - data: null, - isJson: () => false, - }; -} - -describe('BatchRequestClient', () => { - - const batchUrl = 'https://batch.url'; - const responseObject = { success: true }; - const httpClient = new HttpClient(); - - let stubs: sinon.SinonStub[] = []; - - afterEach(() => { - stubs.forEach((mock) => { - mock.restore(); - }); - stubs = []; - }); - - it('should serialize a batch with a single request', async () => { - const stub = sinon.stub(httpClient, 'send').resolves( - createMultipartResponse([responseObject])); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 } }, - ]; - const batch = new BatchRequestClient(httpClient, batchUrl); - - const responses: RequestResponse[] = await batch.send(requests); - - expect(responses.length).to.equal(1); - expect(responses[0].status).to.equal(200); - expect(responses[0].data).to.deep.equal(responseObject); - checkOutgoingRequest(stub, requests); - }); - - it('should serialize a batch with multiple requests', async () => { - const stub = sinon.stub(httpClient, 'send').resolves( - createMultipartResponse([responseObject, responseObject, responseObject])); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 } }, - { url: 'https://example.com', body: { foo: 2 } }, - { url: 'https://example.com', body: { foo: 3 } }, - ]; - const batch = new BatchRequestClient(httpClient, batchUrl); - - const responses: RequestResponse[] = await batch.send(requests); - - expect(responses.length).to.equal(3); - responses.forEach((response) => { - expect(response.status).to.equal(200); - expect(response.data).to.deep.equal(responseObject); - }); - checkOutgoingRequest(stub, requests); - }); - - it('should handle both success and failure HTTP responses in a batch', async () => { - const stub = sinon.stub(httpClient, 'send').resolves( - createMultipartResponse([responseObject, responseObject], [responseObject])); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 } }, - { url: 'https://example.com', body: { foo: 2 } }, - { url: 'https://example.com', body: { foo: 3 } }, - ]; - const batch = new BatchRequestClient(httpClient, batchUrl); - - const responses: RequestResponse[] = await batch.send(requests); - - expect(responses.length).to.equal(3); - responses.forEach((response, idx) => { - const expectedStatus = idx < 2 ? 200 : 500; - expect(response.status).to.equal(expectedStatus); - expect(response.data).to.deep.equal(responseObject); - }); - checkOutgoingRequest(stub, requests); - }); - - it('should reject on top-level HTTP error responses', async () => { - const stub = sinon.stub(httpClient, 'send').rejects( - utils.errorFrom({ error: 'test' })); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 } }, - { url: 'https://example.com', body: { foo: 2 } }, - { url: 'https://example.com', body: { foo: 3 } }, - ]; - const batch = new BatchRequestClient(httpClient, batchUrl); - - try { - await batch.send(requests); - sinon.assert.fail('No error thrown for HTTP error'); - } catch (err) { - expect(err).to.be.instanceOf(RequestResponseError); - expect((err as RequestResponseError).response.status).to.equal(500); - checkOutgoingRequest(stub, requests); - } - }); - - it('should add common headers to the parent and sub requests in a batch', async () => { - const stub = sinon.stub(httpClient, 'send').resolves( - createMultipartResponse([responseObject])); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 } }, - { url: 'https://example.com', body: { foo: 2 } }, - ]; - const commonHeaders = { 'X-Custom-Header': 'value' }; - const batch = new BatchRequestClient(httpClient, batchUrl, commonHeaders); - - const responses: RequestResponse[] = await batch.send(requests); - - expect(responses.length).to.equal(1); - expect(stub).to.have.been.calledOnce; - const args: HttpRequestConfig = stub.getCall(0).args[0]; - expect(args.headers).to.have.property('X-Custom-Header', 'value'); - - const parsedRequest = parseHttpRequest(args.data as Buffer); - expect(parsedRequest.multipart.length).to.equal(requests.length); - parsedRequest.multipart.forEach((sub: {body: Buffer}) => { - const parsedSubRequest: {headers: object} = parseHttpRequest(sub.body.toString().trim()); - expect(parsedSubRequest.headers).to.have.property('X-Custom-Header', 'value'); - }); - }); - - it('should add sub request headers to the payload', async () => { - const stub = sinon.stub(httpClient, 'send').resolves( - createMultipartResponse([responseObject])); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 }, headers: { 'X-Custom-Header': 'value' } }, - { url: 'https://example.com', body: { foo: 1 }, headers: { 'X-Custom-Header': 'value' } }, - ]; - const batch = new BatchRequestClient(httpClient, batchUrl); - - const responses: RequestResponse[] = await batch.send(requests); - - expect(responses.length).to.equal(1); - expect(stub).to.have.been.calledOnce; - const args: HttpRequestConfig = stub.getCall(0).args[0]; - const parsedRequest = parseHttpRequest(args.data as Buffer); - expect(parsedRequest.multipart.length).to.equal(requests.length); - parsedRequest.multipart.forEach((sub: {body: Buffer}) => { - const parsedSubRequest: {headers: object} = parseHttpRequest(sub.body.toString().trim()); - expect(parsedSubRequest.headers).to.have.property('X-Custom-Header', 'value'); - }); - }); - - it('sub request headers should get precedence', async () => { - const stub = sinon.stub(httpClient, 'send').resolves( - createMultipartResponse([responseObject])); - stubs.push(stub); - const requests: SubRequest[] = [ - { url: 'https://example.com', body: { foo: 1 }, headers: { 'X-Custom-Header': 'overwrite' } }, - { url: 'https://example.com', body: { foo: 1 }, headers: { 'X-Custom-Header': 'overwrite' } }, - ]; - const commonHeaders = { 'X-Custom-Header': 'value' }; - const batch = new BatchRequestClient(httpClient, batchUrl, commonHeaders); - - const responses: RequestResponse[] = await batch.send(requests); - - expect(responses.length).to.equal(1); - expect(stub).to.have.been.calledOnce; - const args: HttpRequestConfig = stub.getCall(0).args[0]; - const parsedRequest = parseHttpRequest(args.data as Buffer); - expect(parsedRequest.multipart.length).to.equal(requests.length); - parsedRequest.multipart.forEach((part: {body: Buffer}) => { - const parsedPart: {headers: object} = parseHttpRequest(part.body.toString().trim()); - expect(parsedPart.headers).to.have.property('X-Custom-Header', 'overwrite'); - }); - }); - - function checkOutgoingRequest(stub: sinon.SinonStub, requests: SubRequest[]): void { - expect(stub).to.have.been.calledOnce; - const args: HttpRequestConfig = stub.getCall(0).args[0]; - expect(args.method).to.equal('POST'); - expect(args.url).to.equal(batchUrl); - expect(args.headers).to.have.property( - 'Content-Type', 'multipart/mixed; boundary=__END_OF_PART__'); - expect(args.timeout).to.equal(15000); - const parsedRequest = parseHttpRequest(args.data as Buffer); - expect(parsedRequest.multipart.length).to.equal(requests.length); - - if (requests.length === 1) { - // http-message-parser handles single-element batches slightly differently. Specifically, the - // payload contents are exposed through body instead of multipart, and the body string uses - // \n instead of \r\n for line breaks. - let expectedPartData = getParsedPartData(requests[0].body); - expectedPartData = expectedPartData.replace(/\r\n/g, '\n'); - expect(parsedRequest.body.trim()).to.equal(expectedPartData); - } else { - requests.forEach((req, idx) => { - const part = parsedRequest.multipart[idx].body.toString().trim(); - expect(part).to.equal(getParsedPartData(req.body)); - }); - } - } -}); diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index 4fb5264d5c..6a14f17171 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -27,11 +27,10 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { FirebaseApp } from '../../../src/app/firebase-app'; import { - Message, MessagingOptions, MessagingPayload, MessagingDevicesResponse, - MessagingDeviceGroupResponse, MessagingTopicManagementResponse, BatchResponse, + Message, + MessagingTopicManagementResponse, BatchResponse, SendResponse, MulticastMessage, Messaging, TokenMessage, TopicMessage, ConditionMessage, } from '../../../src/messaging/index'; -import { BLACKLISTED_OPTIONS_KEYS, BLACKLISTED_DATA_PAYLOAD_KEYS } from '../../../src/messaging/messaging-internal'; import { HttpClient } from '../../../src/utils/api-request'; import { getSdkVersion } from '../../../src/utils/index'; import * as utils from '../utils'; @@ -44,7 +43,6 @@ const expect = chai.expect; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; -const FCM_SEND_PATH = '/fcm/send'; const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; @@ -89,44 +87,6 @@ function mockHttp2SendRequestResponse(messageId = 'projects/projec_id/messages/m } as mocks.MockHttp2Response } -function mockBatchRequest(ids: string[]): nock.Scope { - return mockBatchRequestWithErrors(ids); -} - -function mockBatchRequestWithErrors(ids: string[], errors: object[] = []): nock.Scope { - const mockPayload = createMultipartPayloadWithErrors(ids.map((id) => { - return { name: id }; - }), errors); - return nock(`https://${FCM_SEND_HOST}:443`) - .post('/batch') - .reply(200, mockPayload, { - 'Content-type': 'multipart/mixed; boundary=boundary', - }); -} - -function createMultipartPayloadWithErrors( - success: object[], failures: object[] = []): string { - - const boundary = 'boundary'; - let payload = ''; - success.forEach((part) => { - payload += `--${boundary}\r\n`; - payload += 'Content-type: application/http\r\n\r\n'; - payload += 'HTTP/1.1 200 OK\r\n'; - payload += 'Content-type: application/json\r\n\r\n'; - payload += `${JSON.stringify(part)}\r\n`; - }); - failures.forEach((part) => { - payload += `--${boundary}\r\n`; - payload += 'Content-type: application/http\r\n\r\n'; - payload += 'HTTP/1.1 500 Internal Server Error\r\n'; - payload += 'Content-type: application/json\r\n\r\n'; - payload += `${JSON.stringify(part)}\r\n`; - }); - payload += `--${boundary}--\r\n`; - return payload; -} - function mockSendError( statusCode: number, errorFormat: 'json' | 'text', @@ -161,13 +121,6 @@ function mockHttp2SendRequestError( } as mocks.MockHttp2Response } -function mockBatchError( - statusCode: number, - errorFormat: 'json' | 'text', - responseOverride?: any, -): nock.Scope { - return mockErrorResponse('/batch', statusCode, errorFormat, responseOverride); -} function mockErrorResponse( path: string, @@ -192,76 +145,6 @@ function mockErrorResponse( }); } -function mockSendToDeviceStringRequest(mockFailure = false): nock.Scope { - let deviceResult: object = { message_id: `0:${mocks.messaging.messageId}` }; - if (mockFailure) { - deviceResult = { error: 'InvalidRegistration' }; - } - - return nock(`https://${FCM_SEND_HOST}:443`) - .post(FCM_SEND_PATH) - .reply(200, { - multicast_id: mocks.messaging.multicastId, - success: mockFailure ? 0 : 1, - failure: mockFailure ? 1 : 0, - canonical_ids: 0, - results: [deviceResult], - }); -} - -function mockSendToDeviceArrayRequest(): nock.Scope { - return nock(`https://${FCM_SEND_HOST}:443`) - .post(FCM_SEND_PATH) - .reply(200, { - multicast_id: mocks.messaging.multicastId, - success: 1, - failure: 2, - canonical_ids: 1, - results: [ - { - message_id: `0:${mocks.messaging.messageId}`, - registration_id: mocks.messaging.registrationToken + '3', - }, - { error: 'some-error' }, - { error: mockServerErrorResponse.json.error }, - ], - }); -} - -function mockSendToDeviceGroupRequest(numFailedRegistrationTokens = 0): nock.Scope { - const response: any = { - success: 5 - numFailedRegistrationTokens, - failure: numFailedRegistrationTokens, - }; - - if (numFailedRegistrationTokens > 0) { - response.failed_registration_ids = []; - for (let i = 0; i < numFailedRegistrationTokens; i++) { - response.failed_registration_ids.push(mocks.messaging.registrationToken + i); - } - } - - return nock(`https://${FCM_SEND_HOST}:443`) - .post(FCM_SEND_PATH) - .reply(200, response); -} - -function mockSendToTopicRequest(): nock.Scope { - return nock(`https://${FCM_SEND_HOST}:443`) - .post(FCM_SEND_PATH) - .reply(200, { - message_id: mocks.messaging.messageId, - }); -} - -function mockSendToConditionRequest(): nock.Scope { - return nock(`https://${FCM_SEND_HOST}:443`) - .post(FCM_SEND_PATH) - .reply(200, { - message_id: mocks.messaging.messageId, - }); -} - function mockTopicSubscriptionRequest( methodName: string, successCount = 1, @@ -286,28 +169,6 @@ function mockTopicSubscriptionRequest( }); } -function mockSendRequestWithError( - statusCode: number, - errorFormat: 'json' | 'text', - responseOverride?: any, -): nock.Scope { - let response; - let contentType; - if (errorFormat === 'json') { - response = mockServerErrorResponse.json; - contentType = 'application/json; charset=UTF-8'; - } else { - response = mockServerErrorResponse.text; - contentType = 'text/html; charset=UTF-8'; - } - - return nock(`https://${FCM_SEND_HOST}:443`) - .post(FCM_SEND_PATH) - .reply(statusCode, responseOverride || response, { - 'Content-Type': contentType, - }); -} - function mockTopicSubscriptionRequestWithError( methodName: string, statusCode: number, @@ -1499,1912 +1360,114 @@ describe('Messaging', () => { }); }); - describe('sendAll()', () => { - 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.sendAll(undefined as any); - }).to.throw('messages must be a non-empty array'); - expect(() => { - messaging.sendAll(null as any); - }).to.throw('messages must be a non-empty array'); - expect(() => { - messaging.sendAll([]); - }).to.throw('messages must be a non-empty array'); + describe('Payload validation', () => { + const invalidImages = ['', 'a', 'foo', 'image.jpg']; + invalidImages.forEach((imageUrl) => { + it(`should throw given an invalid imageUrl: ${imageUrl}`, () => { + const message: Message = { + condition: 'topic-name', + notification: { + imageUrl, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('notification.imageUrl must be a valid URL string'); + }); }); - 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.sendAll(messages); - }).to.throw('messages list must not contain more than 500 items'); + const invalidTtls = ['', 'abc', '123', '-123s', '1.2.3s', 'As', 's', '1s', -1]; + invalidTtls.forEach((ttl) => { + it(`should throw given an invalid ttl: ${ ttl }`, () => { + const message: Message = { + condition: 'topic-name', + android: { + ttl: (ttl as any), + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('TTL must be a non-negative duration in milliseconds'); + }); }); - it('should reject when a message is invalid', () => { - const invalidMessage: Message = {} as any; - messaging.sendAll([validMessage, invalidMessage]) - .should.eventually.be.rejectedWith('Exactly one of topic, token or condition is required'); + const invalidColors = ['', 'foo', '123', '#AABBCX', '112233', '#11223']; + invalidColors.forEach((color) => { + it(`should throw given an invalid color: ${ color }`, () => { + const message: Message = { + condition: 'topic-name', + android: { + notification: { + color, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('android.notification.color must be in the form #RRGGBB'); + }); }); - 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)}`, () => { + invalidImages.forEach((imageUrl) => { + it(`should throw given an invalid imageUrl: ${ imageUrl }`, () => { + const message: Message = { + condition: 'topic-name', + android: { + notification: { + imageUrl, + }, + }, + }; expect(() => { - messaging.sendAll([{ token: 'a' }], dryRun as any); - }).to.throw('dryRun must be a boolean'); + messaging.send(message); + }).to.throw('android.notification.imageUrl must be a valid URL string'); }); }); - 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', - ]; - mockedRequests.push(mockBatchRequest(messageIds)); - return messaging.sendAll([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 throw given android titleLocArgs without titleLocKey', () => { + const message: Message = { + condition: 'topic-name', + android: { + notification: { + titleLocArgs: ['foo'], + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('titleLocKey is required when specifying titleLocArgs'); }); - 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', - ]; - mockedRequests.push(mockBatchRequest(messageIds)); - const message = { - token: 'a', + it('should throw given android bodyLocArgs without bodyLocKey', () => { + const message: Message = { + condition: 'topic-name', android: { - ttl: 3600, + notification: { + bodyLocArgs: ['foo'], + }, }, }; - 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.sendAll(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; - }); - }); + expect(() => { + messaging.send(message); + }).to.throw('bodyLocKey is required when specifying bodyLocArgs'); }); - 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', - ]; - mockedRequests.push(mockBatchRequest(messageIds)); - return messaging.sendAll([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 when the response contains some errors', () => { - const messageIds = [ - 'projects/projec_id/messages/1', - 'projects/projec_id/messages/2', - ]; - const errors = [ - { - error: { - status: 'INVALID_ARGUMENT', - message: 'test error message', - }, - }, - ]; - mockedRequests.push(mockBatchRequestWithErrors(messageIds, errors)); - return messaging.sendAll([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 expose the FCM error code 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', - }, - ], - }, - }, - ]; - mockedRequests.push(mockBatchRequestWithErrors(messageIds, errors)); - return messaging.sendAll([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 fail when the backend server returns a detailed error', () => { - const resp = { - error: { - status: 'INVALID_ARGUMENT', - message: 'test error message', - }, - }; - mockedRequests.push(mockBatchError(400, 'json', resp)); - return messaging.sendAll( - [validMessage], - ).should.eventually.be.rejectedWith('test error message') - .and.have.property('code', 'messaging/invalid-argument'); - }); - - it('should fail when the backend server returns a detailed error with FCM error code', () => { - const resp = { - error: { - status: 'INVALID_ARGUMENT', - message: 'test error message', - details: [ - { - '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', - }, - ], - }, - }; - mockedRequests.push(mockBatchError(404, 'json', resp)); - return messaging.sendAll( - [validMessage], - ).should.eventually.be.rejectedWith('test error message') - .and.have.property('code', 'messaging/registration-token-not-registered'); - }); - - it('should map server error code to client-side error', () => { - const resp = { - error: { - status: 'NOT_FOUND', - message: 'test error message', - }, - }; - mockedRequests.push(mockBatchError(404, 'json', resp)); - return messaging.sendAll( - [validMessage], - ).should.eventually.be.rejectedWith('test error message') - .and.have.property('code', 'messaging/registration-token-not-registered'); - }); - - it('should fail when the backend server returns an unknown error', () => { - const resp = { error: 'test error message' }; - mockedRequests.push(mockBatchError(400, 'json', resp)); - return messaging.sendAll( - [validMessage], - ).should.eventually.be.rejected.and.have.property('code', 'messaging/unknown-error'); - }); - - it('should fail when the backend server returns a non-json error', () => { - // Error code will be determined based on the status code. - mockedRequests.push(mockBatchError(400, 'text', 'foo bar')); - return messaging.sendAll( - [validMessage], - ).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenMessaging.sendAll( - [validMessage], - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - // 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]; - - mockedRequests.push(mockBatchRequest(messageIds)); - - return messaging.sendAll(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('sendMulticast()', () => { - 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.sendMulticast(undefined as any); - }).to.throw('MulticastMessage must be a non-null object'); - expect(() => { - messaging.sendMulticast({} as any); - }).to.throw('tokens must be a non-empty array'); - expect(() => { - messaging.sendMulticast({ 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.sendMulticast({ 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.sendMulticast({ 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, 'sendAll').resolves(mockResponse); - const tokens = ['a', 'b', 'c']; - return messaging.sendMulticast({ 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, 'sendAll').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.sendMulticast(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, 'sendAll').resolves(mockResponse); - const tokens = ['a', 'b', 'c']; - return messaging.sendMulticast({ 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', - ]; - mockedRequests.push(mockBatchRequest(messageIds)); - return messaging.sendMulticast({ - 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', - ]; - mockedRequests.push(mockBatchRequest(messageIds)); - return messaging.sendMulticast({ - 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 when the response contains some errors', () => { - const messageIds = [ - 'projects/projec_id/messages/1', - 'projects/projec_id/messages/2', - ]; - const errors = [ - { - error: { - status: 'INVALID_ARGUMENT', - message: 'test error message', - }, - }, - ]; - mockedRequests.push(mockBatchRequestWithErrors(messageIds, errors)); - return messaging.sendMulticast({ tokens: ['a', 'b'] }) - .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 expose the FCM error code 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', - }, - ], - }, - }, - ]; - mockedRequests.push(mockBatchRequestWithErrors(messageIds, errors)); - return messaging.sendMulticast({ 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 fail when the backend server returns a detailed error', () => { - const resp = { - error: { - status: 'INVALID_ARGUMENT', - message: 'test error message', - }, - }; - mockedRequests.push(mockBatchError(400, 'json', resp)); - return messaging.sendMulticast( - { tokens: ['a'] }, - ).should.eventually.be.rejectedWith('test error message') - .and.have.property('code', 'messaging/invalid-argument'); - }); - - it('should fail when the backend server returns a detailed error with FCM error code', () => { - const resp = { - error: { - status: 'INVALID_ARGUMENT', - message: 'test error message', - details: [ - { - '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', - }, - ], - }, - }; - mockedRequests.push(mockBatchError(404, 'json', resp)); - return messaging.sendMulticast( - { tokens: ['a'] }, - ).should.eventually.be.rejectedWith('test error message') - .and.have.property('code', 'messaging/registration-token-not-registered'); - }); - - it('should map server error code to client-side error', () => { - const resp = { - error: { - status: 'NOT_FOUND', - message: 'test error message', - }, - }; - mockedRequests.push(mockBatchError(404, 'json', resp)); - return messaging.sendMulticast( - { tokens: ['a'] }, - ).should.eventually.be.rejectedWith('test error message') - .and.have.property('code', 'messaging/registration-token-not-registered'); - }); - - it('should fail when the backend server returns an unknown error', () => { - const resp = { error: 'test error message' }; - mockedRequests.push(mockBatchError(400, 'json', resp)); - return messaging.sendMulticast( - { tokens: ['a'] }, - ).should.eventually.be.rejected.and.have.property('code', 'messaging/unknown-error'); - }); - - it('should fail when the backend server returns a non-json error', () => { - // Error code will be determined based on the status code. - mockedRequests.push(mockBatchError(400, 'text', 'foo bar')); - return messaging.sendMulticast( - { tokens: ['a'] }, - ).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenMessaging.sendMulticast( - { tokens: ['a'] }, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - 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('sendToDevice()', () => { - const invalidArgumentError = 'Registration token(s) provided to sendToDevice() must be a ' + - 'non-empty string or a non-empty array'; - - const invalidRegistrationTokens = [null, NaN, 0, 1, true, false, {}, { a: 1 }, _.noop]; - invalidRegistrationTokens.forEach((invalidRegistrationToken) => { - it('should throw given invalid type for registration token(s) argument: ' + - JSON.stringify(invalidRegistrationToken), () => { - expect(() => { - messaging.sendToDevice(invalidRegistrationToken as string, mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - }); - - it('should throw given no registration token(s) argument', () => { - expect(() => { - messaging.sendToDevice(undefined as any, mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should throw given empty string for registration token(s) argument', () => { - expect(() => { - messaging.sendToDevice('', mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should throw given empty array for registration token(s) argument', () => { - expect(() => { - messaging.sendToDevice([], mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should be rejected given empty string within array for registration token(s) argument', () => { - return messaging.sendToDevice(['foo', 'bar', ''], mocks.messaging.payloadDataOnly) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-recipient'); - }); - - it('should be rejected given non-string value within array for registration token(s) argument', () => { - return messaging.sendToDevice(['foo', true as any, 'bar'], mocks.messaging.payloadDataOnly) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-recipient'); - }); - - it('should be rejected given an array containing more than 1,000 registration tokens', () => { - mockedRequests.push(mockSendToDeviceArrayRequest()); - - // Create an array of exactly 1,000 registration tokens - const registrationTokens = (Array(1000) as any).fill(mocks.messaging.registrationToken); - - return messaging.sendToDevice(registrationTokens, mocks.messaging.payload) - .then(() => { - // Push the array of registration tokens over 1,000 items - registrationTokens.push(mocks.messaging.registrationToken); - - return messaging.sendToDevice(registrationTokens, mocks.messaging.payload) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-recipient'); - }); - }); - - it('should be rejected given a 200 JSON server response with a known error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json')); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a 200 JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json', { error: 'Unknown' })); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json')); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a non-2xx JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { error: 'Unknown' })); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response without an error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { foo: 'bar' })); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - _.forEach(STATUS_CODE_TO_ERROR_MAP, (expectedError, statusCode) => { - it(`should be rejected given a ${ statusCode } text server response`, () => { - mockedRequests.push(mockSendRequestWithError(parseInt(statusCode, 10), 'text')); - disableRetries(messaging); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedError); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenMessaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return nullAccessTokenMessaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return nullAccessTokenMessaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be fulfilled given a valid registration token and payload', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ); - }); - - it('should be fulfilled given a valid registration token, payload, and options', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - mocks.messaging.options, - ); - }); - - it('should be fulfilled given a valid array of registration tokens and payload', () => { - mockedRequests.push(mockSendToDeviceArrayRequest()); - - return messaging.sendToDevice( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.payload, - ); - }); - - it('should be fulfilled given a valid array of registration tokens, payload, and options', () => { - mockedRequests.push(mockSendToDeviceArrayRequest()); - - return messaging.sendToDevice( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.payload, - mocks.messaging.options, - ); - }); - - it('should be fulfilled with the server response given a single registration token', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 1, - canonicalRegistrationTokenCount: 0, - multicastId: mocks.messaging.multicastId, - results: [ - { messageId: `0:${ mocks.messaging.messageId }` }, - ], - }); - }); - - it('should be fulfilled with the server response given an array of registration tokens', () => { - mockedRequests.push(mockSendToDeviceArrayRequest()); - - return messaging.sendToDevice( - [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ], - mocks.messaging.payload, - ).then((response: MessagingDevicesResponse | MessagingDeviceGroupResponse) => { - expect(response).to.have.keys([ - 'failureCount', 'successCount', 'canonicalRegistrationTokenCount', 'multicastId', 'results', - ]); - response = response as MessagingDevicesResponse; - expect(response.failureCount).to.equal(2); - expect(response.successCount).to.equal(1); - expect(response.canonicalRegistrationTokenCount).to.equal(1); - expect(response.multicastId).to.equal(mocks.messaging.multicastId); - expect(response.results).to.have.length(3); - expect(response.results[0]).to.deep.equal({ - messageId: `0:${ mocks.messaging.messageId }`, - canonicalRegistrationToken: mocks.messaging.registrationToken + '3', - }); - expect(response.results[1]).to.have.keys(['error']); - expect(response.results[1].error).to.have.property('code', expectedErrorCodes.unknownError); - expect(response.results[2]).to.have.keys(['error']); - expect(response.results[2].error).to.have.property('code', expectedErrorCodes.json); - }); - }); - - it('should set the appropriate request data given a single registration token', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.deep.equal({ - to: mocks.messaging.registrationToken, - data: mocks.messaging.payload.data, - notification: mocks.messaging.payload.notification, - }); - }); - }); - - it('should set the appropriate request data given an array of registration tokens', () => { - const registrationTokens = [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - ]; - - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - registrationTokens, - mocks.messaging.payload, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.deep.equal({ - registration_ids: registrationTokens, - data: mocks.messaging.payload.data, - notification: mocks.messaging.payload.notification, - }); - }); - }); - - it('should be fulfilled given a notification key which actually causes a device group response', () => { - mockedRequests.push(mockSendToDeviceGroupRequest(/* numFailedRegistrationTokens */ 2)); - - return messaging.sendToDevice( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.deep.equal({ - failureCount: 2, - successCount: 3, - failedRegistrationTokens: [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - ], - canonicalRegistrationTokenCount: -1, - multicastId: -1, - results: [], - }); - }); - - it('should not mutate the payload argument', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payload); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mockPayloadClone, - ).then(() => { - expect(mockPayloadClone).to.deep.equal(mocks.messaging.payload); - }); - }); - - it('should not mutate the options argument', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - mockOptionsClone, - ).then(() => { - expect(mockOptionsClone).to.deep.equal(mocks.messaging.options); - }); - }); - }); - - describe('sendToDeviceGroup()', () => { - const invalidArgumentError = 'Notification key provided to sendToDeviceGroup() must be a ' + - 'non-empty string.'; - - const invalidNotificationKeys = [null, NaN, 0, 1, true, false, [], ['a', 1], {}, { a: 1 }, _.noop]; - invalidNotificationKeys.forEach((invalidNotificationKey) => { - it('should throw given invalid type for notification key argument: ' + - JSON.stringify(invalidNotificationKey), () => { - expect(() => { - messaging.sendToDeviceGroup(invalidNotificationKey as string, mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - }); - - it('should throw given no notification key argument', () => { - expect(() => { - messaging.sendToDeviceGroup(undefined as any, mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should throw given empty string for notification key argument', () => { - expect(() => { - messaging.sendToDeviceGroup('', mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should throw given a registration token which has a colon', () => { - return messaging.sendToDeviceGroup('tok:en', mocks.messaging.payloadDataOnly) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-recipient'); - }); - - it('should be rejected given a 200 JSON server response with a known error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json')); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a 200 JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json', { error: 'Unknown' })); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json')); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a non-2xx JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { error: 'Unknown' })); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response without an error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { foo: 'bar' })); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - _.forEach(STATUS_CODE_TO_ERROR_MAP, (expectedError, statusCode) => { - it(`should be rejected given a ${ statusCode } text server response`, () => { - mockedRequests.push(mockSendRequestWithError(parseInt(statusCode, 10), 'text')); - disableRetries(messaging); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedError); - }); - }); - - it('should be rejected given a devices response which has a success count of 0', () => { - mockedRequests.push(mockSendToDeviceStringRequest(/* mockFailure */ true)); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-recipient'); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenMessaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return nullAccessTokenMessaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return nullAccessTokenMessaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be fulfilled given a valid notification key and payload', () => { - mockedRequests.push(mockSendToDeviceGroupRequest()); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payloadDataOnly, - ); - }); - - it('should be fulfilled given a valid notification key, payload, and options', () => { - mockedRequests.push(mockSendToDeviceGroupRequest()); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payloadDataOnly, - mocks.messaging.options, - ); - }); - - it('should be fulfilled with the server response (no failed registration tokens)', () => { - mockedRequests.push(mockSendToDeviceGroupRequest()); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payloadDataOnly, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 5, - failedRegistrationTokens: [], - }); - }); - - it('should be fulfilled with the server response (some failed registration token)', () => { - mockedRequests.push(mockSendToDeviceGroupRequest(/* numFailedRegistrationTokens */ 2)); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payloadDataOnly, - ).should.eventually.deep.equal({ - failureCount: 2, - successCount: 3, - failedRegistrationTokens: [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - ], - }); - }); - - it('should be fulfilled with the server response (all failed registration token)', () => { - mockedRequests.push(mockSendToDeviceGroupRequest(/* numFailedRegistrationTokens */ 5)); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payloadDataOnly, - ).should.eventually.deep.equal({ - failureCount: 5, - successCount: 0, - failedRegistrationTokens: [ - mocks.messaging.registrationToken + '0', - mocks.messaging.registrationToken + '1', - mocks.messaging.registrationToken + '2', - mocks.messaging.registrationToken + '3', - mocks.messaging.registrationToken + '4', - ], - }); - }); - - it('should set the appropriate request data', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - ); - }).then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.deep.equal({ - to: mocks.messaging.notificationKey, - data: mocks.messaging.payload.data, - notification: mocks.messaging.payload.notification, - }); - }); - }); - - it('should be fulfilled given a registration token which actually causes a devices response', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDeviceGroup( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ).should.eventually.deep.equal({ - failureCount: 0, - successCount: 1, - canonicalRegistrationTokenCount: 0, - multicastId: mocks.messaging.multicastId, - results: [ - { messageId: `0:${ mocks.messaging.messageId }` }, - ], - failedRegistrationTokens: [], - }); - }); - - it('should not mutate the payload argument', () => { - mockedRequests.push(mockSendToDeviceGroupRequest()); - - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payload); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mockPayloadClone, - ).then(() => { - expect(mockPayloadClone).to.deep.equal(mocks.messaging.payload); - }); - }); - - it('should not mutate the options argument', () => { - mockedRequests.push(mockSendToDeviceGroupRequest()); - - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - mockOptionsClone, - ).then(() => { - expect(mockOptionsClone).to.deep.equal(mocks.messaging.options); - }); - }); - }); - - describe('sendToTopic()', () => { - const invalidArgumentError = 'Topic provided to sendToTopic() must be a string which matches'; - - const invalidTopics = [null, NaN, 0, 1, true, false, [], ['a', 1], {}, { a: 1 }, _.noop]; - invalidTopics.forEach((invalidTopic) => { - it(`should throw given invalid type for topic argument: ${ JSON.stringify(invalidTopic) }`, () => { - expect(() => { - messaging.sendToTopic(invalidTopic as string, mocks.messaging.payload); - }).to.throw(invalidArgumentError); - }); - }); - - it('should throw given no topic argument', () => { - expect(() => { - messaging.sendToTopic(undefined as any, mocks.messaging.payload); - }).to.throw(invalidArgumentError); - }); - - it('should throw given empty string for topic argument', () => { - expect(() => { - messaging.sendToTopic('', mocks.messaging.payload); - }).to.throw(invalidArgumentError); - }); - - const topicsWithInvalidCharacters = ['f*o*o', '/topics/f+o+o', 'foo/topics/foo', '$foo', '/topics/foo&']; - topicsWithInvalidCharacters.forEach((invalidTopic) => { - it(`should be rejected given topic argument which has invalid characters: ${ invalidTopic }`, () => { - return messaging.sendToTopic(invalidTopic, mocks.messaging.payload) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-recipient'); - }); - }); - - it('should be rejected given a 200 JSON server response with a known error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json')); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a 200 JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json', { error: 'Unknown' })); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json')); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a non-2xx JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { error: 'Unknown' })); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response without an error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { foo: 'bar' })); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - _.forEach(STATUS_CODE_TO_ERROR_MAP, (expectedError, statusCode) => { - it(`should be rejected given a ${ statusCode } text server response`, () => { - mockedRequests.push(mockSendRequestWithError(parseInt(statusCode, 10), 'text')); - disableRetries(messaging); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedError); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenMessaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return nullAccessTokenMessaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return nullAccessTokenMessaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be fulfilled given a valid topic and payload (topic name not prefixed with "/topics/")', () => { - mockedRequests.push(mockSendToTopicRequest()); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ); - }); - - it('should be fulfilled given a valid topic and payload (topic name prefixed with "/topics/")', () => { - mockedRequests.push(mockSendToTopicRequest()); - - return messaging.sendToTopic( - mocks.messaging.topicWithPrefix, - mocks.messaging.payload, - ); - }); - - it('should be fulfilled given a valid topic and payload (topic name prefixed with "/topics/private/")', () => { - mockedRequests.push(mockSendToTopicRequest()); - - return messaging.sendToTopic( - mocks.messaging.topicWithPrivatePrefix, - mocks.messaging.payload, - ); - }); - - it('should be fulfilled given a valid topic, payload, and options', () => { - mockedRequests.push(mockSendToTopicRequest()); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - mocks.messaging.options, - ); - }); - - it('should be fulfilled with the server response', () => { - mockedRequests.push(mockSendToTopicRequest()); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.deep.equal({ - messageId: mocks.messaging.messageId, - }); - }); - - it('should set the appropriate request data (topic name not prefixed with "/topics/")', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - ); - }).then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.deep.equal({ - to: mocks.messaging.topicWithPrefix, - data: mocks.messaging.payload.data, - notification: mocks.messaging.payload.notification, - }); - }); - }); - - it('should set the appropriate request data (topic name prefixed with "/topics/")', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToTopic( - mocks.messaging.topicWithPrefix, - mocks.messaging.payload, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.deep.equal({ - to: mocks.messaging.topicWithPrefix, - data: mocks.messaging.payload.data, - notification: mocks.messaging.payload.notification, - }); - }); - }); - - it('should not mutate the payload argument', () => { - mockedRequests.push(mockSendToTopicRequest()); - - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payload); - - return messaging.sendToTopic( - mocks.messaging.topic, - mockPayloadClone, - ).then(() => { - expect(mockPayloadClone).to.deep.equal(mocks.messaging.payload); - }); - }); - - it('should not mutate the options argument', () => { - mockedRequests.push(mockSendToTopicRequest()); - - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - mockOptionsClone, - ).then(() => { - expect(mockOptionsClone).to.deep.equal(mocks.messaging.options); - }); - }); - }); - - describe('sendToCondition()', () => { - const invalidArgumentError = 'Condition provided to sendToCondition() must be a non-empty string.'; - - const invalidConditions = [null, NaN, 0, 1, true, false, [], ['a', 1], {}, { a: 1 }, _.noop]; - invalidConditions.forEach((invalidCondition) => { - it(`should throw given invalid type for condition argument: ${ JSON.stringify(invalidCondition) }`, () => { - expect(() => { - messaging.sendToCondition(invalidCondition as string, mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - }); - - it('should throw given no condition argument', () => { - expect(() => { - messaging.sendToCondition(undefined as any, mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should throw given empty string for condition argument', () => { - expect(() => { - messaging.sendToCondition('', mocks.messaging.payloadDataOnly); - }).to.throw(invalidArgumentError); - }); - - it('should be rejected given a 200 JSON server response with a known error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json')); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a 200 JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(200, 'json', { error: 'Unknown' })); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json')); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.json); - }); - - it('should be rejected given a non-2xx JSON server response with an unknown error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { error: 'Unknown' })); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - it('should be rejected given a non-2xx JSON server response without an error', () => { - mockedRequests.push(mockSendRequestWithError(400, 'json', { foo: 'bar' })); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedErrorCodes.unknownError); - }); - - _.forEach(STATUS_CODE_TO_ERROR_MAP, (expectedError, statusCode) => { - it(`should be rejected given a ${ statusCode } text server response`, () => { - mockedRequests.push(mockSendRequestWithError(parseInt(statusCode, 10), 'text')); - disableRetries(messaging); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', expectedError); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenMessaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return nullAccessTokenMessaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return nullAccessTokenMessaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ).should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be fulfilled given a valid condition and payload', () => { - mockedRequests.push(mockSendToConditionRequest()); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payloadDataOnly, - ); - }); - - it('should be fulfilled given a valid condition, payload, and options', () => { - mockedRequests.push(mockSendToConditionRequest()); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payloadDataOnly, - mocks.messaging.options, - ); - }); - - it('should be fulfilled with the server response', () => { - mockedRequests.push(mockSendToConditionRequest()); - - return messaging.sendToCondition( - mocks.messaging.topic, - mocks.messaging.payload, - ).should.eventually.deep.equal({ - messageId: mocks.messaging.messageId, - }); - }); - - it('should set the appropriate request data', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.deep.equal({ - condition: mocks.messaging.condition, - data: mocks.messaging.payload.data, - notification: mocks.messaging.payload.notification, - }); - }); - }); - - it('should not mutate the payload argument', () => { - mockedRequests.push(mockSendToConditionRequest()); - - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payload); - - return messaging.sendToCondition( - mocks.messaging.condition, - mockPayloadClone, - ).then(() => { - expect(mockPayloadClone).to.deep.equal(mocks.messaging.payload); - }); - }); - - it('should not mutate the options argument', () => { - mockedRequests.push(mockSendToConditionRequest()); - - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - mockOptionsClone, - ).then(() => { - expect(mockOptionsClone).to.deep.equal(mocks.messaging.options); - }); - }); - }); - - describe('Payload validation', () => { - const invalidPayloads = [null, NaN, 0, 1, true, false, '', 'a', [], ['a', 1], _.noop]; - invalidPayloads.forEach((invalidPayload) => { - it(`should throw given invalid type for payload argument: ${ JSON.stringify(invalidPayload) }`, () => { - expect(() => { - messaging.sendToDevice(mocks.messaging.registrationToken, invalidPayload as any); - }).to.throw('Messaging payload must be an object'); - - expect(() => { - messaging.sendToDeviceGroup(mocks.messaging.notificationKey, invalidPayload as any); - }).to.throw('Messaging payload must be an object'); - - expect(() => { - messaging.sendToTopic(mocks.messaging.topic, invalidPayload as any); - }).to.throw('Messaging payload must be an object'); - - expect(() => { - messaging.sendToCondition(mocks.messaging.condition, invalidPayload as any); - }).to.throw('Messaging payload must be an object'); - }); - }); - - it('should be rejected given an empty payload', () => { - const msg: any = {}; - return messaging.sendToDeviceGroup(mocks.messaging.notificationKey, msg) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a non-empty payload with neither the "data" nor the "notification" property', () => { - const msg: any = { - foo: 'one', - bar: 'two', - }; - return messaging.sendToTopic(mocks.messaging.topic, msg) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given an otherwise valid payload with an additional invalid property', () => { - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payload); - (mockPayloadClone as any).foo = 'one'; - - return messaging.sendToCondition(mocks.messaging.condition, mockPayloadClone) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a non-object value for the "data" property', () => { - return messaging.sendToDevice(mocks.messaging.registrationToken, { - data: 'foo' as any, - }).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a non-object value for the "notification" property', () => { - return messaging.sendToDevice(mocks.messaging.registrationToken, { - notification: true as any, - }).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a non-string value for a property within the "data" property', () => { - return messaging.sendToDevice(mocks.messaging.registrationToken, { - data: { - foo: 1 as any, - }, - }).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a non-string value for a property within the "notification" property', () => { - return messaging.sendToDevice(mocks.messaging.registrationToken, { - notification: { - foo: true as any, - }, - }).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a valid "data" property but invalid "notification" property', () => { - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payloadDataOnly); - (mockPayloadClone as any).notification = 'foo'; - - return messaging.sendToDevice(mocks.messaging.registrationToken, mockPayloadClone) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - it('should be rejected given a valid "notification" property but invalid "data" property', () => { - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payloadNotificationOnly); - (mockPayloadClone as any).data = 'foo'; - - return messaging.sendToDevice(mocks.messaging.registrationToken, mockPayloadClone) - .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - - const blacklistedDataPayloadKeys = BLACKLISTED_DATA_PAYLOAD_KEYS.concat(['google.', 'google.foo']); - blacklistedDataPayloadKeys.forEach((blacklistedProperty) => { - it(`should be rejected given blacklisted "data.${blacklistedProperty}" property`, () => { - return messaging.sendToDevice( - mocks.messaging.registrationToken, - { - data: { - [blacklistedProperty]: 'foo', - }, - }, - ).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-payload'); - }); - }); - - const nonBlacklistedDataPayloadKeys = ['google', '.google', 'goo.gle', 'googlefoo', 'googlef.oo']; - nonBlacklistedDataPayloadKeys.forEach((nonBlacklistedProperty) => { - it(`should be fulfilled given non-blacklisted "data.${nonBlacklistedProperty}" property`, () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - { - data: { - [nonBlacklistedProperty]: 'foo', - }, - }, - ); - }); - }); - - it('should be fulfilled given a valid payload containing only the "data" property', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice(mocks.messaging.registrationToken, mocks.messaging.payloadDataOnly); - }); - - it('should be fulfillled given a valid payload containing only the "notification" property', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice(mocks.messaging.registrationToken, mocks.messaging.payloadNotificationOnly); - }); - - it('should be fulfillled given a valid payload containing both "data" and "notification" properties', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice(mocks.messaging.registrationToken, mocks.messaging.payload); - }); - - it('should add "data" and "notification" as top-level properties of the request data', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.have.keys(['to', 'data', 'notification']); - expect(requestData.data).to.deep.equal(mocks.messaging.payload.data); - expect(requestData.notification).to.deep.equal(mocks.messaging.payload.notification); - }); - }); - - it('should convert whitelisted camelCased properties to underscore_cased properties', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - { - notification: { - bodyLocArgs: 'one', - bodyLocKey: 'two', - clickAction: 'three', - titleLocArgs: 'four', - titleLocKey: 'five', - otherKey: 'six', - }, - }, - ); - }).then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData.notification).to.deep.equal({ - body_loc_args: 'one', - body_loc_key: 'two', - click_action: 'three', - title_loc_args: 'four', - title_loc_key: 'five', - otherKey: 'six', - }); - }); - }); - - it('should give whitelisted camelCased properties higher precedence than underscore_cased properties', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - { - notification: { - bodyLocArgs: 'foo', - body_loc_args: 'bar', - }, - }, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData.notification.body_loc_args).to.equal('foo'); - }); - }); - - it('should not mutate the provided payload object', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - const mockPayloadClone: MessagingPayload = _.clone(mocks.messaging.payload); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mockPayloadClone, - ).then(() => { - expect(mockPayloadClone).to.deep.equal(mocks.messaging.payload); - }); - }); - - const invalidImages = ['', 'a', 'foo', 'image.jpg']; - invalidImages.forEach((imageUrl) => { - it(`should throw given an invalid imageUrl: ${imageUrl}`, () => { - const message: Message = { - condition: 'topic-name', - notification: { - imageUrl, - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('notification.imageUrl must be a valid URL string'); - }); - }); - - const invalidTtls = ['', 'abc', '123', '-123s', '1.2.3s', 'As', 's', '1s', -1]; - invalidTtls.forEach((ttl) => { - it(`should throw given an invalid ttl: ${ ttl }`, () => { - const message: Message = { - condition: 'topic-name', - android: { - ttl: (ttl as any), - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('TTL must be a non-negative duration in milliseconds'); - }); - }); - - const invalidColors = ['', 'foo', '123', '#AABBCX', '112233', '#11223']; - invalidColors.forEach((color) => { - it(`should throw given an invalid color: ${ color }`, () => { - const message: Message = { - condition: 'topic-name', - android: { - notification: { - color, - }, - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('android.notification.color must be in the form #RRGGBB'); - }); - }); - - invalidImages.forEach((imageUrl) => { - it(`should throw given an invalid imageUrl: ${ imageUrl }`, () => { - const message: Message = { - condition: 'topic-name', - android: { - notification: { - imageUrl, - }, - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('android.notification.imageUrl must be a valid URL string'); - }); - }); - - it('should throw given android titleLocArgs without titleLocKey', () => { - const message: Message = { - condition: 'topic-name', - android: { - notification: { - titleLocArgs: ['foo'], - }, - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('titleLocKey is required when specifying titleLocArgs'); - }); - - it('should throw given android bodyLocArgs without bodyLocKey', () => { - const message: Message = { - condition: 'topic-name', - android: { - notification: { - bodyLocArgs: ['foo'], - }, - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('bodyLocKey is required when specifying bodyLocArgs'); - }); - - const invalidVibrateTimings = [[null, 500], [-100]]; - invalidVibrateTimings.forEach((vibrateTimingsMillisMaybeNull) => { - const vibrateTimingsMillis = vibrateTimingsMillisMaybeNull as number[]; - it(`should throw given an null or negative vibrateTimingsMillis: ${ vibrateTimingsMillis }`, () => { - const message: Message = { - condition: 'topic-name', - android: { - notification: { - vibrateTimingsMillis, - }, - }, - }; - expect(() => { - messaging.send(message); - }).to.throw('android.notification.vibrateTimingsMillis must be non-negative durations in milliseconds'); - }); + const invalidVibrateTimings = [[null, 500], [-100]]; + invalidVibrateTimings.forEach((vibrateTimingsMillisMaybeNull) => { + const vibrateTimingsMillis = vibrateTimingsMillisMaybeNull as number[]; + it(`should throw given an null or negative vibrateTimingsMillis: ${ vibrateTimingsMillis }`, () => { + const message: Message = { + condition: 'topic-name', + android: { + notification: { + vibrateTimingsMillis, + }, + }, + }; + expect(() => { + messaging.send(message); + }).to.throw('android.notification.vibrateTimingsMillis must be non-negative durations in milliseconds'); + }); }); it('should throw given an empty vibrateTimingsMillis array', () => { @@ -3696,180 +1759,6 @@ describe('Messaging', () => { }); describe('Options validation', () => { - const invalidOptions = [null, NaN, 0, 1, true, false, '', 'a', [], ['a', 1], _.noop]; - invalidOptions.forEach((invalidOption) => { - it(`should throw given invalid type for options argument: ${ JSON.stringify(invalidOption) }`, () => { - expect(() => { - messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - invalidOption as MessagingOptions, - ); - }).to.throw('Messaging options must be an object'); - - expect(() => { - messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - invalidOption as MessagingOptions, - ); - }).to.throw('Messaging options must be an object'); - - expect(() => { - messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - invalidOption as MessagingOptions, - ); - }).to.throw('Messaging options must be an object'); - - expect(() => { - messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - invalidOption as MessagingOptions, - ); - }).to.throw('Messaging options must be an object'); - }); - }); - - BLACKLISTED_OPTIONS_KEYS.forEach((blacklistedProperty) => { - it(`should be rejected given blacklisted "${blacklistedProperty}" property`, () => { - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - { - [blacklistedProperty]: 'foo', - }, - ).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-options'); - }); - }); - - const whitelistedOptionsKeys: { - [name: string]: { - type: string; - underscoreCasedKey?: string; - }; - } = { - dryRun: { type: 'boolean', underscoreCasedKey: 'dry_run' }, - priority: { type: 'string' }, - timeToLive: { type: 'number', underscoreCasedKey: 'time_to_live' }, - collapseKey: { type: 'string', underscoreCasedKey: 'collapse_key' }, - mutableContent: { type: 'boolean', underscoreCasedKey: 'mutable_content' }, - contentAvailable: { type: 'boolean', underscoreCasedKey: 'content_available' }, - restrictedPackageName: { type: 'string', underscoreCasedKey: 'restricted_package_name' }, - }; - - _.forEach(whitelistedOptionsKeys, ({ type, underscoreCasedKey }, camelCasedKey) => { - let validValue: any; - let invalidValues: Array<{value: any; text: string}>; - if (type === 'string') { - invalidValues = [ - { value: true, text: 'non-string' }, - { value: '', text: 'empty string' }, - ]; - validValue = 'foo'; - } else if (type === 'number') { - invalidValues = [ - { value: true, text: 'non-number' }, - { value: NaN, text: 'NaN' }, - ]; - validValue = 1; - } else if (type === 'boolean') { - invalidValues = [ - { value: '', text: 'non-boolean' }, - ]; - validValue = false; - } - - // Only test the alternate underscoreCasedKey if it is defined - const keysToTest = [camelCasedKey]; - if (typeof underscoreCasedKey !== 'undefined') { - keysToTest.push(underscoreCasedKey); - } - - keysToTest.forEach((key) => { - invalidValues.forEach(({ value, text }) => { - it(`should be rejected given ${ text } value for the "${ key }" property`, () => { - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - { - [key]: value as any, - }, - ).should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-options'); - }); - }); - - it(`should be fulfilled given ${ type } value for the "${ key }" property`, () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - { - [key]: validValue, - }, - ); - }); - }); - }); - - it('should be fulfilled given an empty options object', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToDeviceGroup( - mocks.messaging.notificationKey, - mocks.messaging.payload, - {}, - ); - }); - - it('should be fulfilled given an options object containing only whitelisted properties', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - return messaging.sendToTopic( - mocks.messaging.topic, - mocks.messaging.payload, - mocks.messaging.options, - ); - }); - - it('should be fulfilled given an options object containing non-whitelisted properties', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - (mockOptionsClone as any).foo = 'bar'; - - return messaging.sendToCondition( - mocks.messaging.condition, - mocks.messaging.payload, - mockOptionsClone, - ); - }); - - it('should add provided options as top-level properties of the request data', () => { - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payloadDataOnly, - mockOptionsClone, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.have.keys(['to', 'data', 'dry_run', 'collapse_key']); - expect(requestData.dry_run).to.equal(mockOptionsClone.dryRun); - expect(requestData.collapse_key).to.equal(mockOptionsClone.collapseKey); - }); - }); - const validMessages: Array<{ label: string; req: any; @@ -4505,70 +2394,6 @@ describe('Messaging', () => { expect(requestData.message).to.deep.equal(expectedReq); }); }); - - it('should convert whitelisted camelCased properties to underscore_cased properties', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payloadDataOnly, - { - dryRun: true, - timeToLive: 1, - collapseKey: 'foo', - mutableContent: true, - contentAvailable: false, - restrictedPackageName: 'bar', - otherKey: true, - }, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData).to.have.keys([ - 'to', 'data', 'dry_run', 'time_to_live', 'collapse_key', 'mutable_content', - 'content_available', 'restricted_package_name', 'otherKey', - ]); - }); - }); - - it('should give whitelisted camelCased properties higher precedence than underscore_cased properties', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payloadDataOnly, - { - dryRun: true, - dry_run: false, - }, - ); - }) - .then(() => { - expect(httpsRequestStub).to.have.been.calledOnce; - const requestData = httpsRequestStub.args[0][0].data; - expect(requestData.dry_run).to.be.true; - }); - }); - - it('should not mutate the provided options object', () => { - mockedRequests.push(mockSendToDeviceStringRequest()); - - const mockOptionsClone: MessagingOptions = _.clone(mocks.messaging.options); - - return messaging.sendToDevice( - mocks.messaging.registrationToken, - mocks.messaging.payload, - mockOptionsClone, - ).then(() => { - expect(mockOptionsClone).to.deep.equal(mocks.messaging.options); - }); - }); }); function tokenSubscriptionTests(methodName: string): void {