diff --git a/.changeset/lemon-baboons-lick.md b/.changeset/lemon-baboons-lick.md new file mode 100644 index 00000000000..3414d4c9bde --- /dev/null +++ b/.changeset/lemon-baboons-lick.md @@ -0,0 +1,7 @@ +--- +'@firebase/remote-config': minor +'firebase': minor +'@firebase/remote-config-types': minor +--- + +Added support for Realtime Remote Config for the web. This feature introduces a new `onConfigUpdate` API and allows web applications to receive near-instant configuration updates without requiring periodic polling. diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index 1da7c29df0d..a9f5131e0bf 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -42,11 +42,15 @@ export interface FetchResponse { config?: FirebaseRemoteConfigObject; eTag?: string; status: number; + templateVersion?: number; } // @public export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; +// @public +export type FetchType = 'BASE' | 'REALTIME'; + // @public export interface FirebaseRemoteConfigObject { // (undocumented) @@ -78,7 +82,7 @@ export function isSupported(): Promise; export type LogLevel = 'debug' | 'error' | 'silent'; // @public -export function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Promise; +export function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Unsubscribe; // @public export interface RemoteConfig { diff --git a/docs-devsite/remote-config.fetchresponse.md b/docs-devsite/remote-config.fetchresponse.md index 414188e72bb..1955dd47492 100644 --- a/docs-devsite/remote-config.fetchresponse.md +++ b/docs-devsite/remote-config.fetchresponse.md @@ -27,6 +27,7 @@ export interface FetchResponse | [config](./remote-config.fetchresponse.md#fetchresponseconfig) | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines the map of parameters returned as "entries" in the fetch response body.

