diff --git a/.changeset/hip-apricots-end.md b/.changeset/hip-apricots-end.md new file mode 100644 index 00000000000..40bfedbaeec --- /dev/null +++ b/.changeset/hip-apricots-end.md @@ -0,0 +1,7 @@ +--- +'@firebase/remote-config-types': minor +'@firebase/remote-config': minor +'firebase': minor +--- + +Added support for custom signal targeting in Remote Config. Use `setCustomSignals` API for setting custom signals and use them to build custom targeting conditions in Remote Config. diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index 980d8f3d287..bf6cf4761de 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -9,6 +9,12 @@ import { FirebaseApp } from '@firebase/app'; // @public export function activate(remoteConfig: RemoteConfig): Promise; +// @public +export interface CustomSignals { + // (undocumented) + [key: string]: string | number | null; +} + // @public export function ensureInitialized(remoteConfig: RemoteConfig): Promise; @@ -62,6 +68,9 @@ export interface RemoteConfigSettings { minimumFetchIntervalMillis: number; } +// @public +export function setCustomSignals(remoteConfig: RemoteConfig, customSignals: CustomSignals): Promise; + // @public export function setLogLevel(remoteConfig: RemoteConfig, logLevel: LogLevel): void; diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index ca06d4f9398..4ab67bcd6ef 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -428,6 +428,8 @@ toc: - title: remote-config path: /docs/reference/js/remote-config.md section: + - title: CustomSignals + path: /docs/reference/js/remote-config.customsignals.md - title: RemoteConfig path: /docs/reference/js/remote-config.remoteconfig.md - title: RemoteConfigSettings diff --git a/docs-devsite/remote-config.customsignals.md b/docs-devsite/remote-config.customsignals.md new file mode 100644 index 00000000000..98bd371ad7b --- /dev/null +++ b/docs-devsite/remote-config.customsignals.md @@ -0,0 +1,23 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# CustomSignals interface +Defines the type for representing custom signals and their values. + +

The values in CustomSignals must be one of the following types: + +

  • string
  • number
  • null
