diff --git a/.eslintrc b/.eslintrc index 754a9f3..8b96047 100644 --- a/.eslintrc +++ b/.eslintrc @@ -40,5 +40,6 @@ "avoidEscape": true } ], - }, + "@typescript-eslint/no-explicit-any": "off" + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 71d0285..ada9f0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "rollup-plugin-dts": "^5.3.0", "sinon": "^15.2.0", "tslib": "^2.6.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "uuid": "^9.0.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -208,6 +209,14 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/identity/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/keyvault-secrets": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.7.0.tgz", @@ -272,6 +281,14 @@ "node": "10 || 12 || 14 || 16 || 18" } }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -3379,9 +3396,14 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -3656,6 +3678,13 @@ "stoppable": "^1.1.0", "tslib": "^2.2.0", "uuid": "^8.3.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@azure/keyvault-secrets": { @@ -3705,6 +3734,13 @@ "@azure/msal-common": "13.3.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@babel/code-frame": { @@ -5947,9 +5983,10 @@ } }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true }, "which": { "version": "2.0.2", diff --git a/package.json b/package.json index 148f171..b354ca1 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "rollup-plugin-dts": "^5.3.0", "sinon": "^15.2.0", "tslib": "^2.6.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "uuid": "^9.0.1" }, "dependencies": { "@azure/app-configuration": "^1.4.1", diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index cd4cdc2..c7f9efe 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -1,6 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { Disposable } from "./common/disposable"; + export type AzureAppConfiguration = { - // methods for advanced features, e.g. refresh() -} & ReadonlyMap; + /** + * API to trigger refresh operation. + */ + refresh(): Promise; + + /** + * API to register callback listeners, which will be called only when a refresh operation successfully updates key-values. + * + * @param listener Callback funtion to be registered. + * @param thisArg Optional. Value to use as this when executing callback. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable; +} & ReadonlyMap; diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 323ac2a..2be5f43 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,8 +9,11 @@ import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; import { KeyFilter } from "./KeyFilter"; import { LabelFilter } from "./LabelFilter"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; -import { CorrelationContextHeaderName } from "./requestTracing/constants"; +import { CorrelationContextHeaderName, RequestType } from "./requestTracing/constants"; import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils"; +import { DefaultRefreshIntervalInMs, MinimumRefreshIntervalInMs } from "./RefreshOptions"; +import { LinkedList } from "./common/linkedList"; +import { Disposable } from "./common/disposable"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { private adapters: IKeyValueAdapter[] = []; @@ -20,7 +23,10 @@ export class AzureAppConfigurationImpl extends Map implements A */ private sortedTrimKeyPrefixes: string[] | undefined; private readonly requestTracingEnabled: boolean; - private correlationContextHeader: string | undefined; + // Refresh + private refreshIntervalInMs: number; + private onRefreshListeners: LinkedList<() => any>; + private lastUpdateTimestamp: number; constructor( private client: AppConfigurationClient, @@ -29,20 +35,32 @@ export class AzureAppConfigurationImpl extends Map implements A super(); // Enable request tracing if not opt-out this.requestTracingEnabled = requestTracingEnabled(); - if (this.requestTracingEnabled) { - this.enableRequestTracing(); - } if (options?.trimKeyPrefixes) { this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } + + if (options?.refreshOptions) { + this.onRefreshListeners = new LinkedList(); + this.refreshIntervalInMs = DefaultRefreshIntervalInMs; + + const refreshIntervalInMs = this.options?.refreshOptions?.refreshIntervalInMs; + if (refreshIntervalInMs !== undefined) { + if (refreshIntervalInMs < MinimumRefreshIntervalInMs) { + throw new Error(`The refresh interval time cannot be less than ${MinimumRefreshIntervalInMs} milliseconds.`); + } else { + this.refreshIntervalInMs = refreshIntervalInMs; + } + } + } + // TODO: should add more adapters to process different type of values // feature flag, others this.adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); this.adapters.push(new JsonKeyValueAdapter()); } - public async load() { + public async load(requestType: RequestType = RequestType.Startup) { const keyValues: [key: string, value: unknown][] = []; const selectors = this.options?.selectors ?? [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; for (const selector of selectors) { @@ -52,7 +70,7 @@ export class AzureAppConfigurationImpl extends Map implements A }; if (this.requestTracingEnabled) { listOptions.requestOptions = { - customHeaders: this.customHeaders() + customHeaders: this.customHeaders(requestType) } } @@ -60,15 +78,58 @@ export class AzureAppConfigurationImpl extends Map implements A for await (const setting of settings) { if (setting.key) { - const [key, value] = await this.processAdapters(setting); - const trimmedKey = this.keyWithPrefixesTrimmed(key); - keyValues.push([trimmedKey, value]); + const keyValuePair = await this.processKeyValues(setting); + keyValues.push(keyValuePair); } } } for (const [k, v] of keyValues) { this.set(k, v); } + this.lastUpdateTimestamp = Date.now(); + } + + public async refresh(): Promise { + // if no refreshOptions set, return + if (this.options?.refreshOptions === undefined || this.options.refreshOptions.watchedSettings.length === 0) { + return Promise.resolve(); + } + // if still within refresh interval, return + const now = Date.now(); + if (now < this.lastUpdateTimestamp + this.refreshIntervalInMs) { + return Promise.resolve(); + } + + // try refresh if any of watched settings is changed. + // TODO: watchedSettings as optional, etag based refresh if not specified. + let needRefresh = false; + for (const watchedSetting of this.options.refreshOptions.watchedSettings) { + const response = await this.client.getConfigurationSetting(watchedSetting); + const [key, value] = await this.processKeyValues(response); + if (value !== this.get(key)) { + needRefresh = true; + break; + } + } + if (needRefresh) { + await this.load(RequestType.Watch); + // run callbacks in async + for (const listener of this.onRefreshListeners) { + listener(); + } + } + } + + public onRefresh(listener: () => any, thisArg?: any): Disposable { + const boundedListener = listener.bind(thisArg); + const remove = this.onRefreshListeners.push(boundedListener); + return new Disposable(remove); + } + + private async processKeyValues(setting: ConfigurationSetting): Promise<[string, unknown]> { + const [key, value] = await this.processAdapters(setting); + const trimmedKey = this.keyWithPrefixesTrimmed(key); + return [trimmedKey, value]; } private async processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -91,17 +152,13 @@ export class AzureAppConfigurationImpl extends Map implements A return key; } - private enableRequestTracing() { - this.correlationContextHeader = createCorrelationContextHeader(this.options); - } - - private customHeaders() { + private customHeaders(requestType: RequestType) { if (!this.requestTracingEnabled) { return undefined; } const headers = {}; - headers[CorrelationContextHeaderName] = this.correlationContextHeader; + headers[CorrelationContextHeaderName] = createCorrelationContextHeader(this.options, requestType); return headers; } } diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 975cb83..4bda772 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -3,6 +3,7 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions"; +import { RefreshOptions } from "./RefreshOptions"; export const MaxRetries = 2; export const MaxRetryDelayInMs = 60000; @@ -12,4 +13,8 @@ export interface AzureAppConfigurationOptions { trimKeyPrefixes?: string[]; clientOptions?: AppConfigurationClientOptions; keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; + /** + * Specifies options for dynamic refresh key-values. + */ + refreshOptions?: RefreshOptions; } \ No newline at end of file diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts new file mode 100644 index 0000000..620f8a3 --- /dev/null +++ b/src/RefreshOptions.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WatchedSetting } from "./WatchedSetting"; + +export const DefaultRefreshIntervalInMs = 30 * 1000; +export const MinimumRefreshIntervalInMs = 1 * 1000; + +export interface RefreshOptions { + /** + * Specifies the interval for refresh to really update the values. + * Default value is 30 seconds. Must be greater than 1 second. + * Any refresh operation triggered will not update the value for a key until after the interval. + */ + refreshIntervalInMs?: number; + + /** + * Specifies settings to be watched, to determine whether the provider triggers a refresh. + */ + watchedSettings: WatchedSetting[]; +} \ No newline at end of file diff --git a/src/WatchedSetting.ts b/src/WatchedSetting.ts new file mode 100644 index 0000000..815a26b --- /dev/null +++ b/src/WatchedSetting.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface WatchedSetting { + key: string; + label?: string; +} \ No newline at end of file diff --git a/src/common/disposable.ts b/src/common/disposable.ts new file mode 100644 index 0000000..8896013 --- /dev/null +++ b/src/common/disposable.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Disposable { + private disposed = false; + constructor(private callOnDispose: () => any) { } + + dispose() { + if (!this.disposed) { + this.callOnDispose(); + } + this.disposed = true; + } + +} \ No newline at end of file diff --git a/src/common/linkedList.ts b/src/common/linkedList.ts new file mode 100644 index 0000000..5d932c7 --- /dev/null +++ b/src/common/linkedList.ts @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +class Node { + + static readonly Undefined = new Node(undefined); + + element: E; + next: Node; + prev: Node; + + constructor(element: E) { + this.element = element; + this.next = Node.Undefined; + this.prev = Node.Undefined; + } +} + +export class LinkedList { + + private _first: Node = Node.Undefined; + private _last: Node = Node.Undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return this._first === Node.Undefined; + } + + clear(): void { + let node = this._first; + while (node !== Node.Undefined) { + const next = node.next; + node.prev = Node.Undefined; + node.next = Node.Undefined; + node = next; + } + + this._first = Node.Undefined; + this._last = Node.Undefined; + this._size = 0; + } + + unshift(element: E): () => void { + return this._insert(element, false); + } + + push(element: E): () => void { + return this._insert(element, true); + } + + private _insert(element: E, atTheEnd: boolean): () => void { + const newNode = new Node(element); + if (this._first === Node.Undefined) { + this._first = newNode; + this._last = newNode; + + } else if (atTheEnd) { + // push + const oldLast = this._last!; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + + } else { + // unshift + const oldFirst = this._first; + this._first = newNode; + newNode.next = oldFirst; + oldFirst.prev = newNode; + } + this._size += 1; + + let didRemove = false; + return () => { + if (!didRemove) { + didRemove = true; + this._remove(newNode); + } + }; + } + + shift(): E | undefined { + if (this._first === Node.Undefined) { + return undefined; + } else { + const res = this._first.element; + this._remove(this._first); + return res; + } + } + + pop(): E | undefined { + if (this._last === Node.Undefined) { + return undefined; + } else { + const res = this._last.element; + this._remove(this._last); + return res; + } + } + + private _remove(node: Node): void { + if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { + // middle + const anchor = node.prev; + anchor.next = node.next; + node.next.prev = anchor; + + } else if (node.prev === Node.Undefined && node.next === Node.Undefined) { + // only node + this._first = Node.Undefined; + this._last = Node.Undefined; + + } else if (node.next === Node.Undefined) { + // last + this._last = this._last!.prev!; + this._last.next = Node.Undefined; + + } else if (node.prev === Node.Undefined) { + // first + this._first = this._first!.next!; + this._first.prev = Node.Undefined; + } + + // done + this._size -= 1; + } + + *[Symbol.iterator](): Iterator { + let node = this._first; + while (node !== Node.Undefined) { + yield node.element; + node = node.next; + } + } +} diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 95483bf..519a31e 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -21,7 +21,7 @@ import { } from "./constants"; // Utils -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined): string { +export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, requestType: RequestType): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -29,7 +29,7 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt UsersKeyVault */ const keyValues = new Map(); - keyValues.set(RequestTypeKey, RequestType.Startup); // TODO: now always "Startup", until refresh is supported. + keyValues.set(RequestTypeKey, requestType); keyValues.set(HostTypeKey, getHostType()); keyValues.set(EnvironmentKey, isDevEnvironment() ? DevEnvironmentValue : undefined); diff --git a/test/load.test.js b/test/load.test.js index 5a8682a..620ea05 100644 --- a/test/load.test.js +++ b/test/load.test.js @@ -12,54 +12,28 @@ const { createMockedConnectionString, createMockedEnpoint, createMockedTokenCredential, + createMockedKeyValue, } = require("./utils/testHelper"); const mockedKVs = [{ value: "red", key: "app.settings.fontColor", - label: null, - contentType: "", - lastModified: "2023-05-04T04:34:24.000Z", - tags: {}, - etag: "210fjkPIWZMjFTi_qyEEmmsJjtUjj0YQl-Y3s1m6GLw", - isReadOnly: false }, { value: "40", key: "app.settings.fontSize", - label: null, - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false }, { value: "TestValue", key: "TestKey", label: "Test", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false }, { value: null, key: "KeyForNullValue", label: "", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false }, { value: "", key: "KeyForEmptyValue", label: "", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false -}]; +}].map(createMockedKeyValue); describe("load", function () { before(() => { diff --git a/test/refresh.test.js b/test/refresh.test.js new file mode 100644 index 0000000..5ad826b --- /dev/null +++ b/test/refresh.test.js @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const chai = require("chai"); +const chaiAsPromised = require("chai-as-promised"); +chai.use(chaiAsPromised); +const expect = chai.expect; +const { load } = require("../dist/index"); +const { + mockAppConfigurationClientListConfigurationSettings, + mockAppConfigurationClientGetConfigurationSetting, + restoreMocks, + createMockedConnectionString, + createMockedKeyValue, +} = require("./utils/testHelper"); +const { promisify } = require("util") +const sleepInMs = promisify(setTimeout); + +let mockedKVs = []; + +function updateSetting(key, value) { + const setting = mockedKVs.find(elem => elem.key === key); + if (setting) { + setting.value = value; + } +} +function addSetting(key, value) { + mockedKVs.push(createMockedKeyValue({key, value})); +} + +describe("dynamic refresh", function () { + beforeEach(() => { + mockedKVs = [ + { value: "red", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings(mockedKVs); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs) + }); + + afterEach(() => { + restoreMocks(); + }) + + it("should only udpate values after refreshInterval", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + updateSetting("app.settings.fontColor", "blue"); + + // within refreshInterval, should not really refresh + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("red"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000); + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should not update values when unwatched setting changes", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + updateSetting("app.settings.fontSize", "50"); // unwatched setting + await sleepInMs(2 * 1000); + await settings.refresh(); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should watch multiple settings if specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" }, + { key: "app.settings.fontSize" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + addSetting("app.settings.bgColor", "white"); + updateSetting("app.settings.fontSize", "50"); + await sleepInMs(2 * 1000); + await settings.refresh(); + expect(settings.get("app.settings.fontSize")).eq("50"); + expect(settings.get("app.settings.bgColor")).eq("white"); + }); + + it("should execute callbacks on successful refresh", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + let count = 0; + settings.onRefresh(() => count++); + + updateSetting("app.settings.fontColor", "blue"); + await settings.refresh(); + expect(count).eq(0); + + await sleepInMs(2000); + await settings.refresh(); + expect(count).eq(1); + }); +}); \ No newline at end of file diff --git a/test/utils/testHelper.js b/test/utils/testHelper.js index 11e43f7..cde32cb 100644 --- a/test/utils/testHelper.js +++ b/test/utils/testHelper.js @@ -5,6 +5,7 @@ const sinon = require("sinon"); const { AppConfigurationClient } = require("@azure/app-configuration"); const { ClientSecretCredential } = require("@azure/identity"); const { SecretClient } = require("@azure/keyvault-secrets"); +const uuid = require("uuid"); const TEST_CLIENT_ID = "62e76eb5-218e-4f90-8261-000000000000"; const TEST_TENANT_ID = "72f988bf-86f1-41af-91ab-000000000000"; @@ -17,6 +18,14 @@ function mockAppConfigurationClientListConfigurationSettings(kvList) { sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake(() => testKvSetGnerator()); } +function mockAppConfigurationClientGetConfigurationSetting(kvList) { + sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting").callsFake((settingId) => { + const key = settingId.key; + const label = settingId.label ?? null; + return kvList.find(elem => elem.key === key && elem.label === label); + }); +} + // uriValueList: [["", "value"], ...] function mockSecretClientGetSecret(uriValueList) { const dict = new Map(); @@ -74,9 +83,21 @@ const createMockedJsonKeyValue = (key, value) => ({ isReadOnly: false }); +const createMockedKeyValue = (props) => (Object.assign({ + value: "TestValue", + key: "TestKey", + label: null, + contentType: "", + lastModified: new Date().toISOString(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + module.exports = { sinon, mockAppConfigurationClientListConfigurationSettings, + mockAppConfigurationClientGetConfigurationSetting, mockSecretClientGetSecret, restoreMocks, @@ -84,5 +105,6 @@ module.exports = { createMockedConnectionString, createMockedTokenCredential, createMockedKeyVaultReference, - createMockedJsonKeyValue + createMockedJsonKeyValue, + createMockedKeyValue } \ No newline at end of file