Only defined for 200 responses. | | [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.

Only defined for 200 and 304 responses. | | [status](./remote-config.fetchresponse.md#fetchresponsestatus) | number | The HTTP status, which is useful for differentiating success responses with data from those without.

The Remote Config client is modeled after the native Fetch interface, so HTTP status is first-class.

Disambiguation: the fetch response returns a legacy "state" value that is redundant with the HTTP status code. The former is normalized into the latter. | +| [templateVersion](./remote-config.fetchresponse.md#fetchresponsetemplateversion) | number | The version number of the config template fetched from the server. | ## FetchResponse.config @@ -65,3 +66,13 @@ The HTTP status, which is useful for differentiating success responses with data ```typescript status: number; ``` + +## FetchResponse.templateVersion + +The version number of the config template fetched from the server. + +Signature: + +```typescript +templateVersion?: number; +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index 1b8232588de..08b9cbe9bbe 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -53,6 +53,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Type Alias | Description | | --- | --- | | [FetchStatus](./remote-config.md#fetchstatus) | Summarizes the outcome of the last attempt to fetch config from the Firebase Remote Config server.

| +| [FetchType](./remote-config.md#fetchtype) | Indicates the type of fetch request. | | [LogLevel](./remote-config.md#loglevel) | Defines levels of Remote Config logging. | | [Unsubscribe](./remote-config.md#unsubscribe) | A function that unsubscribes from a real-time event stream. | | [ValueSource](./remote-config.md#valuesource) | Indicates the source of a value. | @@ -295,7 +296,7 @@ Starts listening for real-time config updates from the Remote Config backend and Signature: ```typescript -export declare function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Promise; +export declare function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Unsubscribe; ``` #### Parameters @@ -307,7 +308,7 @@ export declare function onConfigUpdate(remoteConfig: RemoteConfig, observer: Con Returns: -Promise<[Unsubscribe](./remote-config.md#unsubscribe)> +[Unsubscribe](./remote-config.md#unsubscribe) An [Unsubscribe](./remote-config.md#unsubscribe) function to remove the listener. @@ -384,6 +385,18 @@ Summarizes the outcome of the last attempt to fetch config from the Firebase Rem export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; ``` +## FetchType + +Indicates the type of fetch request. + +
  • "BASE" indicates a standard fetch request.
  • "REALTIME" indicates a fetch request triggered by a real-time update.
+ +Signature: + +```typescript +export type FetchType = 'BASE' | 'REALTIME'; +``` + ## LogLevel Defines levels of Remote Config logging. diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 47ae2d2af64..e92bf72b913 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -68,6 +68,9 @@ export function getRemoteConfig( rc._initializePromise = Promise.all([ rc._storage.setLastSuccessfulFetchResponse(options.initialFetchResponse), rc._storage.setActiveConfigEtag(options.initialFetchResponse?.eTag || ''), + rc._storage.setActiveConfigTemplateVersion( + options.initialFetchResponse.templateVersion || 0 + ), rc._storageCache.setLastSuccessfulFetchTimestampMillis(Date.now()), rc._storageCache.setLastFetchStatus('success'), rc._storageCache.setActiveConfig( @@ -100,6 +103,7 @@ export async function activate(remoteConfig: RemoteConfig): Promise { !lastSuccessfulFetchResponse || !lastSuccessfulFetchResponse.config || !lastSuccessfulFetchResponse.eTag || + !lastSuccessfulFetchResponse.templateVersion || lastSuccessfulFetchResponse.eTag === activeConfigEtag ) { // Either there is no successful fetched config, or is the same as current active @@ -108,7 +112,10 @@ export async function activate(remoteConfig: RemoteConfig): Promise { } await Promise.all([ rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config), - rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag) + rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag), + rc._storage.setActiveConfigTemplateVersion( + lastSuccessfulFetchResponse.templateVersion + ) ]); return true; } @@ -369,12 +376,12 @@ export async function setCustomSignals( * * @public */ -export async function onConfigUpdate( +export function onConfigUpdate( remoteConfig: RemoteConfig, observer: ConfigUpdateObserver -): Promise { +): Unsubscribe { const rc = getModularInstance(remoteConfig) as RemoteConfigImpl; - await rc._realtimeHandler.addObserver(observer); + rc._realtimeHandler.addObserver(observer); return () => { rc._realtimeHandler.removeObserver(observer); }; diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index 8f8f7311d5e..c1aa54afff7 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -17,17 +17,32 @@ import { _FirebaseInstallationsInternal } from '@firebase/installations'; import { Logger } from '@firebase/logger'; -import { ConfigUpdateObserver } from '../public_types'; +import { + ConfigUpdate, + ConfigUpdateObserver, + FetchResponse, + FirebaseRemoteConfigObject +} from '../public_types'; import { calculateBackoffMillis, FirebaseError } from '@firebase/util'; import { ERROR_FACTORY, ErrorCode } from '../errors'; import { Storage } from '../storage/storage'; import { VisibilityMonitor } from './visibility_monitor'; +import { StorageCache } from '../storage/storage_cache'; +import { + FetchRequest, + RemoteConfigAbortSignal +} from './remote_config_fetch_client'; +import { CachingClient } from './caching_client'; const API_KEY_HEADER = 'X-Goog-Api-Key'; const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; const ORIGINAL_RETRIES = 8; +const MAXIMUM_FETCH_ATTEMPTS = 3; const NO_BACKOFF_TIME_IN_MILLIS = -1; const NO_FAILED_REALTIME_STREAMS = 0; +const REALTIME_DISABLED_KEY = 'featureDisabled'; +const REALTIME_RETRY_INTERVAL = 'retryIntervalSeconds'; +const TEMPLATE_VERSION_KEY = 'latestTemplateVersionNumber'; export class RealtimeHandler { constructor( @@ -38,7 +53,9 @@ export class RealtimeHandler { private readonly projectId: string, private readonly apiKey: string, private readonly appId: string, - private readonly logger: Logger + private readonly logger: Logger, + private readonly storageCache: StorageCache, + private readonly cachingClient: CachingClient ) { void this.setRetriesRemaining(); void VisibilityMonitor.getInstance().on( @@ -53,9 +70,11 @@ export class RealtimeHandler { private isConnectionActive: boolean = false; private isRealtimeDisabled: boolean = false; private controller?: AbortController; - private reader: ReadableStreamDefaultReader | undefined; + private reader: ReadableStreamDefaultReader | undefined; private httpRetriesRemaining: number = ORIGINAL_RETRIES; private isInBackground: boolean = false; + private readonly decoder = new TextDecoder('utf-8'); + private isClosingConnection: boolean = false; private async setRetriesRemaining(): Promise { // Retrieve number of remaining retries from last session. The minimum retry count being one. @@ -81,7 +100,7 @@ export class RealtimeHandler { const numFailedStreams = ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || 0) + 1; - const backoffMillis = calculateBackoffMillis(numFailedStreams) * 60; + const backoffMillis = calculateBackoffMillis(numFailedStreams, 60000, 2); await this.storage.setRealtimeBackoffMetadata({ backoffEndTimeMillis: new Date( lastFailedStreamTime.getTime() + backoffMillis @@ -90,6 +109,23 @@ export class RealtimeHandler { }); } + /** + * Increase the backoff duration with a new end time based on Retry Interval. + */ + private async updateBackoffMetadataWithRetryInterval( + retryIntervalSeconds: number + ): Promise { + const currentTime = Date.now(); + const backoffDurationInMillis = retryIntervalSeconds * 1000; + const backoffEndTime = new Date(currentTime + backoffDurationInMillis); + const numFailedStreams = 0; + await this.storage.setRealtimeBackoffMetadata({ + backoffEndTimeMillis: backoffEndTime, + numFailedStreams + }); + await this.retryHttpConnectionWhenBackoffEnds(); + } + /** * HTTP status code that the Realtime client should retry on. */ @@ -105,19 +141,34 @@ export class RealtimeHandler { }; /** - * Stops the real-time HTTP connection by aborting the in-progress fetch request - * and canceling the stream reader if they exist. + * Closes the realtime HTTP connection. + * Note: This method is designed to be called only once at a time. + * If a call is already in progress, subsequent calls will be ignored. */ - private closeRealtimeHttpConnection(): void { - if (this.controller && !this.isInBackground) { - this.controller.abort(); - this.controller = undefined; + private async closeRealtimeHttpConnection(): Promise { + if (this.isClosingConnection) { + return; } + this.isClosingConnection = true; - if (this.reader) { - void this.reader.cancel(); + try { + if (this.reader) { + await this.reader.cancel(); + } + } catch (e) { + // The network connection was lost, so cancel() failed. + // This is expected in a disconnected state, so we can safely ignore the error. + this.logger.debug('Failed to cancel the reader, connection was lost.'); + } finally { this.reader = undefined; } + + if (this.controller) { + await this.controller.abort(); + this.controller = undefined; + } + + this.isClosingConnection = false; } private async resetRealtimeBackoff(): Promise { @@ -144,7 +195,7 @@ export class RealtimeHandler { ): Promise { const eTagValue = await this.storage.getActiveConfigEtag(); const lastKnownVersionNumber = - await this.storage.getLastKnownTemplateVersion(); + await this.storage.getActiveConfigTemplateVersion(); const headers = { [API_KEY_HEADER]: this.apiKey, @@ -221,6 +272,11 @@ export class RealtimeHandler { this.isConnectionActive = connectionRunning; } + /** + * Combines the check and set operations to prevent multiple asynchronous + * calls from redundantly starting an HTTP connection. This ensures that + * only one attempt is made at a time. + */ private checkAndSetHttpConnectionFlagIfNotRunning(): boolean { const canMakeConnection = this.canEstablishStreamConnection(); if (canMakeConnection) { @@ -229,6 +285,276 @@ export class RealtimeHandler { return canMakeConnection; } + private fetchResponseIsUpToDate( + fetchResponse: FetchResponse, + lastKnownVersion: number + ): boolean { + // If there is a config, make sure its version is >= the last known version. + if (fetchResponse.config != null && fetchResponse.templateVersion) { + return fetchResponse.templateVersion >= lastKnownVersion; + } + // If there isn't a config, return true if the fetch was successful and backend had no update. + // Else, it returned an out of date config. + return this.storageCache.getLastFetchStatus() === 'success'; + } + + private parseAndValidateConfigUpdateMessage(message: string): string { + const left = message.indexOf('{'); + const right = message.indexOf('}', left); + + if (left < 0 || right < 0) { + return ''; + } + return left >= right ? '' : message.substring(left, right + 1); + } + + private isEventListenersEmpty(): boolean { + return this.observers.size === 0; + } + + private getRandomInt(max: number): number { + return Math.floor(Math.random() * max); + } + + private executeAllListenerCallbacks(configUpdate: ConfigUpdate): void { + this.observers.forEach(observer => observer.next(configUpdate)); + } + + /** + * Compares two configuration objects and returns a set of keys that have changed. + * A key is considered changed if it's new, removed, or has a different value. + */ + private getChangedParams( + newConfig: FirebaseRemoteConfigObject, + oldConfig: FirebaseRemoteConfigObject + ): Set { + const changedKeys = new Set(); + const newKeys = new Set(Object.keys(newConfig || {})); + const oldKeys = new Set(Object.keys(oldConfig || {})); + + for (const key of newKeys) { + if (!oldKeys.has(key) || newConfig[key] !== oldConfig[key]) { + changedKeys.add(key); + } + } + + for (const key of oldKeys) { + if (!newKeys.has(key)) { + changedKeys.add(key); + } + } + + return changedKeys; + } + + private async fetchLatestConfig( + remainingAttempts: number, + targetVersion: number + ): Promise { + const remainingAttemptsAfterFetch = remainingAttempts - 1; + const currentAttempt = MAXIMUM_FETCH_ATTEMPTS - remainingAttemptsAfterFetch; + const customSignals = this.storageCache.getCustomSignals(); + if (customSignals) { + this.logger.debug( + `Fetching config with custom signals: ${JSON.stringify(customSignals)}` + ); + } + const abortSignal = new RemoteConfigAbortSignal(); + try { + const fetchRequest: FetchRequest = { + cacheMaxAgeMillis: 0, + signal: abortSignal, + customSignals, + fetchType: 'REALTIME', + fetchAttempt: currentAttempt + }; + + const fetchResponse: FetchResponse = await this.cachingClient.fetch( + fetchRequest + ); + let activatedConfigs = await this.storage.getActiveConfig(); + + if (!this.fetchResponseIsUpToDate(fetchResponse, targetVersion)) { + this.logger.debug( + "Fetched template version is the same as SDK's current version." + + ' Retrying fetch.' + ); + // Continue fetching until template version number is greater than current. + await this.autoFetch(remainingAttemptsAfterFetch, targetVersion); + return; + } + + if (fetchResponse.config == null) { + this.logger.debug( + 'The fetch succeeded, but the backend had no updates.' + ); + return; + } + + if (activatedConfigs == null) { + activatedConfigs = {}; + } + + const updatedKeys = this.getChangedParams( + fetchResponse.config, + activatedConfigs + ); + + if (updatedKeys.size === 0) { + this.logger.debug('Config was fetched, but no params changed.'); + return; + } + + const configUpdate: ConfigUpdate = { + getUpdatedKeys(): Set { + return new Set(updatedKeys); + } + }; + this.executeAllListenerCallbacks(configUpdate); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, { + originalErrorMessage: `Failed to auto-fetch config update: ${errorMessage}` + }); + this.propagateError(error); + } + } + + private async autoFetch( + remainingAttempts: number, + targetVersion: number + ): Promise { + if (remainingAttempts === 0) { + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, { + originalErrorMessage: + 'Unable to fetch the latest version of the template.' + }); + this.propagateError(error); + return; + } + + const timeTillFetchSeconds = this.getRandomInt(4); + const timeTillFetchInMiliseconds = timeTillFetchSeconds * 1000; + + await new Promise(resolve => + setTimeout(resolve, timeTillFetchInMiliseconds) + ); + await this.fetchLatestConfig(remainingAttempts, targetVersion); + } + + /** + * Processes a stream of real-time messages for configuration updates. + * This method reassembles fragmented messages, validates and parses the JSON, + * and automatically fetches a new config if a newer template version is available. + * It also handles server-specified retry intervals and propagates errors for + * invalid messages or when real-time updates are disabled. + */ + private async handleNotifications( + reader: ReadableStreamDefaultReader + ): Promise { + let partialConfigUpdateMessage: string; + let currentConfigUpdateMessage = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + partialConfigUpdateMessage = this.decoder.decode(value, { stream: true }); + currentConfigUpdateMessage += partialConfigUpdateMessage; + + if (partialConfigUpdateMessage.includes('}')) { + currentConfigUpdateMessage = this.parseAndValidateConfigUpdateMessage( + currentConfigUpdateMessage + ); + + if (currentConfigUpdateMessage.length === 0) { + continue; + } + + try { + const jsonObject = JSON.parse(currentConfigUpdateMessage); + + if (this.isEventListenersEmpty()) { + break; + } + + if ( + REALTIME_DISABLED_KEY in jsonObject && + jsonObject[REALTIME_DISABLED_KEY] === true + ) { + const error = ERROR_FACTORY.create( + ErrorCode.CONFIG_UPDATE_UNAVAILABLE, + { + originalErrorMessage: + 'The server is temporarily unavailable. Try again in a few minutes.' + } + ); + this.propagateError(error); + break; + } + + if (TEMPLATE_VERSION_KEY in jsonObject) { + const oldTemplateVersion = + await this.storage.getActiveConfigTemplateVersion(); + const targetTemplateVersion = Number( + jsonObject[TEMPLATE_VERSION_KEY] + ); + if ( + oldTemplateVersion && + targetTemplateVersion > oldTemplateVersion + ) { + await this.autoFetch( + MAXIMUM_FETCH_ATTEMPTS, + targetTemplateVersion + ); + } + } + + // This field in the response indicates that the realtime request should retry after the + // specified interval to establish a long-lived connection. This interval extends the + // backoff duration without affecting the number of retries, so it will not enter an + // exponential backoff state. + if (REALTIME_RETRY_INTERVAL in jsonObject) { + const retryIntervalSeconds = Number( + jsonObject[REALTIME_RETRY_INTERVAL] + ); + await this.updateBackoffMetadataWithRetryInterval( + retryIntervalSeconds + ); + } + } catch (e: unknown) { + this.logger.debug('Unable to parse latest config update message.', e); + const errorMessage = e instanceof Error ? e.message : String(e); + this.propagateError( + ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID, { + originalErrorMessage: errorMessage + }) + ); + } + currentConfigUpdateMessage = ''; + } + } + } + + private async listenForNotifications( + reader: ReadableStreamDefaultReader + ): Promise { + try { + await this.handleNotifications(reader); + } catch (e) { + // If the real-time connection is at an unexpected lifecycle state when the app is + // backgrounded, it's expected closing the connection will throw an exception. + if (!this.isInBackground) { + // Otherwise, the real-time server connection was closed due to a transient issue. + this.logger.debug( + 'Real-time connection was closed due to an exception.' + ); + } + } + } + /** * Open the real-time connection, begin listening for updates, and auto-fetch when an update is * received. @@ -236,7 +562,7 @@ export class RealtimeHandler { *

If the connection is successful, this method will block on its thread while it reads the * chunk-encoded HTTP body. When the connection closes, it attempts to reestablish the stream. */ - private async beginRealtimeHttpStream(): Promise { + private async prepareAndBeginRealtimeHttpStream(): Promise { if (!this.checkAndSetHttpConnectionFlagIfNotRunning()) { return; } @@ -257,14 +583,15 @@ export class RealtimeHandler { let response: Response | undefined; let responseCode: number | undefined; try { - //this has been called in the try cause it throws an error if the method does not get implemented response = await this.createRealtimeConnection(); responseCode = response.status; if (response.ok && response.body) { this.resetRetryCount(); await this.resetRealtimeBackoff(); - //const configAutoFetch = this.startAutoFetch(reader); - //await configAutoFetch.listenForNotifications(); + const reader = response.body.getReader(); + this.reader = reader; + // Start listening for realtime notifications. + await this.listenForNotifications(reader); } } catch (error) { if (this.isInBackground) { @@ -281,12 +608,14 @@ export class RealtimeHandler { } } finally { // Close HTTP connection and associated streams. - this.closeRealtimeHttpConnection(); + await this.closeRealtimeHttpConnection(); this.setIsHttpConnectionRunning(false); // Update backoff metadata if the connection failed in the foreground. const connectionFailed = - responseCode == null || this.isStatusCodeRetryable(responseCode); + !this.isInBackground && + (responseCode === undefined || + this.isStatusCodeRetryable(responseCode)); if (connectionFailed) { await this.updateBackoffMetadataWithLastFailedStreamConnectionTime( @@ -301,7 +630,6 @@ export class RealtimeHandler { const firebaseError = ERROR_FACTORY.create( ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { - httpStatus: responseCode, originalErrorMessage: errorMessage } ); @@ -333,9 +661,8 @@ export class RealtimeHandler { } if (this.httpRetriesRemaining > 0) { this.httpRetriesRemaining--; - setTimeout(async () => { - await this.beginRealtimeHttpStream(); - }, delayMillis); + await new Promise(resolve => setTimeout(resolve, delayMillis)); + void this.prepareAndBeginRealtimeHttpStream(); } else if (!this.isInBackground) { const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { originalErrorMessage: @@ -355,9 +682,9 @@ export class RealtimeHandler { * Adds an observer to the realtime updates. * @param observer The observer to add. */ - async addObserver(observer: ConfigUpdateObserver): Promise { + addObserver(observer: ConfigUpdateObserver): void { this.observers.add(observer); - await this.beginRealtime(); + void this.beginRealtime(); } /** @@ -370,11 +697,17 @@ export class RealtimeHandler { } } + /** + * Handles changes to the application's visibility state, managing the real-time connection. + * + * When the application is moved to the background, this method closes the existing + * real-time connection to save resources. When the application returns to the + * foreground, it attempts to re-establish the connection. + */ private async onVisibilityChange(visible: unknown): Promise { this.isInBackground = !visible; - if (!visible && this.controller) { - this.controller.abort(); - this.controller = undefined; + if (!visible) { + await this.closeRealtimeHttpConnection(); } else if (visible) { await this.beginRealtime(); } diff --git a/packages/remote-config/src/client/remote_config_fetch_client.ts b/packages/remote-config/src/client/remote_config_fetch_client.ts index 359bb7c0409..02fdbd19283 100644 --- a/packages/remote-config/src/client/remote_config_fetch_client.ts +++ b/packages/remote-config/src/client/remote_config_fetch_client.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { CustomSignals, FetchResponse } from '../public_types'; +import { CustomSignals, FetchResponse, FetchType } from '../public_types'; /** * Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the @@ -100,4 +100,18 @@ export interface FetchRequest { *

Optional in case no custom signals are set for the instance. */ customSignals?: CustomSignals; + + /** + * The type of fetch to perform, such as a regular fetch or a real-time fetch. + * + *

Optional as not all fetch requests need to be distinguished. + */ + fetchType?: FetchType; + + /** + * The number of fetch attempts made so far for this request. + * + *

Optional as not all fetch requests are part of a retry series. + */ + fetchAttempt?: number; } diff --git a/packages/remote-config/src/client/rest_client.ts b/packages/remote-config/src/client/rest_client.ts index 57f55f53d88..42b0cab27c6 100644 --- a/packages/remote-config/src/client/rest_client.ts +++ b/packages/remote-config/src/client/rest_client.ts @@ -88,6 +88,8 @@ export class RestClient implements RemoteConfigFetchClient { // Deviates from pure decorator by not passing max-age header since we don't currently have // service behavior using that header. 'If-None-Match': request.eTag || '*' + // TODO: Add this header once CORS error is fixed internally. + //'X-Firebase-RC-Fetch-Type': `${fetchType}/${fetchAttempt}` }; const requestBody: FetchRequestBody = { @@ -140,6 +142,7 @@ export class RestClient implements RemoteConfigFetchClient { let config: FirebaseRemoteConfigObject | undefined; let state: string | undefined; + let templateVersion: number | undefined; // JSON parsing throws SyntaxError if the response body isn't a JSON string. // Requesting application/json and checking for a 200 ensures there's JSON data. @@ -154,6 +157,7 @@ export class RestClient implements RemoteConfigFetchClient { } config = responseBody['entries']; state = responseBody['state']; + templateVersion = responseBody['templateVersion']; } // Normalizes based on legacy state. @@ -176,6 +180,6 @@ export class RestClient implements RemoteConfigFetchClient { }); } - return { status, eTag: responseEtag, config }; + return { status, eTag: responseEtag, config, templateVersion }; } } diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index ac7c71b3218..dea9f43e922 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -34,7 +34,10 @@ export const enum ErrorCode { FETCH_STATUS = 'fetch-status', INDEXED_DB_UNAVAILABLE = 'indexed-db-unavailable', CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals', - CONFIG_UPDATE_STREAM_ERROR = 'stream-error' + CONFIG_UPDATE_STREAM_ERROR = 'stream-error', + CONFIG_UPDATE_UNAVAILABLE = 'realtime-unavailable', + CONFIG_UPDATE_MESSAGE_INVALID = 'update-message-invalid', + CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched' } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { @@ -75,7 +78,13 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: 'Setting more than {$maxSignals} custom signals is not supported.', [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: - 'The stream was not able to connect to the backend.' + 'The stream was not able to connect to the backend: {$originalErrorMessage}.', + [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: + 'The Realtime service is unavailable: {$originalErrorMessage}', + [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: + 'The stream invalidation message was unparsable: {$originalErrorMessage}', + [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: + 'Unable to fetch the latest config: {$originalErrorMessage}' }; // Note this is effectively a type system binding a code to params. This approach overlaps with the @@ -95,10 +104,10 @@ interface ErrorParams { [ErrorCode.FETCH_PARSE]: { originalErrorMessage: string }; [ErrorCode.FETCH_STATUS]: { httpStatus: number }; [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: { maxSignals: number }; - [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: { - httpStatus?: number; - originalErrorMessage?: string; - }; + [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: { originalErrorMessage: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index e65b9557b9e..964726a51f4 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -52,6 +52,8 @@ export interface RemoteConfig { /** * Defines a self-descriptive reference for config key-value pairs. + * + * @public */ export interface FirebaseRemoteConfigObject { [key: string]: string; @@ -62,6 +64,8 @@ export interface FirebaseRemoteConfigObject { * *

Modeled after the native `Response` interface, but simplified for Remote Config's * use case. + * + * @public */ export interface FetchResponse { /** @@ -90,6 +94,11 @@ export interface FetchResponse { */ config?: FirebaseRemoteConfigObject; + /** + * The version number of the config template fetched from the server. + */ + templateVersion?: number; + // Note: we're not extracting experiment metadata until // ABT and Analytics have Web SDKs. } @@ -257,6 +266,18 @@ export interface ConfigUpdateObserver { */ export type Unsubscribe = () => void; +/** + * Indicates the type of fetch request. + * + *

    + *
  • "BASE" indicates a standard fetch request.
  • + *
  • "REALTIME" indicates a fetch request triggered by a real-time update.
  • + *
+ * + * @public + */ +export type FetchType = 'BASE' | 'REALTIME'; + declare module '@firebase/component' { interface NameServiceMapping { 'remote-config': RemoteConfig; diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index df54439b3f5..9c25d01831a 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -116,7 +116,9 @@ export function registerRemoteConfig(): void { projectId, apiKey, appId, - logger + logger, + storageCache, + cachingClient ); const remoteConfigInstance = new RemoteConfigImpl( diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index 8dd767ef101..bd262d29968 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -192,10 +192,6 @@ export abstract class Storage { return this.get('realtime_backoff_metadata'); } - getLastKnownTemplateVersion(): Promise { - return this.get('last_known_template_version'); - } - setRealtimeBackoffMetadata( realtimeMetadata: RealtimeBackoffMetadata ): Promise { @@ -204,6 +200,14 @@ export abstract class Storage { realtimeMetadata ); } + + getActiveConfigTemplateVersion(): Promise { + return this.get('last_known_template_version'); + } + + setActiveConfigTemplateVersion(version: number): Promise { + return this.set('last_known_template_version', version); + } } export class IndexedDbStorage extends Storage { diff --git a/packages/remote-config/test/api.test.ts b/packages/remote-config/test/api.test.ts index b1fe658ebae..f38b4ca0bee 100644 --- a/packages/remote-config/test/api.test.ts +++ b/packages/remote-config/test/api.test.ts @@ -17,11 +17,13 @@ import { expect } from 'chai'; import { + ConfigUpdateObserver, ensureInitialized, fetchAndActivate, FetchResponse, getRemoteConfig, - getString + getString, + onConfigUpdate } from '../src'; import '../test/setup'; import { @@ -34,6 +36,8 @@ import * as sinon from 'sinon'; import { Component, ComponentType } from '@firebase/component'; import { FirebaseInstallations } from '@firebase/installations-types'; import { openDatabase, APP_NAMESPACE_STORE } from '../src/storage/storage'; +import { ERROR_FACTORY, ErrorCode } from '../src/errors'; +import { RemoteConfig as RemoteConfigImpl } from '../src/remote_config'; const fakeFirebaseConfig = { apiKey: 'api-key', @@ -45,6 +49,12 @@ const fakeFirebaseConfig = { appId: '1:111:web:a1234' }; +const mockObserver = { + next: sinon.stub(), + error: sinon.stub(), + complete: sinon.stub() +}; + async function clearDatabase(): Promise { const db = await openDatabase(); db.transaction([APP_NAMESPACE_STORE], 'readwrite') @@ -57,7 +67,8 @@ describe('Remote Config API', () => { const STUB_FETCH_RESPONSE: FetchResponse = { status: 200, eTag: 'asdf', - config: { 'foobar': 'hello world' } + config: { 'foobar': 'hello world' }, + templateVersion: 1 }; let fetchStub: sinon.SinonStub; @@ -94,7 +105,8 @@ describe('Remote Config API', () => { json: () => Promise.resolve({ entries: response.config, - state: 'OK' + state: 'OK', + templateVersion: response.templateVersion }) } as Response) ); @@ -149,4 +161,99 @@ describe('Remote Config API', () => { await ensureInitialized(rc); expect(getString(rc, 'foobar')).to.equal('hello world'); }); + + describe('onConfigUpdate', () => { + let capturedObserver: ConfigUpdateObserver | undefined; + let rc: RemoteConfigImpl; + let addObserverStub: sinon.SinonStub; + let removeObserverStub: sinon.SinonStub; + + beforeEach(() => { + rc = getRemoteConfig(app) as RemoteConfigImpl; + + addObserverStub = sinon + .stub(rc._realtimeHandler, 'addObserver') + .resolves(); + removeObserverStub = sinon + .stub(rc._realtimeHandler, 'removeObserver') + .resolves(); + + addObserverStub.callsFake(async (observer: ConfigUpdateObserver) => { + capturedObserver = observer; + }); + }); + + afterEach(() => { + capturedObserver = undefined; + addObserverStub.restore(); + removeObserverStub.restore(); + }); + + it('should call addObserver on the internal realtimeHandler', async () => { + await onConfigUpdate(rc, mockObserver); + expect(addObserverStub).to.have.been.calledOnce; + expect(addObserverStub).to.have.been.calledWith(mockObserver); + }); + + it('should return an unsubscribe function', async () => { + const unsubscribe = await onConfigUpdate(rc, mockObserver); + expect(unsubscribe).to.be.a('function'); + }); + + it('returned unsubscribe function should call removeObserver', async () => { + const unsubscribe = await onConfigUpdate(rc, mockObserver); + + unsubscribe(); + expect(removeObserverStub).to.have.been.calledOnce; + expect(removeObserverStub).to.have.been.calledWith(mockObserver); + }); + + it('observer.next should be called when realtimeHandler propagates an update', async () => { + await onConfigUpdate(rc, mockObserver); + + if (capturedObserver && capturedObserver.next) { + const mockConfigUpdate = { getUpdatedKeys: () => new Set(['new_key']) }; + capturedObserver.next(mockConfigUpdate); + } else { + expect.fail('Observer was not captured or next method is missing.'); + } + + expect(mockObserver.next).to.have.been.calledOnce; + expect(mockObserver.next).to.have.been.calledWithMatch({ + getUpdatedKeys: sinon.match.func + }); + expect( + mockObserver.next.getCall(0).args[0].getUpdatedKeys() + ).to.deep.equal(new Set(['new_key'])); + }); + + it('observer.error should be called when realtimeHandler propagates an error', async () => { + await onConfigUpdate(rc, mockObserver); + + if (capturedObserver && capturedObserver.error) { + const expectedOriginalErrorMessage = 'Realtime stream error'; + const mockError = ERROR_FACTORY.create( + ErrorCode.CONFIG_UPDATE_STREAM_ERROR, + { + originalErrorMessage: expectedOriginalErrorMessage + } + ); + capturedObserver.error(mockError); + } else { + expect.fail('Observer was not captured or error method is missing.'); + } + + expect(mockObserver.error).to.have.been.calledOnce; + const receivedError = mockObserver.error.getCall(0).args[0]; + + expect(receivedError.message).to.equal( + 'Remote Config: The stream was not able to connect to the backend: Realtime stream error. (remoteconfig/stream-error).' + ); + expect(receivedError).to.have.nested.property( + 'customData.originalErrorMessage', + 'Realtime stream error' + ); + expect((receivedError as any).code).to.equal('remoteconfig/stream-error'); + }); + }); }); diff --git a/packages/remote-config/test/client/realtime_handler.test.ts b/packages/remote-config/test/client/realtime_handler.test.ts new file mode 100644 index 00000000000..fbdbe982b8b --- /dev/null +++ b/packages/remote-config/test/client/realtime_handler.test.ts @@ -0,0 +1,911 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * 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 { expect, use } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { RealtimeHandler } from '../../src/client/realtime_handler'; +import { _FirebaseInstallationsInternal } from '@firebase/installations'; +import { Logger } from '@firebase/logger'; +import { Storage } from '../../src/storage/storage'; +import { StorageCache } from '../../src/storage/storage_cache'; +import { CachingClient } from '../../src/client/caching_client'; +import { ConfigUpdateObserver, FetchResponse } from '../../src/public_types'; +import { ErrorCode } from '../../src/errors'; +import { VisibilityMonitor } from '../../src/client/visibility_monitor'; + +use(sinonChai); + +const FAKE_APP_ID = '1:123456789:web:abcdef'; +const INSTALLATION_ID_STRING = 'installation-id-123'; +const INSTALLATION_AUTH_TOKEN_STRING = 'installation-auth-token-456'; +const PROJECT_NUMBER = '123456789'; +const API_KEY = 'api-key-123'; +const FAKE_NOW = 1234567890; +const ORIGINAL_RETRIES = 8; +const MAXIMUM_FETCH_ATTEMPTS = 3; + +const DUMMY_FETCH_RESPONSE: FetchResponse = { + status: 200, + config: { testKey: 'test_value' }, + eTag: 'etag-2', + templateVersion: 2 +}; + +// Helper to create a mock ReadableStream from a string array. +function createMockReadableStream( + chunks: string[] = [] +): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } + }); +} + +function createStreamingMockReader( + chunks: string[] +): ReadableStreamDefaultReader { + const stream = createMockReadableStream(chunks); + const reader = stream.getReader(); + const originalRead = reader.read; + sinon.stub(reader, 'read').callsFake(originalRead.bind(reader)); + return reader; +} + +describe('RealtimeHandler', () => { + let mockFetch: sinon.SinonStub; + let mockInstallations: sinon.SinonStubbedInstance<_FirebaseInstallationsInternal>; + let mockStorage: sinon.SinonStubbedInstance; + let mockStorageCache: sinon.SinonStubbedInstance; + let mockCachingClient: sinon.SinonStubbedInstance; + let mockLogger: sinon.SinonStubbedInstance; + let realtime: RealtimeHandler; + let clock: sinon.SinonFakeTimers; + let visibilityMonitorOnStub: sinon.SinonStub; + + beforeEach(async () => { + mockFetch = sinon.stub(window, 'fetch'); + mockInstallations = { + getId: sinon.stub().resolves(INSTALLATION_ID_STRING), + getToken: sinon.stub().resolves(INSTALLATION_AUTH_TOKEN_STRING) + } as any; + + mockLogger = sinon.createStubInstance(Logger); + + mockStorage = { + getRealtimeBackoffMetadata: sinon.stub().resolves(undefined), + setRealtimeBackoffMetadata: sinon.stub().resolves(), + getActiveConfigEtag: sinon.stub().resolves('etag-1'), + getActiveConfigTemplateVersion: sinon.stub().resolves(1), + getActiveConfig: sinon.stub().resolves({}), + + getLastFetchStatus: sinon.stub(), + setLastFetchStatus: sinon.stub(), + getLastSuccessfulFetchTimestampMillis: sinon.stub(), + setLastSuccessfulFetchTimestampMillis: sinon.stub(), + getLastSuccessfulFetchResponse: sinon.stub(), + setLastSuccessfulFetchResponse: sinon.stub(), + setActiveConfig: sinon.stub(), + setActiveConfigEtag: sinon.stub(), + getThrottleMetadata: sinon.stub(), + setThrottleMetadata: sinon.stub(), + deleteThrottleMetadata: sinon.stub(), + getCustomSignals: sinon.stub(), + setCustomSignals: sinon.stub(), + setActiveConfigTemplateVersion: sinon.stub() + } as sinon.SinonStubbedInstance; + + mockStorageCache = sinon.createStubInstance(StorageCache); + mockStorageCache.getLastFetchStatus.returns('success'); + mockStorageCache.getCustomSignals.returns(undefined); + + mockCachingClient = sinon.createStubInstance(CachingClient); + mockCachingClient.fetch.resolves(DUMMY_FETCH_RESPONSE); + + visibilityMonitorOnStub = sinon.stub(); + sinon.stub(VisibilityMonitor, 'getInstance').returns({ + on: visibilityMonitorOnStub + } as any); + + clock = sinon.useFakeTimers(FAKE_NOW); + + realtime = new RealtimeHandler( + mockInstallations, + mockStorage as any, + 'sdk-version', + 'namespace', + PROJECT_NUMBER, + API_KEY, + FAKE_APP_ID, + mockLogger as any, + mockStorageCache as any, + mockCachingClient as any + ); + }); + + afterEach(() => { + sinon.restore(); + clock.restore(); + }); + + describe('constructor', () => { + it('should initialize with default retries if no backoff metadata in storage', async () => { + await clock.runAllAsync(); + expect((realtime as any).httpRetriesRemaining).to.equal(ORIGINAL_RETRIES); + }); + + it('should set retries remaining from storage if available', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves({ + backoffEndTimeMillis: new Date(FAKE_NOW - 1000), // In the past, so no backoff + numFailedStreams: 3 + }); + + realtime = new RealtimeHandler( + mockInstallations, + mockStorage as any, + 'sdk-version', + 'namespace', + PROJECT_NUMBER, + API_KEY, + FAKE_APP_ID, + mockLogger as any, + mockStorageCache as any, + mockCachingClient as any + ); + await clock.runAllAsync(); + expect((realtime as any).httpRetriesRemaining).to.equal( + ORIGINAL_RETRIES - 3 + ); + }); + }); + + describe('getRealtimeUrl', () => { + it('should construct the correct URL', () => { + const url = (realtime as any).getRealtimeUrl(); + expect(url.toString()).to.equal( + `https://firebaseremoteconfigrealtime.googleapis.com/v1/projects/${PROJECT_NUMBER}/namespaces/namespace:streamFetchInvalidations?key=${API_KEY}` + ); + }); + + it('should use the URL base from window if it exists', () => { + (window as any).FIREBASE_REMOTE_CONFIG_URL_BASE = + 'https://test.googleapis.com'; + const url = (realtime as any).getRealtimeUrl(); + expect(url.toString()).to.equal( + `https://test.googleapis.com/v1/projects/${PROJECT_NUMBER}/namespaces/namespace:streamFetchInvalidations?key=${API_KEY}` + ); + delete (window as any).FIREBASE_REMOTE_CONFIG_URL_BASE; + }); + }); + + describe('isStatusCodeRetryable', () => { + it('should return true for retryable status codes', () => { + const retryableCodes = [408, 429, 502, 503, 504]; + retryableCodes.forEach(code => { + expect((realtime as any).isStatusCodeRetryable(code)).to.be.true; + }); + }); + + it('should return true for undefined status code', () => { + expect((realtime as any).isStatusCodeRetryable(undefined)).to.be.true; + }); + + it('should return false for non-retryable status codes', () => { + // This is a sample of non-retryable codes for testing purposes. + const nonRetryableCodes = [200, 304, 400, 401, 403]; + nonRetryableCodes.forEach(code => { + expect((realtime as any).isStatusCodeRetryable(code)).to.be.false; + }); + }); + }); + + describe('updateBackoffMetadataWithLastFailedStreamConnectionTime', () => { + it('should increment numFailedStreams and set backoffEndTimeMillis', async () => { + const spy = mockStorage.setRealtimeBackoffMetadata; + const lastFailedTime = new Date(FAKE_NOW); + + await ( + realtime as any + ).updateBackoffMetadataWithLastFailedStreamConnectionTime(lastFailedTime); + + expect(spy).to.have.been.calledOnce; + const metadata = spy.getCall(0).args[0]; + expect(metadata.numFailedStreams).to.equal(1); + expect(metadata.backoffEndTimeMillis.getTime()).to.be.greaterThan( + lastFailedTime.getTime() + ); + }); + }); + + describe('updateBackoffMetadataWithRetryInterval', () => { + it('should set backoffEndTimeMillis based on provided retryIntervalSeconds and then retry connection', async () => { + const setMetadataSpy = mockStorage.setRealtimeBackoffMetadata; + const retryHttpConnectionSpy = sinon.spy( + realtime as any, + 'retryHttpConnectionWhenBackoffEnds' + ); + const retryInterval = 10; + + await (realtime as any).updateBackoffMetadataWithRetryInterval( + retryInterval + ); + + expect(setMetadataSpy).to.have.been.calledOnce; + const metadata = setMetadataSpy.getCall(0).args[0]; + expect(metadata.backoffEndTimeMillis.getTime()).to.be.closeTo( + FAKE_NOW + retryInterval * 1000, + 100 + ); + expect(retryHttpConnectionSpy).to.have.been.calledOnce; + }); + }); + + describe('closeRealtimeHttpConnection', () => { + let mockController: sinon.SinonStubbedInstance; + let mockReader: sinon.SinonStubbedInstance< + ReadableStreamDefaultReader + >; + + beforeEach(() => { + mockController = sinon.createStubInstance(AbortController); + mockReader = sinon.createStubInstance(ReadableStreamDefaultReader); + (realtime as any).controller = mockController; + (realtime as any).reader = mockReader; + }); + + it('should abort controller and cancel reader', async () => { + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockController.abort).to.have.been.calledOnce; + expect(mockReader.cancel).to.have.been.calledOnce; + expect((realtime as any).controller).to.be.undefined; + expect((realtime as any).reader).to.be.undefined; + }); + + it('should handle reader cancellation failure gracefully', async () => { + mockReader.cancel.rejects(new Error('test error')); + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockLogger.debug).to.have.been.calledWith( + 'Failed to cancel the reader, connection was lost.' + ); + // Should still clear reader + expect((realtime as any).reader).to.be.undefined; + }); + + it('should handle being called when reader is already undefined', async () => { + (realtime as any).reader = undefined; + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockController.abort).to.have.been.calledOnce; + expect((realtime as any).controller).to.be.undefined; + }); + + it('should handle being called when controller is already undefined', async () => { + (realtime as any).controller = undefined; + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockReader.cancel).to.have.been.calledOnce; + expect((realtime as any).reader).to.be.undefined; + }); + }); + + describe('resetRealtimeBackoff', () => { + it('should reset backoff metadata in storage', async () => { + const spy = mockStorage.setRealtimeBackoffMetadata; + await (realtime as any).resetRealtimeBackoff(); + expect(spy).to.have.been.calledOnce; + const metadata = spy.getCall(0).args[0]; + expect(metadata.numFailedStreams).to.equal(0); + expect(metadata.backoffEndTimeMillis.getTime()).to.equal(-1); + }); + }); + + describe('establishRealtimeConnection', () => { + it('should send correct headers and body for realtime connection', async () => { + mockStorage.getActiveConfigEtag.resolves('current-etag'); + mockStorage.getActiveConfigTemplateVersion.resolves(10); + + const url = new URL('https://example.com/stream'); + const signal = new AbortController().signal; + + await (realtime as any).establishRealtimeConnection( + url, + INSTALLATION_ID_STRING, + INSTALLATION_AUTH_TOKEN_STRING, + signal + ); + + expect(mockFetch).to.have.been.calledOnce; + const [fetchUrl, fetchOptions] = mockFetch.getCall(0).args; + expect(fetchUrl).to.equal(url); + expect(fetchOptions.method).to.equal('POST'); + expect(fetchOptions.headers).to.deep.include({ + 'X-Goog-Api-Key': API_KEY, + 'X-Goog-Firebase-Installations-Auth': INSTALLATION_AUTH_TOKEN_STRING, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'If-None-Match': 'current-etag', + 'Content-Encoding': 'gzip' + }); + const body = JSON.parse(fetchOptions.body as string); + expect(body).to.deep.equal({ + project: PROJECT_NUMBER, + namespace: 'namespace', + lastKnownVersionNumber: 10, + appId: FAKE_APP_ID, + sdkVersion: 'sdk-version', + appInstanceId: INSTALLATION_ID_STRING + }); + }); + }); + + describe('retryHttpConnectionWhenBackoffEnds', () => { + let makeRealtimeHttpConnectionSpy: sinon.SinonSpy; + + beforeEach(() => { + makeRealtimeHttpConnectionSpy = sinon.spy( + realtime as any, + 'makeRealtimeHttpConnection' + ); + }); + + it('should call makeRealtimeHttpConnection with 0 delay if no backoff metadata', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves(undefined); + await (realtime as any).retryHttpConnectionWhenBackoffEnds(); + expect(makeRealtimeHttpConnectionSpy).to.have.been.calledWith(0); + }); + + it('should call makeRealtimeHttpConnection with calculated delay if backoff metadata exists', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves({ + // 5 seconds in the future + backoffEndTimeMillis: new Date(FAKE_NOW + 5000), + numFailedStreams: 1 + }); + await (realtime as any).retryHttpConnectionWhenBackoffEnds(); + expect(makeRealtimeHttpConnectionSpy).to.have.been.calledOnce; + const delay = makeRealtimeHttpConnectionSpy.getCall(0).args[0]; + expect(delay).to.be.closeTo(5000, 100); + }); + }); + + describe('fetchResponseIsUpToDate', () => { + it('should return true if templateVersion is greater or equal', () => { + const fetchResponse: FetchResponse = { + config: { k: 'v' }, + templateVersion: 5, + status: 200, + eTag: 'e' + }; + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.true; + }); + + it('should return false if templateVersion is smaller', () => { + const fetchResponse: FetchResponse = { + config: { k: 'v' }, + templateVersion: 4, + status: 200, + eTag: 'e' + }; + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.false; + }); + + it('should return true if no config and lastFetchStatus is success', () => { + const fetchResponse: FetchResponse = { + config: undefined, + templateVersion: undefined, + status: 304, + eTag: 'e' + }; + mockStorageCache.getLastFetchStatus.returns('success'); + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.true; + }); + + it('should return false if no config and lastFetchStatus is not success', () => { + const fetchResponse: FetchResponse = { + config: undefined, + templateVersion: undefined, + status: 304, + eTag: 'e' + }; + mockStorageCache.getLastFetchStatus.returns('throttle'); // Or any other non-'success' status + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.false; + }); + }); + + describe('fetchLatestConfig', () => { + let autoFetchSpy: sinon.SinonSpy; + let executeAllListenerCallbacksSpy: sinon.SinonSpy; + + beforeEach(() => { + autoFetchSpy = sinon.spy(realtime as any, 'autoFetch'); + executeAllListenerCallbacksSpy = sinon.spy( + realtime as any, + 'executeAllListenerCallbacks' + ); + mockStorage.getActiveConfig.resolves({ existingKey: 'value' }); + mockStorage.getActiveConfigTemplateVersion.resolves(1); + }); + + afterEach(() => { + autoFetchSpy.restore(); + executeAllListenerCallbacksSpy.restore(); + }); + + it('should fetch, identify changed keys, and notify observers', async () => { + mockCachingClient.fetch.resolves({ + config: { existingKey: 'new_value', newKey: 'value' }, + templateVersion: 2, + status: 200, + eTag: 'e' + }); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(mockCachingClient.fetch).to.have.been.calledOnce; + expect(executeAllListenerCallbacksSpy).to.have.been.calledOnce; + const configUpdate = executeAllListenerCallbacksSpy.getCall(0).args[0]; + expect(configUpdate.getUpdatedKeys()).to.deep.equal( + new Set(['existingKey', 'newKey']) + ); + }); + + it('should retry with autoFetch if fetched version is not up-to-date', async () => { + autoFetchSpy.restore(); + const autoFetchStub = sinon.stub(realtime as any, 'autoFetch'); + + mockCachingClient.fetch.resolves({ + config: { k: 'v' }, + templateVersion: 1, + status: 200, + eTag: 'e' + }); + mockStorage.getActiveConfigTemplateVersion.resolves(0); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(mockCachingClient.fetch).to.have.been.calledOnce; + expect(autoFetchStub).to.have.been.calledOnceWith( + MAXIMUM_FETCH_ATTEMPTS - 1, + 2 + ); + }); + + it('should not notify if no keys have changed', async () => { + mockCachingClient.fetch.resolves({ + config: { existingKey: 'value' }, + templateVersion: 2, + status: 200, + eTag: 'e' + }); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(executeAllListenerCallbacksSpy).not.to.have.been.called; + }); + + it('should propagate error on fetch failure', async () => { + const testError = new Error('Network failed'); + mockCachingClient.fetch.rejects(testError); + const propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_NOT_FETCHED); + }); + + it('should include custom signals in fetch request', async () => { + mockStorageCache.getCustomSignals.returns({ signal1: 'value1' }); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + expect(mockLogger.debug).to.have.been.calledWith( + `Fetching config with custom signals: {"signal1":"value1"}` + ); + }); + + it('should handle null activatedConfigs gracefully', async () => { + mockCachingClient.fetch.resolves({ + config: { newKey: 'value' }, + templateVersion: 2, + status: 200, + eTag: 'e' + }); + mockStorage.getActiveConfig.resolves(null as any); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(executeAllListenerCallbacksSpy).to.have.been.calledOnce; + const configUpdate = executeAllListenerCallbacksSpy.getCall(0).args[0]; + expect(configUpdate.getUpdatedKeys()).to.deep.equal(new Set(['newKey'])); + }); + }); + + describe('autoFetch', () => { + let fetchLatestConfigStub: sinon.SinonStub; + let propagateErrorSpy: sinon.SinonSpy; + + beforeEach(() => { + fetchLatestConfigStub = sinon.stub(realtime as any, 'fetchLatestConfig'); + propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + }); + + afterEach(() => { + fetchLatestConfigStub.restore(); + propagateErrorSpy.restore(); + }); + + it('should call fetchLatestConfig after a random delay', async () => { + (realtime as any).autoFetch(MAXIMUM_FETCH_ATTEMPTS, 10); + await clock.runAllAsync(); + + expect(fetchLatestConfigStub).to.have.been.calledOnceWith( + MAXIMUM_FETCH_ATTEMPTS, + 10 + ); + }); + + it('should propagate an error if remaining attempts is zero', async () => { + await (realtime as any).autoFetch(0, 10); + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_NOT_FETCHED); + expect(fetchLatestConfigStub).not.to.have.been.called; + }); + }); + + describe('handleNotifications', () => { + let mockReader: ReadableStreamDefaultReader; + let autoFetchSpy: sinon.SinonSpy; + let executeAllListenerCallbacksSpy: sinon.SinonSpy; + let propagateErrorSpy: sinon.SinonSpy; + + beforeEach(() => { + autoFetchSpy = sinon.spy(realtime as any, 'autoFetch'); + executeAllListenerCallbacksSpy = sinon.spy( + realtime as any, + 'executeAllListenerCallbacks' + ); + propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + (realtime as any).observers.add({}); + }); + + afterEach(() => { + autoFetchSpy.restore(); + executeAllListenerCallbacksSpy.restore(); + propagateErrorSpy.restore(); + }); + + it('should set backoff metadata if REALTIME_RETRY_INTERVAL is present', async () => { + const updateBackoffStub = sinon + .stub(realtime as any, 'updateBackoffMetadataWithRetryInterval') + .resolves(); + + mockReader = createStreamingMockReader(['{"retryIntervalSeconds": 60}']); + + await (realtime as any).handleNotifications(mockReader); + + expect(updateBackoffStub).to.have.been.calledOnceWith(60); + }); + + it('should propagate error on invalid JSON', async () => { + mockReader = createStreamingMockReader(['{invalid_json}']); + + await (realtime as any).handleNotifications(mockReader); + + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID); + }); + + it('should break if event listeners become empty during handling', async () => { + autoFetchSpy.restore(); + + mockReader = createStreamingMockReader([ + '{"latestTemplateVersionNumber": 10}' + ]); + mockStorage.getActiveConfigTemplateVersion.resolves(5); + mockCachingClient.fetch.resolves({ + config: { k: 'v' }, + templateVersion: 10, + status: 200, + eTag: 'e' + }); + + const observer = (realtime as any).observers.values().next().value; + const originalJsonParse = JSON.parse; + JSON.parse = (text: string) => { + (realtime as any).observers.delete(observer); + return originalJsonParse(text); + }; + + await (realtime as any).handleNotifications(mockReader); + + expect(mockReader.read).to.have.been.calledOnce; + + JSON.parse = originalJsonParse; + }); + }); + + describe('beginRealtimeHttpStream', () => { + let createRealtimeConnectionSpy: sinon.SinonStub; + let listenForNotificationsSpy: sinon.SinonSpy; + let closeRealtimeHttpConnectionSpy: sinon.SinonSpy; + let retryHttpConnectionWhenBackoffEndsSpy: sinon.SinonStub; + let updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy: sinon.SinonSpy; + let propagateErrorSpy: sinon.SinonSpy; + let checkAndSetHttpConnectionFlagIfNotRunningSpy: sinon.SinonStub; + + beforeEach(() => { + createRealtimeConnectionSpy = sinon.stub( + realtime as any, + 'createRealtimeConnection' + ); + listenForNotificationsSpy = sinon.spy( + realtime as any, + 'listenForNotifications' + ); + closeRealtimeHttpConnectionSpy = sinon.spy( + realtime as any, + 'closeRealtimeHttpConnection' + ); + + retryHttpConnectionWhenBackoffEndsSpy = sinon + .stub(realtime as any, 'retryHttpConnectionWhenBackoffEnds') + .resolves(); + updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy = sinon.spy( + realtime as any, + 'updateBackoffMetadataWithLastFailedStreamConnectionTime' + ); + propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + checkAndSetHttpConnectionFlagIfNotRunningSpy = sinon + .stub(realtime as any, 'checkAndSetHttpConnectionFlagIfNotRunning') + .returns(true); + + createRealtimeConnectionSpy.resolves( + new Response(createMockReadableStream(), { status: 200 }) + ); + + mockStorage.getRealtimeBackoffMetadata.resolves({ + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + }); + (realtime as any).httpRetriesRemaining = ORIGINAL_RETRIES; + }); + + afterEach(() => { + retryHttpConnectionWhenBackoffEndsSpy.restore(); + }); + + it('should successfully establish and handle a connection', async () => { + const resetRealtimeBackoffSpy = sinon.spy( + realtime as any, + 'resetRealtimeBackoff' + ); + (realtime as any).observers.add({}); + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(createRealtimeConnectionSpy).to.have.been.calledOnce; + expect(listenForNotificationsSpy).to.have.been.calledOnce; + expect(resetRealtimeBackoffSpy).to.have.been.calledOnce; + expect(closeRealtimeHttpConnectionSpy).to.have.been.calledOnce; + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + }); + + it('should return early if connection flag cannot be set', async () => { + checkAndSetHttpConnectionFlagIfNotRunningSpy.returns(false); + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + expect(createRealtimeConnectionSpy).not.to.have.been.called; + }); + + it('should retry if currently in backoff period', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves({ + backoffEndTimeMillis: new Date(FAKE_NOW + 1000), + numFailedStreams: 1 + }); + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + expect(createRealtimeConnectionSpy).not.to.have.been.called; + }); + + it('should update backoff metadata on connection failure in foreground', async () => { + (realtime as any).httpRetriesRemaining = 1; + + createRealtimeConnectionSpy.resolves(new Response(null, { status: 502 })); + (realtime as any).observers.add({}); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy).to.have + .been.calledOnce; + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + }); + + it('should NOT schedule a retry on connection failure in background', async () => { + (realtime as any).isInBackground = true; + + (realtime as any).observers.add({}); + + createRealtimeConnectionSpy.resolves(new Response(null, { status: 503 })); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy).not.to + .have.been.called; + + expect(retryHttpConnectionWhenBackoffEndsSpy).not.to.have.been.called; + }); + + it('should propagate CONFIG_UPDATE_STREAM_ERROR if connection fails non-retryably', async () => { + (realtime as any).httpRetriesRemaining = 1; + createRealtimeConnectionSpy.resolves(new Response(null, { status: 400 })); + (realtime as any).observers.add({}); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(retryHttpConnectionWhenBackoffEndsSpy).not.to.have.been.called; + expect(propagateErrorSpy).to.have.been.calledOnce; + }); + + it('should not propagate error if connection fails non-retryably in background', async () => { + (realtime as any).httpRetriesRemaining = 1; + createRealtimeConnectionSpy.resolves(new Response(null, { status: 400 })); + (realtime as any).observers.add({}); + (realtime as any).isInBackground = true; + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(propagateErrorSpy).to.have.been.calledOnce; + }); + + it('should propagate CONFIG_UPDATE_STREAM_ERROR if retries are exhausted', async () => { + (realtime as any).httpRetriesRemaining = 0; + (realtime as any).observers.add({}); + await (realtime as any).makeRealtimeHttpConnection(0); + + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_STREAM_ERROR); + }); + + it('should handle rejection from createRealtimeConnection', async () => { + const testError = new Error('Connection refused'); + createRealtimeConnectionSpy.rejects(testError); + (realtime as any).observers.add({}); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy).to.have + .been.calledOnce; + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + }); + }); + + describe('canEstablishStreamConnection', () => { + it('returns true if all conditions are met', () => { + (realtime as any).observers.add({}); + (realtime as any).isRealtimeDisabled = false; + (realtime as any).isConnectionActive = false; + (realtime as any).isInBackground = false; + expect((realtime as any).canEstablishStreamConnection()).to.be.true; + }); + + it('returns false if there are no observers', () => { + (realtime as any).observers.clear(); + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + + it('returns false if realtime is disabled', () => { + (realtime as any).observers.add({}); + (realtime as any).isRealtimeDisabled = true; + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + + it('returns false if a connection is already active', () => { + (realtime as any).observers.add({}); + (realtime as any).isConnectionActive = true; + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + + it('returns false if app is in background', () => { + (realtime as any).observers.add({}); + (realtime as any).isInBackground = true; + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + }); + + describe('addObserver/removeObserver', () => { + let beginRealtimeStub: sinon.SinonStub; + const observer: ConfigUpdateObserver = { + next: () => {}, + error: () => {}, + complete: () => {} + }; + + beforeEach(() => { + beginRealtimeStub = sinon + .stub(realtime as any, 'beginRealtime') + .resolves(); + }); + + afterEach(() => { + beginRealtimeStub.restore(); + }); + + it('addObserver should add an observer and start the realtime connection', async () => { + await realtime.addObserver(observer); + expect((realtime as any).observers.has(observer)).to.be.true; + + expect(beginRealtimeStub).to.have.been.calledOnce; + }); + + it('removeObserver should remove an observer', () => { + (realtime as any).observers.add(observer); + realtime.removeObserver(observer); + expect((realtime as any).observers.has(observer)).to.be.false; + }); + }); + describe('onVisibilityChange', () => { + let closeConnectionSpy: sinon.SinonSpy; + let beginRealtimeSpy: sinon.SinonSpy; + + beforeEach(() => { + closeConnectionSpy = sinon.spy( + realtime as any, + 'closeRealtimeHttpConnection' + ); + beginRealtimeSpy = sinon.spy(realtime as any, 'beginRealtime'); + }); + + afterEach(() => { + closeConnectionSpy.restore(); + beginRealtimeSpy.restore(); + }); + + it('should close connection when app goes to background', async () => { + await (realtime as any).onVisibilityChange(false); + expect((realtime as any).isInBackground).to.be.true; + expect(closeConnectionSpy).to.have.been.calledOnce; + expect(beginRealtimeSpy).not.to.have.been.called; + }); + + it('should start connection when app comes to foreground', async () => { + await (realtime as any).onVisibilityChange(true); + expect((realtime as any).isInBackground).to.be.false; + expect(closeConnectionSpy).not.to.have.been.called; + expect(beginRealtimeSpy).to.have.been.calledOnce; + }); + }); +}); diff --git a/packages/remote-config/test/client/rest_client.test.ts b/packages/remote-config/test/client/rest_client.test.ts index 96a6cde8454..bda6fbce01a 100644 --- a/packages/remote-config/test/client/rest_client.test.ts +++ b/packages/remote-config/test/client/rest_client.test.ts @@ -26,6 +26,7 @@ import { FetchRequest, RemoteConfigAbortSignal } from '../../src/client/remote_config_fetch_client'; +import { Storage } from '../../src/storage/storage'; const DEFAULT_REQUEST: FetchRequest = { cacheMaxAgeMillis: 1, @@ -34,6 +35,7 @@ const DEFAULT_REQUEST: FetchRequest = { describe('RestClient', () => { const firebaseInstallations = {} as FirebaseInstallations; + const storage = {} as Storage; let client: RestClient; beforeEach(() => { @@ -51,6 +53,7 @@ describe('RestClient', () => { firebaseInstallations.getToken = sinon .stub() .returns(Promise.resolve('fis-token')); + storage.setActiveConfigTemplateVersion = sinon.stub(); }); describe('fetch', () => { @@ -74,7 +77,8 @@ describe('RestClient', () => { status: 200, eTag: 'etag', state: 'UPDATE', - entries: { color: 'sparkling' } + entries: { color: 'sparkling' }, + templateVersion: 1 }; fetchStub.returns( @@ -85,7 +89,8 @@ describe('RestClient', () => { json: () => Promise.resolve({ entries: expectedResponse.entries, - state: expectedResponse.state + state: expectedResponse.state, + templateVersion: expectedResponse.templateVersion }) } as Response) ); @@ -95,7 +100,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: expectedResponse.status, eTag: expectedResponse.eTag, - config: expectedResponse.entries + config: expectedResponse.entries, + templateVersion: expectedResponse.templateVersion }); }); @@ -184,7 +190,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: 304, eTag: 'response-etag', - config: undefined + config: undefined, + templateVersion: undefined }); }); @@ -222,7 +229,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: 304, eTag: 'etag', - config: undefined + config: undefined, + templateVersion: undefined }); }); @@ -239,7 +247,8 @@ describe('RestClient', () => { await expect(client.fetch(DEFAULT_REQUEST)).to.eventually.be.deep.eq({ status: 200, eTag: 'etag', - config: {} + config: {}, + templateVersion: undefined }); } }); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 2ee9e71eccf..1cc6b62717e 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -390,39 +390,56 @@ describe('RemoteConfig', () => { const ETAG = 'etag'; const CONFIG = { key: 'val' }; const NEW_ETAG = 'new_etag'; + const TEMPLATE_VERSION = 1; let getLastSuccessfulFetchResponseStub: sinon.SinonStub; let getActiveConfigEtagStub: sinon.SinonStub; + let getActiveConfigTemplateVersionStub: sinon.SinonStub; let setActiveConfigEtagStub: sinon.SinonStub; let setActiveConfigStub: sinon.SinonStub; + let setActiveConfigTemplateVersionStub: sinon.SinonStub; beforeEach(() => { getLastSuccessfulFetchResponseStub = sinon.stub(); getActiveConfigEtagStub = sinon.stub(); + getActiveConfigTemplateVersionStub = sinon.stub(); setActiveConfigEtagStub = sinon.stub(); setActiveConfigStub = sinon.stub(); + setActiveConfigTemplateVersionStub = sinon.stub(); storage.getLastSuccessfulFetchResponse = getLastSuccessfulFetchResponseStub; storage.getActiveConfigEtag = getActiveConfigEtagStub; + storage.getActiveConfigTemplateVersion = + getActiveConfigTemplateVersionStub; storage.setActiveConfigEtag = setActiveConfigEtagStub; storageCache.setActiveConfig = setActiveConfigStub; + storage.setActiveConfigTemplateVersion = + setActiveConfigTemplateVersionStub; }); it('does not activate if last successful fetch response is undefined', async () => { getLastSuccessfulFetchResponseStub.returns(Promise.resolve()); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); + getActiveConfigTemplateVersionStub.returns( + Promise.resolve(TEMPLATE_VERSION) + ); const activateResponse = await activate(rc); expect(activateResponse).to.be.false; expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; + expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called; }); it('does not activate if fetched and active etags are the same', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: {}, etag: ETAG }) + Promise.resolve({ + config: {}, + eTag: ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -431,11 +448,16 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.false; expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; + expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called; }); it('activates if fetched and active etags are different', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: CONFIG, eTag: NEW_ETAG }) + Promise.resolve({ + config: CONFIG, + eTag: NEW_ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -444,11 +466,18 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.true; expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG); expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG); + expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( + TEMPLATE_VERSION + ); }); it('activates if fetched is defined but active config is not', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: CONFIG, eTag: NEW_ETAG }) + Promise.resolve({ + config: CONFIG, + eTag: NEW_ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve()); @@ -457,6 +486,9 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.true; expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG); expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG); + expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( + TEMPLATE_VERSION + ); }); });