+ +Signature: + +```typescript +export interface CustomSignals +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index 371ab7ff157..40319453a3f 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -28,6 +28,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | [getNumber(remoteConfig, key)](./remote-config.md#getnumber_476c09f) | Gets the value for the given key as a number.Convenience method for calling remoteConfig.getValue(key).asNumber(). | | [getString(remoteConfig, key)](./remote-config.md#getstring_476c09f) | Gets the value for the given key as a string. Convenience method for calling remoteConfig.getValue(key).asString(). | | [getValue(remoteConfig, key)](./remote-config.md#getvalue_476c09f) | Gets the [Value](./remote-config.value.md#value_interface) for the given key. | +| [setCustomSignals(remoteConfig, customSignals)](./remote-config.md#setcustomsignals_aeeb95e) | Sets the custom signals for the app instance. | | [setLogLevel(remoteConfig, logLevel)](./remote-config.md#setloglevel_039a45b) | Defines the log level to use. | | function() | | [isSupported()](./remote-config.md#issupported) | This method provides two different checks:1. Check if IndexedDB exists in the browser environment. 2. Check if the current browser context allows IndexedDB open() calls. | @@ -36,6 +37,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Interface | Description | | --- | --- | +| [CustomSignals](./remote-config.customsignals.md#customsignals_interface) | Defines the type for representing custom signals and their values.

The values in CustomSignals must be one of the following types:

  • string
  • number
  • null
| | [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The Firebase Remote Config service interface. | | [RemoteConfigSettings](./remote-config.remoteconfigsettings.md#remoteconfigsettings_interface) | Defines configuration options for the Remote Config SDK. | | [Value](./remote-config.value.md#value_interface) | Wraps a value with metadata and type-safe getters. | @@ -276,6 +278,27 @@ export declare function getValue(remoteConfig: RemoteConfig, key: string): Value The value for the given key. +### setCustomSignals(remoteConfig, customSignals) {:#setcustomsignals_aeeb95e} + +Sets the custom signals for the app instance. + +Signature: + +```typescript +export declare function setCustomSignals(remoteConfig: RemoteConfig, customSignals: CustomSignals): Promise; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| remoteConfig | [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) instance. | +| customSignals | [CustomSignals](./remote-config.customsignals.md#customsignals_interface) | Map (key, value) of the custom signals to be set for the app instance. If a key already exists, the value is overwritten. Setting the value of a custom signal to null unsets the signal. The signals will be persisted locally on the client. | + +Returns: + +Promise<void> + ### setLogLevel(remoteConfig, logLevel) {:#setloglevel_039a45b} Defines the log level to use. diff --git a/packages/firebase/compat/index.d.ts b/packages/firebase/compat/index.d.ts index efda7c954a5..92c7bd2c278 100644 --- a/packages/firebase/compat/index.d.ts +++ b/packages/firebase/compat/index.d.ts @@ -2046,6 +2046,7 @@ declare namespace firebase.remoteConfig { * Defines levels of Remote Config logging. */ export type LogLevel = 'debug' | 'error' | 'silent'; + /** * This method provides two different checks: * diff --git a/packages/remote-config-types/index.d.ts b/packages/remote-config-types/index.d.ts index a088f665310..7fbaf7c3e5c 100644 --- a/packages/remote-config-types/index.d.ts +++ b/packages/remote-config-types/index.d.ts @@ -173,6 +173,19 @@ export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; */ export type LogLevel = 'debug' | 'error' | 'silent'; +/** + * Defines the type for representing custom signals and their values. + * + *

The values in CustomSignals must be one of the following types: + * + *

    + *
  • string + *
  • number + *
  • null + *
+ */ +export type CustomSignals = { [key: string]: string | number | null }; + declare module '@firebase/component' { interface NameServiceMapping { 'remoteConfig-compat': RemoteConfig; diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index aeae67d450e..607d4944d26 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -17,12 +17,17 @@ import { _getProvider, FirebaseApp, getApp } from '@firebase/app'; import { + CustomSignals, LogLevel as RemoteConfigLogLevel, RemoteConfig, Value } from './public_types'; import { RemoteConfigAbortSignal } from './client/remote_config_fetch_client'; -import { RC_COMPONENT_NAME } from './constants'; +import { + RC_COMPONENT_NAME, + RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH, + RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH +} from './constants'; import { ErrorCode, hasErrorCode } from './errors'; import { RemoteConfig as RemoteConfigImpl } from './remote_config'; import { Value as ValueImpl } from './value'; @@ -114,11 +119,18 @@ export async function fetchConfig(remoteConfig: RemoteConfig): Promise { abortSignal.abort(); }, rc.settings.fetchTimeoutMillis); + const customSignals = rc._storageCache.getCustomSignals(); + if (customSignals) { + rc._logger.debug( + `Fetching config with custom signals: ${JSON.stringify(customSignals)}` + ); + } // Catches *all* errors thrown by client so status can be set consistently. try { await rc._client.fetch({ cacheMaxAgeMillis: rc.settings.minimumFetchIntervalMillis, - signal: abortSignal + signal: abortSignal, + customSignals }); await rc._storageCache.setLastFetchStatus('success'); @@ -258,3 +270,51 @@ export function setLogLevel( function getAllKeys(obj1: {} = {}, obj2: {} = {}): string[] { return Object.keys({ ...obj1, ...obj2 }); } + +/** + * Sets the custom signals for the app instance. + * + * @param remoteConfig - The {@link RemoteConfig} instance. + * @param customSignals - Map (key, value) of the custom signals to be set for the app instance. If + * a key already exists, the value is overwritten. Setting the value of a custom signal to null + * unsets the signal. The signals will be persisted locally on the client. + * + * @public + */ +export async function setCustomSignals( + remoteConfig: RemoteConfig, + customSignals: CustomSignals +): Promise { + const rc = getModularInstance(remoteConfig) as RemoteConfigImpl; + if (Object.keys(customSignals).length === 0) { + return; + } + + // eslint-disable-next-line guard-for-in + for (const key in customSignals) { + if (key.length > RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH) { + rc._logger.error( + `Custom signal key ${key} is too long, max allowed length is ${RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH}.` + ); + return; + } + const value = customSignals[key]; + if ( + typeof value === 'string' && + value.length > RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH + ) { + rc._logger.error( + `Value supplied for custom signal ${key} is too long, max allowed length is ${RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH}.` + ); + return; + } + } + + try { + await rc._storageCache.setCustomSignals(customSignals); + } catch (error) { + rc._logger.error( + `Error encountered while setting custom signals: ${error}` + ); + } +} 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 25e00299855..71ea66d5e50 100644 --- a/packages/remote-config/src/client/remote_config_fetch_client.ts +++ b/packages/remote-config/src/client/remote_config_fetch_client.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import { CustomSignals } from '../public_types'; + /** * Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the * Remote Config server (https://firebase.google.com/docs/reference/remote-config/rest). @@ -99,6 +101,12 @@ export interface FetchRequest { *

Comparable to passing `headers = { 'If-None-Match': }` to the native Fetch API. */ eTag?: string; + + /** The custom signals stored for the app instance. + * + *

Optional in case no custom signals are set for the instance. + */ + customSignals?: CustomSignals; } /** diff --git a/packages/remote-config/src/client/rest_client.ts b/packages/remote-config/src/client/rest_client.ts index 87fdae3c3d6..9d87ffbb1ac 100644 --- a/packages/remote-config/src/client/rest_client.ts +++ b/packages/remote-config/src/client/rest_client.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { CustomSignals } from '../public_types'; import { FetchResponse, RemoteConfigFetchClient, @@ -41,6 +42,7 @@ interface FetchRequestBody { app_instance_id_token: string; app_id: string; language_code: string; + custom_signals?: CustomSignals; /* eslint-enable camelcase */ } @@ -92,7 +94,8 @@ export class RestClient implements RemoteConfigFetchClient { app_instance_id: installationId, app_instance_id_token: installationToken, app_id: this.appId, - language_code: getUserLanguage() + language_code: getUserLanguage(), + custom_signals: request.customSignals /* eslint-enable camelcase */ }; diff --git a/packages/remote-config/src/constants.ts b/packages/remote-config/src/constants.ts index 365d9037f86..d7d286909a5 100644 --- a/packages/remote-config/src/constants.ts +++ b/packages/remote-config/src/constants.ts @@ -16,3 +16,6 @@ */ export const RC_COMPONENT_NAME = 'remote-config'; +export const RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 100; +export const RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH = 250; +export const RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH = 500; diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index eac9a25657b..762eeb899ee 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -31,7 +31,8 @@ export const enum ErrorCode { FETCH_THROTTLE = 'fetch-throttle', FETCH_PARSE = 'fetch-client-parse', FETCH_STATUS = 'fetch-status', - INDEXED_DB_UNAVAILABLE = 'indexed-db-unavailable' + INDEXED_DB_UNAVAILABLE = 'indexed-db-unavailable', + CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals' } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { @@ -67,7 +68,9 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { [ErrorCode.FETCH_STATUS]: 'Fetch server returned an HTTP error status. HTTP status: {$httpStatus}.', [ErrorCode.INDEXED_DB_UNAVAILABLE]: - 'Indexed DB is not supported by current browser' + 'Indexed DB is not supported by current browser', + [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: + 'Setting more than {$maxSignals} custom signals is not supported.' }; // Note this is effectively a type system binding a code to params. This approach overlaps with the @@ -86,6 +89,7 @@ interface ErrorParams { [ErrorCode.FETCH_THROTTLE]: { throttleEndTimeMillis: number }; [ErrorCode.FETCH_PARSE]: { originalErrorMessage: string }; [ErrorCode.FETCH_STATUS]: { httpStatus: number }; + [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: { maxSignals: number }; } 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 d489809e451..365d5e5905f 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -134,6 +134,23 @@ export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; */ export type LogLevel = 'debug' | 'error' | 'silent'; +/** + * Defines the type for representing custom signals and their values. + * + *

The values in CustomSignals must be one of the following types: + * + *

    + *
  • string + *
  • number + *
  • null + *
+ * + * @public + */ +export interface CustomSignals { + [key: string]: string | number | null; +} + declare module '@firebase/component' { interface NameServiceMapping { 'remote-config': RemoteConfig; diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index baa7ab46b52..52e660f1fdb 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -15,12 +15,13 @@ * limitations under the License. */ -import { FetchStatus } from '@firebase/remote-config-types'; +import { FetchStatus, CustomSignals } from '@firebase/remote-config-types'; import { FetchResponse, FirebaseRemoteConfigObject } from '../client/remote_config_fetch_client'; import { ERROR_FACTORY, ErrorCode } from '../errors'; +import { RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS } from '../constants'; import { FirebaseError } from '@firebase/util'; /** @@ -70,7 +71,8 @@ type ProjectNamespaceKeyFieldValue = | 'last_successful_fetch_timestamp_millis' | 'last_successful_fetch_response' | 'settings' - | 'throttle_metadata'; + | 'throttle_metadata' + | 'custom_signals'; // Visible for testing. export function openDatabase(): Promise { @@ -181,10 +183,64 @@ export class Storage { return this.delete('throttle_metadata'); } - async get(key: ProjectNamespaceKeyFieldValue): Promise { + getCustomSignals(): Promise { + return this.get('custom_signals'); + } + + async setCustomSignals(customSignals: CustomSignals): Promise { const db = await this.openDbPromise; + const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); + const storedSignals = await this.getWithTransaction( + 'custom_signals', + transaction + ); + const combinedSignals = { + ...storedSignals, + ...customSignals + }; + // Filter out key-value assignments with null values since they are signals being unset + const updatedSignals = Object.fromEntries( + Object.entries(combinedSignals) + .filter(([_, v]) => v !== null) + .map(([k, v]) => { + // Stringify numbers to store a map of string keys and values which can be sent + // as-is in a fetch call. + if (typeof v === 'number') { + return [k, v.toString()]; + } + return [k, v]; + }) + ); + + // Throw an error if the number of custom signals to be stored exceeds the limit + if ( + Object.keys(updatedSignals).length > RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS + ) { + throw ERROR_FACTORY.create(ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS, { + maxSignals: RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS + }); + } + + await this.setWithTransaction( + 'custom_signals', + updatedSignals, + transaction + ); + return updatedSignals; + } + + /** + * Gets a value from the database using the provided transaction. + * + * @param key The key of the value to get. + * @param transaction The transaction to use for the operation. + * @returns The value associated with the key, or undefined if no such value exists. + */ + async getWithTransaction( + key: ProjectNamespaceKeyFieldValue, + transaction: IDBTransaction + ): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction([APP_NAMESPACE_STORE], 'readonly'); const objectStore = transaction.objectStore(APP_NAMESPACE_STORE); const compositeKey = this.createCompositeKey(key); try { @@ -210,10 +266,20 @@ export class Storage { }); } - async set(key: ProjectNamespaceKeyFieldValue, value: T): Promise { - const db = await this.openDbPromise; + /** + * Sets a value in the database using the provided transaction. + * + * @param key The key of the value to set. + * @param value The value to set. + * @param transaction The transaction to use for the operation. + * @returns A promise that resolves when the operation is complete. + */ + async setWithTransaction( + key: ProjectNamespaceKeyFieldValue, + value: T, + transaction: IDBTransaction + ): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); const objectStore = transaction.objectStore(APP_NAMESPACE_STORE); const compositeKey = this.createCompositeKey(key); try { @@ -237,6 +303,18 @@ export class Storage { }); } + async get(key: ProjectNamespaceKeyFieldValue): Promise { + const db = await this.openDbPromise; + const transaction = db.transaction([APP_NAMESPACE_STORE], 'readonly'); + return this.getWithTransaction(key, transaction); + } + + async set(key: ProjectNamespaceKeyFieldValue, value: T): Promise { + const db = await this.openDbPromise; + const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); + return this.setWithTransaction(key, value, transaction); + } + async delete(key: ProjectNamespaceKeyFieldValue): Promise { const db = await this.openDbPromise; return new Promise((resolve, reject) => { diff --git a/packages/remote-config/src/storage/storage_cache.ts b/packages/remote-config/src/storage/storage_cache.ts index 302ba9a2487..fc419b0068e 100644 --- a/packages/remote-config/src/storage/storage_cache.ts +++ b/packages/remote-config/src/storage/storage_cache.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FetchStatus } from '@firebase/remote-config-types'; +import { FetchStatus, CustomSignals } from '@firebase/remote-config-types'; import { FirebaseRemoteConfigObject } from '../client/remote_config_fetch_client'; import { Storage } from './storage'; @@ -31,6 +31,7 @@ export class StorageCache { private lastFetchStatus?: FetchStatus; private lastSuccessfulFetchTimestampMillis?: number; private activeConfig?: FirebaseRemoteConfigObject; + private customSignals?: CustomSignals; /** * Memory-only getters @@ -47,6 +48,10 @@ export class StorageCache { return this.activeConfig; } + getCustomSignals(): CustomSignals | undefined { + return this.customSignals; + } + /** * Read-ahead getter */ @@ -55,6 +60,7 @@ export class StorageCache { const lastSuccessfulFetchTimestampMillisPromise = this.storage.getLastSuccessfulFetchTimestampMillis(); const activeConfigPromise = this.storage.getActiveConfig(); + const customSignalsPromise = this.storage.getCustomSignals(); // Note: // 1. we consistently check for undefined to avoid clobbering defined values @@ -78,6 +84,11 @@ export class StorageCache { if (activeConfig) { this.activeConfig = activeConfig; } + + const customSignals = await customSignalsPromise; + if (customSignals) { + this.customSignals = customSignals; + } } /** @@ -99,4 +110,8 @@ export class StorageCache { this.activeConfig = activeConfig; return this.storage.setActiveConfig(activeConfig); } + + async setCustomSignals(customSignals: CustomSignals): Promise { + this.customSignals = await this.storage.setCustomSignals(customSignals); + } } diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index f53f4a72c86..51304bc3b2f 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -42,7 +42,8 @@ import { getString, getValue, setLogLevel, - fetchConfig + fetchConfig, + setCustomSignals } from '../src/api'; import * as api from '../src/api'; import { fetchAndActivate } from '../src'; @@ -93,6 +94,48 @@ describe('RemoteConfig', () => { loggerLogLevelSpy.restore(); }); + describe('setCustomSignals', () => { + beforeEach(() => { + storageCache.setCustomSignals = sinon.stub(); + storage.setCustomSignals = sinon.stub(); + logger.error = sinon.stub(); + }); + + it('call storage API to store signals', async () => { + await setCustomSignals(rc, { key: 'value' }); + + expect(storageCache.setCustomSignals).to.have.been.calledWith({ + key: 'value' + }); + }); + + it('logs an error when supplied with a custom signal key greater than 250 characters', async () => { + const longKey = 'a'.repeat(251); + const customSignals = { [longKey]: 'value' }; + + await setCustomSignals(rc, customSignals); + + expect(storageCache.setCustomSignals).to.not.have.been.called; + expect(logger.error).to.have.been.called; + }); + + it('logs an error when supplied with a custom signal value greater than 500 characters', async () => { + const longValue = 'a'.repeat(501); + const customSignals = { 'key': longValue }; + + await setCustomSignals(rc, customSignals); + + expect(storageCache.setCustomSignals).to.not.have.been.called; + expect(logger.error).to.have.been.called; + }); + + it('empty custom signals map does nothing', async () => { + await setCustomSignals(rc, {}); + + expect(storageCache.setCustomSignals).to.not.have.been.called; + }); + }); + // Adapts getUserLanguage tests from packages/auth/test/utils_test.js for TypeScript. describe('setLogLevel', () => { it('proxies to the FirebaseLogger instance', () => { @@ -449,6 +492,7 @@ describe('RemoteConfig', () => { .stub() .returns(Promise.resolve({ status: 200 } as FetchResponse)); storageCache.setLastFetchStatus = sinon.stub(); + storageCache.getCustomSignals = sinon.stub(); timeoutStub = sinon.stub(window, 'setTimeout'); }); @@ -517,5 +561,11 @@ describe('RemoteConfig', () => { 'failure' ); }); + + it('sends custom signals', async () => { + await fetchConfig(rc); + + expect(storageCache.getCustomSignals).to.have.been.called; + }); }); }); diff --git a/packages/remote-config/test/storage/storage.test.ts b/packages/remote-config/test/storage/storage.test.ts index 92cc12225e8..7a865107791 100644 --- a/packages/remote-config/test/storage/storage.test.ts +++ b/packages/remote-config/test/storage/storage.test.ts @@ -117,4 +117,58 @@ describe('Storage', () => { expect(actualMetadata).to.be.undefined; }); + + it('sets and gets custom signals', async () => { + const customSignals = { key: 'value', key1: 'value1', key2: 1 }; + const customSignalsInStorage = { + key: 'value', + key1: 'value1', + key2: '1' + }; + + await storage.setCustomSignals(customSignals); + + const storedCustomSignals = await storage.getCustomSignals(); + + expect(storedCustomSignals).to.deep.eq(customSignalsInStorage); + }); + + it('upserts custom signals when key is present in storage', async () => { + const customSignals = { key: 'value', key1: 'value1' }; + const updatedSignals = { key: 'value', key1: 'value2' }; + + await storage.setCustomSignals(customSignals); + + await storage.setCustomSignals({ key1: 'value2' }); + + const storedCustomSignals = await storage.getCustomSignals(); + + expect(storedCustomSignals).to.deep.eq(updatedSignals); + }); + + it('deletes custom signal when value supplied is null', async () => { + const customSignals = { key: 'value', key1: 'value1' }; + const updatedSignals = { key: 'value' }; + + await storage.setCustomSignals(customSignals); + + await storage.setCustomSignals({ key1: null }); + + const storedCustomSignals = await storage.getCustomSignals(); + + expect(storedCustomSignals).to.deep.eq(updatedSignals); + }); + + it('throws an error when supplied with excess custom signals', async () => { + const customSignals: { [key: string]: string } = {}; + for (let i = 0; i < 101; i++) { + customSignals[`key${i}`] = `value${i}`; + } + + await expect( + storage.setCustomSignals(customSignals) + ).to.eventually.be.rejectedWith( + 'Remote Config: Setting more than 100 custom signals is not supported.' + ); + }); }); diff --git a/packages/remote-config/test/storage/storage_cache.test.ts b/packages/remote-config/test/storage/storage_cache.test.ts index e7cfb0ef0da..8d11cfac46a 100644 --- a/packages/remote-config/test/storage/storage_cache.test.ts +++ b/packages/remote-config/test/storage/storage_cache.test.ts @@ -37,6 +37,7 @@ describe('StorageCache', () => { const status = 'success'; const lastSuccessfulFetchTimestampMillis = 123; const activeConfig = { key: 'value' }; + const customSignals = { 'key': 'value' }; storage.getLastFetchStatus = sinon .stub() @@ -47,12 +48,16 @@ describe('StorageCache', () => { storage.getActiveConfig = sinon .stub() .returns(Promise.resolve(activeConfig)); + storage.getCustomSignals = sinon + .stub() + .returns(Promise.resolve(customSignals)); await storageCache.loadFromStorage(); expect(storage.getLastFetchStatus).to.have.been.called; expect(storage.getLastSuccessfulFetchTimestampMillis).to.have.been.called; expect(storage.getActiveConfig).to.have.been.called; + expect(storage.getCustomSignals).to.have.been.called; expect(storageCache.getLastFetchStatus()).to.eq(status); expect(storageCache.getLastSuccessfulFetchTimestampMillis()).to.deep.eq( @@ -81,4 +86,26 @@ describe('StorageCache', () => { expect(storage.setActiveConfig).to.have.been.calledWith(activeConfig); }); }); + + describe('setCustomSignals', () => { + const customSignals = { key: 'value' }; + + beforeEach(() => { + storage.setCustomSignals = sinon + .stub() + .returns(Promise.resolve(customSignals)); + }); + + it('writes to memory cache', async () => { + await storageCache.setCustomSignals(customSignals); + + expect(storageCache.getCustomSignals()).to.deep.eq(customSignals); + }); + + it('writes to persistent storage', async () => { + await storageCache.setCustomSignals(customSignals); + + expect(storage.setCustomSignals).to.have.been.calledWith(customSignals); + }); + }); });