diff --git a/etc/firebase-admin.remote-config.api.md b/etc/firebase-admin.remote-config.api.md index fb07bfad76..614fc5dfeb 100644 --- a/etc/firebase-admin.remote-config.api.md +++ b/etc/firebase-admin.remote-config.api.md @@ -46,8 +46,10 @@ export class RemoteConfig { // (undocumented) readonly app: App; createTemplateFromJSON(json: string): RemoteConfigTemplate; + getServerTemplate(options?: RemoteConfigServerTemplateOptions): Promise; getTemplate(): Promise; getTemplateAtVersion(versionNumber: number | string): Promise; + initServerTemplate(options?: RemoteConfigServerTemplateOptions): RemoteConfigServerTemplate; listVersions(options?: ListVersionsOptions): Promise; publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean; @@ -84,6 +86,41 @@ export interface RemoteConfigParameterGroup { // @public export type RemoteConfigParameterValue = ExplicitParameterValue | InAppDefaultValue; +// @public +export interface RemoteConfigServerCondition { + expression: string; + name: string; +} + +// @public +export type RemoteConfigServerConfig = { + [key: string]: string | boolean | number; +}; + +// @public +export interface RemoteConfigServerTemplate { + cache: RemoteConfigServerTemplateData; + defaultConfig: RemoteConfigServerConfig; + evaluate(): RemoteConfigServerConfig; + load(): Promise; +} + +// @public +export interface RemoteConfigServerTemplateData { + conditions: RemoteConfigServerCondition[]; + readonly etag: string; + parameters: { + [key: string]: RemoteConfigParameter; + }; + version?: Version; +} + +// @public +export interface RemoteConfigServerTemplateOptions { + defaultConfig?: RemoteConfigServerConfig; + template?: RemoteConfigServerTemplateData; +} + // @public export interface RemoteConfigTemplate { conditions: RemoteConfigCondition[]; diff --git a/src/remote-config/index.ts b/src/remote-config/index.ts index e4719b2e43..194929641c 100644 --- a/src/remote-config/index.ts +++ b/src/remote-config/index.ts @@ -35,6 +35,11 @@ export { RemoteConfigParameterGroup, RemoteConfigParameterValue, RemoteConfigTemplate, + RemoteConfigServerCondition, + RemoteConfigServerConfig, + RemoteConfigServerTemplate, + RemoteConfigServerTemplateData, + RemoteConfigServerTemplateOptions, RemoteConfigUser, TagColor, Version, diff --git a/src/remote-config/remote-config-api.ts b/src/remote-config/remote-config-api.ts index a27e29c81a..a5d0287d80 100644 --- a/src/remote-config/remote-config-api.ts +++ b/src/remote-config/remote-config-api.ts @@ -213,6 +213,27 @@ export interface RemoteConfigServerTemplateData { version?: Version; } +/** + * Represents optional arguments that can be used when instantiating {@link RemoteConfigServerTemplate}. + */ +export interface RemoteConfigServerTemplateOptions { + + /** + * Defines in-app default parameter values, so that your app behaves as + * intended before it connects to the Remote Config backend, and so that + * default values are available if none are set on the backend. + */ + defaultConfig?: RemoteConfigServerConfig, + + /** + * Enables integrations to use template data loaded independently. For + * example, customers can reduce initialization latency by pre-fetching and + * caching template data and then using this option to initialize the SDK with + * that data. + */ + template?: RemoteConfigServerTemplateData, +} + /** * Represents a stateful abstraction for a Remote Config server template. */ diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index 27cbd05793..cfd965d27b 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -23,9 +23,16 @@ import { RemoteConfigCondition, RemoteConfigParameter, RemoteConfigParameterGroup, + RemoteConfigServerTemplate, RemoteConfigTemplate, RemoteConfigUser, Version, + ExplicitParameterValue, + InAppDefaultValue, + ParameterValueType, + RemoteConfigServerConfig, + RemoteConfigServerTemplateData, + RemoteConfigServerTemplateOptions, } from './remote-config-api'; /** @@ -168,6 +175,27 @@ export class RemoteConfig { return new RemoteConfigTemplateImpl(template); } + + /** + * Instantiates {@link RemoteConfigServerTemplate} and then fetches and caches the latest + * template version of the project. + */ + public async getServerTemplate(options?: RemoteConfigServerTemplateOptions): Promise { + const template = this.initServerTemplate(options); + await template.load(); + return template; + } + + /** + * Synchronously instantiates {@link RemoteConfigServerTemplate}. + */ + public initServerTemplate(options?: RemoteConfigServerTemplateOptions): RemoteConfigServerTemplate { + const template = new RemoteConfigServerTemplateImpl(this.client, options?.defaultConfig); + if (options?.template) { + template.cache = options?.template; + } + return template; + } } /** @@ -254,6 +282,143 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate { } } +/** + * Remote Config dataplane template data implementation. + */ +class RemoteConfigServerTemplateImpl implements RemoteConfigServerTemplate { + public cache: RemoteConfigServerTemplateData; + + constructor( + private readonly apiClient: RemoteConfigApiClient, + public readonly defaultConfig: RemoteConfigServerConfig = {} + ) { } + + /** + * Fetches and caches the current active version of the project's {@link RemoteConfigServerTemplate}. + */ + public load(): Promise { + return this.apiClient.getServerTemplate() + .then((template) => { + this.cache = new RemoteConfigServerTemplateDataImpl(template); + }); + } + + /** + * Evaluates the current template in cache to produce a {@link RemoteConfigServerConfig}. + */ + public evaluate(): RemoteConfigServerConfig { + if (!this.cache) { + throw new FirebaseRemoteConfigError( + 'failed-precondition', + 'No Remote Config Server template in cache. Call load() before calling evaluate().'); + } + + const evaluatedConfig: RemoteConfigServerConfig = {}; + + for (const [key, parameter] of Object.entries(this.cache.parameters)) { + const { defaultValue, valueType } = parameter; + + if (!defaultValue) { + // TODO: add logging once we have a wrapped logger. + continue; + } + + if ((defaultValue as InAppDefaultValue).useInAppDefault) { + // TODO: add logging once we have a wrapped logger. + continue; + } + + const parameterDefaultValue = (defaultValue as ExplicitParameterValue).value; + + evaluatedConfig[key] = this.parseRemoteConfigParameterValue(valueType, parameterDefaultValue); + } + + // Merges rendered config over default config. + const mergedConfig = Object.assign(this.defaultConfig, evaluatedConfig); + + // Enables config to be a convenient object, but with the ability to perform additional + // functionality when a value is retrieved. + const proxyHandler = { + get(target: RemoteConfigServerConfig, prop: string) { + return target[prop]; + } + }; + + return new Proxy(mergedConfig, proxyHandler); + } + + /** + * Private helper method that processes and parses a parameter value based on {@link ParameterValueType}. + */ + private parseRemoteConfigParameterValue(parameterType: ParameterValueType | undefined, + parameterDefaultValue: string): string | number | boolean { + const BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on']; + const DEFAULT_VALUE_FOR_NUMBER = 0; + const DEFAULT_VALUE_FOR_STRING = ''; + + if (parameterType === 'BOOLEAN') { + return BOOLEAN_TRUTHY_VALUES.indexOf(parameterDefaultValue) >= 0; + } else if (parameterType === 'NUMBER') { + const num = Number(parameterDefaultValue); + if (isNaN(num)) { + return DEFAULT_VALUE_FOR_NUMBER; + } + return num; + } else { + // Treat everything else as string + return parameterDefaultValue || DEFAULT_VALUE_FOR_STRING; + } + } +} + +/** + * Remote Config dataplane template data implementation. + */ +class RemoteConfigServerTemplateDataImpl implements RemoteConfigServerTemplateData { + public parameters: { [key: string]: RemoteConfigParameter }; + public parameterGroups: { [key: string]: RemoteConfigParameterGroup }; + public conditions: RemoteConfigCondition[]; + public readonly etag: string; + public version?: Version; + + constructor(template: RemoteConfigServerTemplateData) { + if (!validator.isNonNullObject(template) || + !validator.isNonEmptyString(template.etag)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `Invalid Remote Config template: ${JSON.stringify(template)}`); + } + + this.etag = template.etag; + + if (typeof template.parameters !== 'undefined') { + if (!validator.isNonNullObject(template.parameters)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'Remote Config parameters must be a non-null object'); + } + this.parameters = template.parameters; + } else { + this.parameters = {}; + } + + if (typeof template.conditions !== 'undefined') { + if (!validator.isArray(template.conditions)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'Remote Config conditions must be an array'); + } + this.conditions = template.conditions; + } else { + this.conditions = []; + } + + if (typeof template.version !== 'undefined') { + this.version = new VersionImpl(template.version); + } + } +} + /** * Remote Config Version internal implementation. */ diff --git a/test/unit/remote-config/remote-config.spec.ts b/test/unit/remote-config/remote-config.spec.ts index 5459ecd90c..71fcf87f84 100644 --- a/test/unit/remote-config/remote-config.spec.ts +++ b/test/unit/remote-config/remote-config.spec.ts @@ -34,6 +34,9 @@ import { RemoteConfigApiClient } from '../../../src/remote-config/remote-config-api-client-internal'; import { deepCopy } from '../../../src/utils/deep-copy'; +import { + RemoteConfigServerCondition, RemoteConfigServerTemplate, RemoteConfigServerTemplateData +} from '../../../src/remote-config/remote-config-api'; const expect = chai.expect; @@ -98,6 +101,34 @@ describe('RemoteConfig', () => { version: VERSION_INFO, }; + const SERVER_REMOTE_CONFIG_RESPONSE: { + // This type is effectively a RemoteConfigServerTemplate, but with mutable fields + // to allow easier use from within the tests. An improvement would be to + // alter this into a helper that creates customized RemoteConfigTemplateContent based + // on the needs of the test, as that would ensure type-safety. + conditions?: Array<{ name: string; expression: string; }>; + parameters?: object | null; + etag: string; + version?: object; + } = { + conditions: [ + { + name: 'ios', + expression: 'device.os == \'ios\'' + }, + ], + parameters: { + holiday_promo_enabled: { + defaultValue: { value: 'true' }, + conditionalValues: { ios: { useInAppDefault: true } }, + description: 'this is a promo', + valueType: 'BOOLEAN', + }, + }, + etag: 'etag-123456789012-5', + version: VERSION_INFO, + }; + const REMOTE_CONFIG_TEMPLATE: RemoteConfigTemplate = { conditions: [{ name: 'ios', @@ -511,6 +542,435 @@ describe('RemoteConfig', () => { }); }); + describe('getServerTemplate', () => { + const operationName = 'getServerTemplate'; + + it('should propagate API errors', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .rejects(INTERNAL_ERROR); + stubs.push(stub); + + return remoteConfig.getServerTemplate().should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should resolve a server template on success', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(SERVER_REMOTE_CONFIG_RESPONSE as RemoteConfigServerTemplateData); + stubs.push(stub); + + return remoteConfig.getServerTemplate() + .then((template) => { + expect(template.cache.conditions.length).to.equal(1); + expect(template.cache.conditions[0].name).to.equal('ios'); + expect(template.cache.conditions[0].expression).to.equal('device.os == \'ios\''); + expect(template.cache.etag).to.equal('etag-123456789012-5'); + + const version = template.cache.version!; + expect(version.versionNumber).to.equal('86'); + expect(version.updateOrigin).to.equal('ADMIN_SDK_NODE'); + expect(version.updateType).to.equal('INCREMENTAL_UPDATE'); + expect(version.updateUser).to.deep.equal({ + email: 'firebase-adminsdk@gserviceaccount.com' + }); + expect(version.description).to.equal('production version'); + expect(version.updateTime).to.equal('Mon, 15 Jun 2020 16:45:03 GMT'); + + const key = 'holiday_promo_enabled'; + const p1 = template.cache.parameters[key]; + expect(p1.defaultValue).deep.equals({ value: 'true' }); + expect(p1.conditionalValues).deep.equals({ ios: { useInAppDefault: true } }); + expect(p1.description).equals('this is a promo'); + expect(p1.valueType).equals('BOOLEAN'); + + const c = template.cache.conditions.find((c) => c.name === 'ios'); + expect(c).to.be.not.undefined; + const cond = c as RemoteConfigServerCondition; + expect(cond.name).to.equal('ios'); + expect(cond.expression).to.equal('device.os == \'ios\''); + + const parsed = JSON.parse(JSON.stringify(template.cache)); + const expectedTemplate = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + const expectedVersion = deepCopy(VERSION_INFO); + expectedVersion.updateTime = new Date(expectedVersion.updateTime).toUTCString(); + expectedTemplate.version = expectedVersion; + expect(parsed).deep.equals(expectedTemplate); + }); + }); + + it('should set defaultConfig when passed', () => { + const defaultConfig = { + holiday_promo_enabled: false, + holiday_promo_discount: 20, + }; + + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(SERVER_REMOTE_CONFIG_RESPONSE as RemoteConfigServerTemplateData); + stubs.push(stub); + + return remoteConfig.getServerTemplate({ defaultConfig }) + .then((template) => { + expect(template.defaultConfig.holiday_promo_enabled).to.equal(false); + expect(template.defaultConfig.holiday_promo_discount).to.equal(20); + }); + }); + }); + + describe('initServerTemplate', () => { + it('should set and instantiates template when passed', () => { + const template = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as RemoteConfigServerTemplateData; + template.parameters = { + dog_type: { + defaultValue: { + value: 'shiba' + }, + description: 'Type of dog breed', + valueType: 'STRING' + } + }; + const initializedTemplate = remoteConfig.initServerTemplate({ template }).cache; + const parsed = JSON.parse(JSON.stringify(initializedTemplate)); + expect(parsed).deep.equals(deepCopy(template)); + }); + }); + + describe('RemoteConfigServerTemplate', () => { + const SERVER_REMOTE_CONFIG_RESPONSE_2 = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + SERVER_REMOTE_CONFIG_RESPONSE_2.parameters = { + dog_type: { + defaultValue: { + value: 'corgi' + }, + description: 'Type of dog breed', + valueType: 'STRING' + }, + dog_type_enabled: { + defaultValue: { + value: 'true' + }, + description: 'It\'s true or false', + valueType: 'BOOLEAN' + }, + dog_age: { + defaultValue: { + value: '22' + }, + description: 'Age', + valueType: 'NUMBER' + }, + dog_jsonified: { + defaultValue: { + value: '{"name":"Taro","breed":"Corgi","age":1,"fluffiness":100}' + }, + description: 'Dog Json Response', + valueType: 'JSON' + }, + dog_use_inapp_default: { + defaultValue: { + useInAppDefault: true + }, + description: 'Use in-app default dog', + valueType: 'STRING' + }, + dog_no_remote_default_value: { + description: 'TIL: default values are optional!', + valueType: 'STRING' + } + }; + + describe('load', () => { + const operationName = 'getServerTemplate'; + + it('should propagate API errors', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .rejects(INTERNAL_ERROR); + stubs.push(stub); + + return remoteConfig.getServerTemplate().should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(undefined); + stubs.push(stub); + return remoteConfig.getServerTemplate().should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Remote Config template: undefined'); + }); + + it('should reject when API response does not contain an ETag', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + response.etag = ''; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Remote Config template: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain valid parameters', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + response.parameters = null; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', 'Remote Config parameters must be a non-null object'); + }); + + it('should reject when API response does not contain valid conditions', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + response.conditions = Object(); + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', 'Remote Config conditions must be an array'); + }); + + it('should resolve with parameters:{} when no parameters present in the response', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + response.parameters = undefined; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate() + .then((template) => { + // If parameters are not present in the response, we set it to an empty object. + expect(template.cache.parameters).deep.equals({}); + }); + }); + + it('should resolve with conditions:[] when no conditions present in the response', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + response.conditions = undefined; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate() + .then((template) => { + // If conditions are not present in the response, we set it to an empty array. + expect(template.cache.conditions).deep.equals([]); + }); + }); + + it('should resolve a server template on success', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(SERVER_REMOTE_CONFIG_RESPONSE as RemoteConfigServerTemplateData); + stubs.push(stub); + + return remoteConfig.getServerTemplate() + .then((template) => { + expect(template.cache.conditions.length).to.equal(1); + expect(template.cache.conditions[0].name).to.equal('ios'); + expect(template.cache.conditions[0].expression).to.equal('device.os == \'ios\''); + expect(template.cache.etag).to.equal('etag-123456789012-5'); + + const version = template.cache.version!; + expect(version.versionNumber).to.equal('86'); + expect(version.updateOrigin).to.equal('ADMIN_SDK_NODE'); + expect(version.updateType).to.equal('INCREMENTAL_UPDATE'); + expect(version.updateUser).to.deep.equal({ + email: 'firebase-adminsdk@gserviceaccount.com' + }); + expect(version.description).to.equal('production version'); + expect(version.updateTime).to.equal('Mon, 15 Jun 2020 16:45:03 GMT'); + + const key = 'holiday_promo_enabled'; + const p1 = template.cache.parameters[key]; + expect(p1.defaultValue).deep.equals({ value: 'true' }); + expect(p1.conditionalValues).deep.equals({ ios: { useInAppDefault: true } }); + expect(p1.description).equals('this is a promo'); + expect(p1.valueType).equals('BOOLEAN'); + + const c = template.cache.conditions.find((c) => c.name === 'ios'); + expect(c).to.be.not.undefined; + const cond = c as RemoteConfigServerCondition; + expect(cond.name).to.equal('ios'); + expect(cond.expression).to.equal('device.os == \'ios\''); + + const parsed = JSON.parse(JSON.stringify(template.cache)); + const expectedTemplate = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + const expectedVersion = deepCopy(VERSION_INFO); + expectedVersion.updateTime = new Date(expectedVersion.updateTime).toUTCString(); + expectedTemplate.version = expectedVersion; + expect(parsed).deep.equals(expectedTemplate); + }); + }); + + it('should resolve with template when Version updateTime contains 3 digits in fractional seconds', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + const versionInfo = deepCopy(VERSION_INFO); + versionInfo.updateTime = '2020-10-03T17:14:10.203Z'; + response.version = versionInfo; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + + return remoteConfig.getServerTemplate() + .then((template) => { + expect(template.cache.etag).to.equal('etag-123456789012-5'); + + const version = template.cache.version!; + expect(version.versionNumber).to.equal('86'); + expect(version.updateOrigin).to.equal('ADMIN_SDK_NODE'); + expect(version.updateType).to.equal('INCREMENTAL_UPDATE'); + expect(version.updateUser).to.deep.equal({ + email: 'firebase-adminsdk@gserviceaccount.com' + }); + expect(version.description).to.equal('production version'); + expect(version.updateTime).to.equal('Sat, 03 Oct 2020 17:14:10 GMT'); + }); + }); + + it('should resolve with template when Version updateTime contains 6 digits in fractional seconds', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + const versionInfo = deepCopy(VERSION_INFO); + versionInfo.updateTime = '2020-08-14T17:01:36.541527Z'; + response.version = versionInfo; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + + return remoteConfig.getServerTemplate() + .then((template) => { + expect(template.cache.etag).to.equal('etag-123456789012-5'); + + const version = template.cache.version!; + expect(version.versionNumber).to.equal('86'); + expect(version.updateOrigin).to.equal('ADMIN_SDK_NODE'); + expect(version.updateType).to.equal('INCREMENTAL_UPDATE'); + expect(version.updateUser).to.deep.equal({ + email: 'firebase-adminsdk@gserviceaccount.com' + }); + expect(version.description).to.equal('production version'); + expect(version.updateTime).to.equal('Fri, 14 Aug 2020 17:01:36 GMT'); + }); + }); + + it('should resolve with template when Version updateTime contains 9 digits in fractional seconds', () => { + const response = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); + const versionInfo = deepCopy(VERSION_INFO); + versionInfo.updateTime = '2020-11-15T06:57:26.342763941Z'; + response.version = versionInfo; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response as RemoteConfigServerTemplateData); + stubs.push(stub); + + return remoteConfig.getServerTemplate() + .then((template) => { + expect(template.cache.etag).to.equal('etag-123456789012-5'); + + const version = template.cache.version!; + expect(version.versionNumber).to.equal('86'); + expect(version.updateOrigin).to.equal('ADMIN_SDK_NODE'); + expect(version.updateType).to.equal('INCREMENTAL_UPDATE'); + expect(version.updateUser).to.deep.equal({ + email: 'firebase-adminsdk@gserviceaccount.com' + }); + expect(version.description).to.equal('production version'); + expect(version.updateTime).to.equal('Sun, 15 Nov 2020 06:57:26 GMT'); + }); + }); + }); + + describe('evaluate', () => { + it('returns a config when template is present in cache', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getServerTemplate') + .resolves(SERVER_REMOTE_CONFIG_RESPONSE_2 as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate() + .then((template: RemoteConfigServerTemplate) => { + const config = template.evaluate!(); + expect(config.dog_type).to.equal('corgi'); + expect(config.dog_type_enabled).to.equal(true); + expect(config.dog_age).to.equal(22); + expect(config.dog_jsonified).to.equal('{"name":"Taro","breed":"Corgi","age":1,"fluffiness":100}'); + }); + }); + + it('uses local default if parameter not in template', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getServerTemplate') + .resolves(SERVER_REMOTE_CONFIG_RESPONSE_2 as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate({ + defaultConfig: { + dog_coat: 'blue merle', + } + }) + .then((template: RemoteConfigServerTemplate) => { + const config = template.evaluate!(); + expect(config.dog_coat).to.equal(template.defaultConfig.dog_coat); + }); + }); + + it('uses local default when parameter is in template but default value is undefined', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getServerTemplate') + .resolves(SERVER_REMOTE_CONFIG_RESPONSE_2 as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate({ + defaultConfig: { + dog_no_remote_default_value: 'local default' + } + }) + .then((template: RemoteConfigServerTemplate) => { + const config = template.evaluate!(); + expect(config.dog_no_remote_default_value).to.equal(template.defaultConfig.dog_no_remote_default_value); + }); + }); + + it('uses local default when in-app default value specified', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getServerTemplate') + .resolves(SERVER_REMOTE_CONFIG_RESPONSE_2 as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate({ + defaultConfig: { + dog_use_inapp_default: '🐕' + } + }) + .then((template: RemoteConfigServerTemplate) => { + const config = template.evaluate!(); + expect(config.dog_use_inapp_default).to.equal(template.defaultConfig.dog_use_inapp_default); + }); + }); + + it('overrides local default when value exists', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getServerTemplate') + .resolves(SERVER_REMOTE_CONFIG_RESPONSE_2 as RemoteConfigServerTemplateData); + stubs.push(stub); + return remoteConfig.getServerTemplate({ + defaultConfig: { + dog_type_enabled: false + } + }) + .then((template: RemoteConfigServerTemplate) => { + const config = template.evaluate!(); + expect(config.dog_type_enabled).to.equal(template.defaultConfig.dog_type_enabled); + }); + }); + }); + }); + function runInvalidResponseTests(rcOperation: () => Promise, operationName: any): void { it('should propagate API errors', () => {