diff --git a/package.json b/package.json index 45a09cc..9d13bda 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs", "fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs", "test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel --timeout 100000", + "test:integration": "npm run build-test-esm && mocha \"out/esm/test/integration/*.test.js\" --no-parallel --timeout 120000", "test-browser": "npx playwright install chromium && vitest --config vitest.browser.config.ts run" }, "repository": { diff --git a/test/integration/featureFlag.test.ts b/test/integration/featureFlag.test.ts new file mode 100644 index 0000000..bf5a978 --- /dev/null +++ b/test/integration/featureFlag.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { setTimeout as delay } from "node:timers/promises"; +import { assert } from "chai"; +import { DefaultAzureCredential } from "@azure/identity"; +import { load } from "../../src/index.js"; +import type { AzureAppConfiguration } from "../../src/index.js"; +import { createNotModifiedResponseTracker, INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; + +interface FeatureFlag { + id: string; + enabled: boolean; + conditions: { + client_filters: unknown[]; + requirement_type: "Any" | "All"; + }; +} + +function getFeatureFlags(settings: AzureAppConfiguration): FeatureFlag[] { + const featureManagement = settings.get<{ feature_flags: FeatureFlag[] }>("feature_management"); + if (featureManagement === undefined) { + throw new Error("Expected the provider to return feature_management.feature_flags."); + } + assert.hasAllKeys(featureManagement, ["feature_flags"]); + assert.isArray(featureManagement.feature_flags); + return featureManagement.feature_flags; +} + +describe("integration: feature flags", function () { + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + credential = new DefaultAzureCredential(); + }); + + beforeEach(() => { + context = new IntegrationTestContext(endpoint, credential); + console.info(`Integration test key prefix: ${context.keyPrefix}`); + }); + + afterEach(async () => { + await context.cleanup(); + }); + + it("loads only flags matching the selected IDs and label", async () => { + await context.setSetting("message", "Ordinary value"); + await context.setFeatureFlag("selected-enabled", true, { label: "production" }); + await context.setFeatureFlag("selected-enabled", false, { label: "development" }); + await context.setFeatureFlag("selected-disabled", false, { label: "production" }); + await context.setFeatureFlag("excluded", true, { label: "production" }); + + const settings = await load(endpoint, credential, { + selectors: [{ keyFilter: context.key("*") }], + featureFlagOptions: { + enabled: true, + selectors: [{ keyFilter: context.key("selected-*"), labelFilter: "production" }] + } + }); + + assert.sameDeepMembers(getFeatureFlags(settings), [ + { + id: context.key("selected-enabled"), + enabled: true, + conditions: { client_filters: [], requirement_type: "Any" } + }, + { + id: context.key("selected-disabled"), + enabled: false, + conditions: { client_filters: [], requirement_type: "Any" } + } + ]); + assert.equal(settings.get(context.key("message")), "Ordinary value"); + assert.sameMembers([...settings.keys()], [context.key("message"), "feature_management"]); + }); + + it("refreshes changed, added, and deleted flags independently of ordinary settings", async () => { + await context.setSetting("message", "Initial value"); + await context.setFeatureFlag("updated", true); + await context.setFeatureFlag("removed", false); + const notModified = createNotModifiedResponseTracker(); + const settings = await load(endpoint, credential, { + selectors: [{ keyFilter: context.key("*") }], + clientOptions: { + additionalPolicies: [{ policy: notModified.policy, position: "perCall" }] + }, + featureFlagOptions: { + enabled: true, + selectors: [{ keyFilter: context.key("*") }], + refresh: { enabled: true, refreshIntervalInMs: REFRESH_INTERVAL_IN_MS } + } + }); + assert.sameDeepMembers(getFeatureFlags(settings), [ + { + id: context.key("updated"), + enabled: true, + conditions: { client_filters: [], requirement_type: "Any" } + }, + { + id: context.key("removed"), + enabled: false, + conditions: { client_filters: [], requirement_type: "Any" } + } + ]); + assert.equal(settings.get(context.key("message")), "Initial value"); + + let notifications = 0; + const listener = settings.onRefresh(() => notifications++); + try { + await context.setSetting("message", "Updated ordinary value"); + await context.setFeatureFlag("updated", false); + await context.setFeatureFlag("added", true); + await context.deleteFeatureFlag("removed"); + + await refreshUntil(settings, () => { + const flags = getFeatureFlags(settings); + return flags.some(flag => flag.id === context.key("updated") && !flag.enabled) && + flags.some(flag => flag.id === context.key("added") && flag.enabled) && + !flags.some(flag => flag.id === context.key("removed")); + }, "feature flag updates, additions, and deletions"); + + assert.sameDeepMembers(getFeatureFlags(settings), [ + { + id: context.key("updated"), + enabled: false, + conditions: { client_filters: [], requirement_type: "Any" } + }, + { + id: context.key("added"), + enabled: true, + conditions: { client_filters: [], requirement_type: "Any" } + } + ]); + assert.equal(settings.get(context.key("message")), "Initial value"); + assert.equal(notifications, 1); + + const previousNotModifiedCount = notModified.count; + await delay(REFRESH_INTERVAL_IN_MS); + await settings.refresh(); + assert.isAbove(notModified.count, previousNotModifiedCount); + assert.equal(notifications, 1); + } finally { + listener.dispose(); + } + }); +}); diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts new file mode 100644 index 0000000..3608287 --- /dev/null +++ b/test/integration/helpers.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { randomUUID } from "node:crypto"; +import { AppConfigurationClient, featureFlagContentType, featureFlagPrefix } from "@azure/app-configuration"; +import type { ConfigurationSettingId, FeatureFlagValue, SetConfigurationSettingParam } from "@azure/app-configuration"; +import type { PipelinePolicy } from "@azure/core-rest-pipeline"; +import type { TokenCredential } from "@azure/identity"; +import type { AzureAppConfiguration } from "../../src/index.js"; + +export const INTEGRATION_TEST_ENDPOINT = "https://appconfig-jsprovider-integrationtest.azconfig.io"; +export const REFRESH_INTERVAL_IN_MS = 1_000; +const REFRESH_TIMEOUT_IN_MS = 30_000; + +export async function refreshUntil( + settings: Pick, + condition: () => boolean, + description: string +): Promise { + const deadline = Date.now() + REFRESH_TIMEOUT_IN_MS; + + while (Date.now() < deadline) { + await settings.refresh(); + if (Date.now() < deadline && condition()) { + return; + } + + const remaining = deadline - Date.now(); + if (remaining > 0) { + await new Promise(resolve => setTimeout(resolve, Math.min(REFRESH_INTERVAL_IN_MS, remaining))); + } + } + + throw new Error(`Timed out after ${REFRESH_TIMEOUT_IN_MS} ms waiting for ${description}.`); +} + +export function createNotModifiedResponseTracker(): { readonly count: number; policy: PipelinePolicy } { + let count = 0; + return { + get count() { + return count; + }, + policy: { + name: "integration-test-not-modified-tracker", + async sendRequest(request, next) { + const response = await next(request); + if (response.status === 304) { + count++; + } + return response; + } + } + }; +} + +export class IntegrationTestContext { + readonly keyPrefix = `jsprovider-it-${randomUUID()}-`; + readonly #client: AppConfigurationClient; + #settings: ConfigurationSettingId[] = []; + + constructor(endpoint: string, credential: TokenCredential) { + this.#client = new AppConfigurationClient(endpoint, credential); + } + + key(name: string): string { + return `${this.keyPrefix}${name}`; + } + + featureFlagKey(name: string): string { + return `${featureFlagPrefix}${this.key(name)}`; + } + + async setSetting( + name: string, + value: string, + options: Pick = {} + ): Promise { + await this.#setSetting({ ...options, key: this.key(name), value }); + } + + async setFeatureFlag( + name: string, + enabled: boolean, + options: Pick = {} + ): Promise { + await this.#setSetting({ + ...options, + key: this.featureFlagKey(name), + contentType: featureFlagContentType, + value: { + id: this.key(name), + enabled, + conditions: { clientFilters: [], requirementType: "Any" } + } + }); + } + + async deleteSetting(name: string, options: Pick = {}): Promise { + await this.#client.deleteConfigurationSetting({ key: this.key(name), label: options.label }); + } + + async deleteFeatureFlag(name: string, options: Pick = {}): Promise { + await this.#client.deleteConfigurationSetting({ key: this.featureFlagKey(name), label: options.label }); + } + + async #setSetting(setting: SetConfigurationSettingParam | SetConfigurationSettingParam): Promise { + // A failed request may still have written the setting, so track it before sending. + if (!this.#settings.some(existing => existing.key === setting.key && existing.label === setting.label)) { + this.#settings.push({ key: setting.key, label: setting.label }); + } + + await this.#client.setConfigurationSetting(setting); + } + + async cleanup(): Promise { + const failedSettings: ConfigurationSettingId[] = []; + const errors: Error[] = []; + + for (const setting of this.#settings) { + try { + await this.#client.deleteConfigurationSetting(setting); + } catch (error) { + failedSettings.push(setting); + errors.push(new Error( + `Failed to delete integration test key '${setting.key}' with label '${setting.label ?? ""}'.`, + { cause: error } + )); + } + } + + this.#settings = failedSettings; + if (errors.length > 0) { + throw new AggregateError(errors, `Failed to clean up integration test settings with prefix '${this.keyPrefix}'.`); + } + } +} diff --git a/test/integration/json.test.ts b/test/integration/json.test.ts new file mode 100644 index 0000000..39063f1 --- /dev/null +++ b/test/integration/json.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import { DefaultAzureCredential } from "@azure/identity"; +import { load } from "../../src/index.js"; +import { INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext } from "./helpers.js"; + +describe("integration: JSON", function () { + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + credential = new DefaultAzureCredential(); + }); + + beforeEach(() => { + context = new IntegrationTestContext(endpoint, credential); + console.info(`Integration test key prefix: ${context.keyPrefix}`); + }); + + afterEach(async () => { + await context.cleanup(); + }); + + it("parses JSON values and constructs a hierarchical configuration object", async () => { + const service = { enabled: true, retries: 3, endpoints: ["primary", "secondary"] }; + const values: [string, unknown][] = [ + ["settings.service", service], + ["settings.enabled", false], + ["settings.count", 0], + ["settings.tags", ["one", "two"]], + ["settings.empty", null] + ]; + for (const [key, value] of values) { + await context.setSetting(key, JSON.stringify(value), { contentType: "application/json" }); + } + + const settings = await load(endpoint, credential, { + selectors: [{ keyFilter: context.key("*") }], + trimKeyPrefixes: [context.keyPrefix] + }); + + assert.sameDeepMembers([...settings], values); + assert.deepEqual(settings.constructConfigurationObject(), { + settings: { + service, + enabled: false, + count: 0, + tags: ["one", "two"], + empty: null + } + }); + }); +}); diff --git a/test/integration/load.test.ts b/test/integration/load.test.ts new file mode 100644 index 0000000..f313903 --- /dev/null +++ b/test/integration/load.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import { DefaultAzureCredential } from "@azure/identity"; +import { load } from "../../src/index.js"; +import { INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext } from "./helpers.js"; + +describe("integration: load", function () { + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + credential = new DefaultAzureCredential(); + }); + + beforeEach(() => { + context = new IntegrationTestContext(endpoint, credential); + console.info(`Integration test key prefix: ${context.keyPrefix}`); + }); + + afterEach(async () => { + await context.cleanup(); + }); + + it("loads unlabeled string and empty values using an endpoint and credential", async () => { + await context.setSetting("message", "Hello from App Configuration"); + await context.setSetting("empty", ""); + await context.setSetting("message", "Labeled value", { label: "production" }); + + const settings = await load(endpoint, credential, { + selectors: [{ keyFilter: context.key("*") }] + }); + + assert.sameDeepMembers([...settings], [ + [context.key("message"), "Hello from App Configuration"], + [context.key("empty"), ""] + ]); + }); + + it("selects only settings matching the key and label filters", async () => { + await context.setSetting("selected:message", "Production value", { label: "production" }); + await context.setSetting("selected:message", "Development value", { label: "development" }); + await context.setSetting("selected:unlabeled", "Default value"); + await context.setSetting("excluded:message", "Excluded value", { label: "production" }); + + const settings = await load(endpoint, credential, { + selectors: [{ + keyFilter: context.key("selected:*"), + labelFilter: "production" + }] + }); + + assert.deepEqual([...settings], [ + [context.key("selected:message"), "Production value"] + ]); + }); +}); diff --git a/test/integration/refresh.test.ts b/test/integration/refresh.test.ts new file mode 100644 index 0000000..28ce7fb --- /dev/null +++ b/test/integration/refresh.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { setTimeout as delay } from "node:timers/promises"; +import { assert } from "chai"; +import { DefaultAzureCredential } from "@azure/identity"; +import { load } from "../../src/index.js"; +import { createNotModifiedResponseTracker, INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; + +describe("integration: refresh", function () { + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + credential = new DefaultAzureCredential(); + }); + + beforeEach(() => { + context = new IntegrationTestContext(endpoint, credential); + console.info(`Integration test key prefix: ${context.keyPrefix}`); + }); + + afterEach(async () => { + await context.cleanup(); + }); + + it("refreshes collection updates, additions, and deletions", async () => { + await context.setSetting("updated", "Initial value"); + await context.setSetting("removed", "Removed value"); + const settings = await load(endpoint, credential, { + selectors: [{ keyFilter: context.key("*") }], + trimKeyPrefixes: [context.keyPrefix], + refreshOptions: { enabled: true, refreshIntervalInMs: REFRESH_INTERVAL_IN_MS } + }); + assert.sameDeepMembers([...settings], [["updated", "Initial value"], ["removed", "Removed value"]]); + + let notifications = 0; + const listener = settings.onRefresh(() => notifications++); + try { + await context.setSetting("updated", "Updated value"); + await context.setSetting("added", "Added value"); + await context.deleteSetting("removed"); + + await refreshUntil(settings, () => + settings.get("updated") === "Updated value" && + settings.get("added") === "Added value" && + !settings.has("removed"), + "collection updates, additions, and deletions"); + + assert.sameDeepMembers([...settings], [["updated", "Updated value"], ["added", "Added value"]]); + assert.equal(notifications, 1); + } finally { + listener.dispose(); + } + }); + + it("reloads selected values only when the watched sentinel label changes", async () => { + await context.setSetting("data:updated", "Initial value"); + await context.setSetting("data:removed", "Removed value"); + await context.setSetting("sentinel", "Initial sentinel", { label: "production" }); + await context.setSetting("sentinel", "Unwatched sentinel"); + const notModified = createNotModifiedResponseTracker(); + const settings = await load(endpoint, credential, { + selectors: [{ keyFilter: context.key("data:*") }], + trimKeyPrefixes: [context.key("data:")], + clientOptions: { + additionalPolicies: [{ policy: notModified.policy, position: "perCall" }] + }, + refreshOptions: { + enabled: true, + refreshIntervalInMs: REFRESH_INTERVAL_IN_MS, + watchedSettings: [{ key: context.key("sentinel"), label: "production" }] + } + }); + const initialValues: [string, string][] = [["updated", "Initial value"], ["removed", "Removed value"]]; + assert.sameDeepMembers([...settings], initialValues); + + let notifications = 0; + const listener = settings.onRefresh(() => notifications++); + try { + await context.setSetting("data:updated", "Updated value"); + await context.setSetting("data:added", "Added value"); + await context.deleteSetting("data:removed"); + await context.setSetting("sentinel", "Changed unwatched sentinel"); + const previousNotModifiedCount = notModified.count; + await delay(REFRESH_INTERVAL_IN_MS); + await settings.refresh(); + + assert.isAbove(notModified.count, previousNotModifiedCount); + assert.sameDeepMembers([...settings], initialValues); + assert.equal(notifications, 0); + + await context.setSetting("sentinel", "Changed watched sentinel", { label: "production" }); + await refreshUntil(settings, () => + settings.get("updated") === "Updated value" && + settings.get("added") === "Added value" && + !settings.has("removed"), + "the watched sentinel to reload the selected collection"); + + assert.sameDeepMembers([...settings], [["updated", "Updated value"], ["added", "Added value"]]); + assert.equal(notifications, 1); + } finally { + listener.dispose(); + } + }); +}); diff --git a/test/integrationHelpers.test.ts b/test/integrationHelpers.test.ts new file mode 100644 index 0000000..93239da --- /dev/null +++ b/test/integrationHelpers.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import * as sinon from "sinon"; +import { AppConfigurationClient, featureFlagContentType } from "@azure/app-configuration"; +import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline"; +import type { PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; +import type { TokenCredential } from "@azure/identity"; +import { createNotModifiedResponseTracker, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./integration/helpers.js"; + +chai.use(chaiAsPromised); +const assert = chai.assert; + +describe("integration test context", function () { + const endpoint = "https://integration.azconfig.io"; + const credential: TokenCredential = { + async getToken() { + throw new Error("Fixture unit tests must not request credentials."); + } + }; + const sandbox = sinon.createSandbox(); + let setSetting: sinon.SinonStubbedFunction; + let deleteSetting: sinon.SinonStubbedFunction; + let context: IntegrationTestContext; + + beforeEach(() => { + setSetting = sandbox.stub(AppConfigurationClient.prototype, "setConfigurationSetting").resolves(); + deleteSetting = sandbox.stub(AppConfigurationClient.prototype, "deleteConfigurationSetting").resolves(); + context = new IntegrationTestContext(endpoint, credential); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("uses a unique JS-test prefix for each context", () => { + const otherContext = new IntegrationTestContext(endpoint, credential); + assert.match(context.keyPrefix, /^jsprovider-it-[0-9a-f-]{36}-$/); + assert.notEqual(context.keyPrefix, otherContext.keyPrefix); + assert.equal(context.key("message"), `${context.keyPrefix}message`); + }); + + it("writes and cleans up exact key and label pairs without listing the store", async () => { + const listSettings = sandbox.spy(AppConfigurationClient.prototype, "listConfigurationSettings"); + await context.setSetting("message", "Default value"); + await context.setSetting("message", "Production value", { label: "production" }); + + sinon.assert.calledWithExactly(setSetting, { + key: context.key("message"), + value: "Default value" + }); + sinon.assert.calledWithExactly(setSetting, { + key: context.key("message"), + label: "production", + value: "Production value" + }); + + await context.cleanup(); + + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("message"), label: undefined }); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("message"), label: "production" }); + sinon.assert.notCalled(listSettings); + }); + + it("preserves content types when creating JSON fixtures", async () => { + await context.setSetting("json", "{\"enabled\":true}", { + label: "production", + contentType: "application/json" + }); + + sinon.assert.calledOnceWithExactly(setSetting, { + key: context.key("json"), + label: "production", + contentType: "application/json", + value: "{\"enabled\":true}" + }); + + await context.cleanup(); + sinon.assert.calledOnceWithExactly(deleteSetting, { key: context.key("json"), label: "production" }); + }); + + it("uses the reserved feature flag prefix and tracks each label for cleanup", async () => { + await context.setFeatureFlag("enabled", true); + await context.setFeatureFlag("enabled", false, { label: "production" }); + + const key = `.appconfig.featureflag/${context.key("enabled")}`; + assert.equal(context.featureFlagKey("enabled"), key); + sinon.assert.calledWithExactly(setSetting, { + key, + contentType: featureFlagContentType, + value: { + id: context.key("enabled"), + enabled: false, + conditions: { clientFilters: [], requirementType: "Any" } + }, + label: "production" + }); + + await context.cleanup(); + + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key, label: undefined }); + sinon.assert.calledWithExactly(deleteSetting, { key, label: "production" }); + }); + + it("keeps ordinary and feature flag keys with the same name separate", async () => { + await context.setSetting("shared", "Ordinary value"); + await context.setFeatureFlag("shared", true); + + await context.cleanup(); + + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("shared"), label: undefined }); + sinon.assert.calledWithExactly(deleteSetting, { key: context.featureFlagKey("shared"), label: undefined }); + }); + + it("scopes explicit deletions and retains the settings for final cleanup", async () => { + await context.setSetting("removed", "Value", { label: "production" }); + await context.setFeatureFlag("removed", true, { label: "production" }); + + await context.deleteSetting("removed", { label: "production" }); + await context.deleteFeatureFlag("removed", { label: "production" }); + + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("removed"), label: "production" }); + sinon.assert.calledWithExactly(deleteSetting, { key: context.featureFlagKey("removed"), label: "production" }); + + deleteSetting.resetHistory(); + await context.cleanup(); + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("removed"), label: "production" }); + sinon.assert.calledWithExactly(deleteSetting, { key: context.featureFlagKey("removed"), label: "production" }); + }); + + it("tracks attempted feature flag writes when setup fails", async () => { + setSetting.rejects(new Error("Feature flag write response was lost")); + + await assert.isRejected(context.setFeatureFlag("failed", true), "Feature flag write response was lost"); + await context.cleanup(); + + sinon.assert.calledOnceWithExactly(deleteSetting, { + key: context.featureFlagKey("failed"), + label: undefined + }); + }); + + it("does not clean up another context's settings", async () => { + const otherContext = new IntegrationTestContext(endpoint, credential); + await context.setSetting("message", "First test"); + await otherContext.setSetting("message", "Second test"); + + await context.cleanup(); + + sinon.assert.calledOnceWithExactly(deleteSetting, { key: context.key("message"), label: undefined }); + }); + + it("tracks repeated writes once and allows cleanup to be called again", async () => { + await context.setSetting("message", "Initial value"); + await context.setSetting("message", "Updated value"); + + await context.cleanup(); + await context.cleanup(); + + sinon.assert.calledOnceWithExactly(deleteSetting, { key: context.key("message"), label: undefined }); + }); + + it("cleans up attempted writes when setup fails", async () => { + await context.setSetting("first", "Created value"); + setSetting.onSecondCall().rejects(new Error("Write response was lost")); + + await assert.isRejected(context.setSetting("second", "Possibly created value"), "Write response was lost"); + await context.cleanup(); + + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("first"), label: undefined }); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("second"), label: undefined }); + }); + + it("reports cleanup failures, continues cleanup, and retains failed settings for retry", async () => { + await context.setSetting("first", "First value"); + await context.setSetting("second", "Second value"); + deleteSetting.onFirstCall().rejects(new Error("Delete failed")); + + await assert.isRejected(context.cleanup(), AggregateError, "Failed to clean up integration test settings"); + + sinon.assert.calledTwice(deleteSetting); + sinon.assert.calledWithExactly(deleteSetting, { key: context.key("second"), label: undefined }); + + deleteSetting.reset(); + deleteSetting.resolves(); + await context.cleanup(); + + sinon.assert.calledOnceWithExactly(deleteSetting, { key: context.key("first"), label: undefined }); + }); +}); + +describe("integration refresh polling", function () { + const sandbox = sinon.createSandbox(); + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sandbox.useFakeTimers(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("retries at the refresh interval until the expected values are observed", async () => { + const refresh = sandbox.stub<[], Promise>().resolves(); + const result = refreshUntil({ refresh }, () => refresh.callCount === 3, "updated values"); + + await clock.runAllAsync(); + await result; + + sinon.assert.calledThrice(refresh); + assert.equal(Date.now(), 2 * REFRESH_INTERVAL_IN_MS); + }); + + it("fails at the deadline even when refresh resolves without changing values", async () => { + const refresh = sandbox.stub<[], Promise>().resolves(); + const rejection = assert.isRejected( + refreshUntil({ refresh }, () => false, "updated values"), + "Timed out after 30000 ms waiting for updated values." + ); + + await clock.runAllAsync(); + await rejection; + + assert.equal(Date.now(), 30_000); + assert.equal(refresh.callCount, 30_000 / REFRESH_INTERVAL_IN_MS); + }); + + it("does not accept a change observed after the deadline", async () => { + const refresh = sandbox.stub<[], Promise>().callsFake(async () => { + clock.setSystemTime(30_001); + }); + + await assert.isRejected(refreshUntil({ refresh }, () => true, "late values"), "Timed out after 30000 ms"); + sinon.assert.calledOnce(refresh); + }); + + it("propagates refresh failures without retrying", async () => { + const refresh = sandbox.stub<[], Promise>().rejects(new Error("Refresh failed")); + + await assert.isRejected(refreshUntil({ refresh }, () => false, "updated values"), "Refresh failed"); + sinon.assert.calledOnce(refresh); + }); + + it("propagates errors in the expected-state check", async () => { + const refresh = sandbox.stub<[], Promise>().resolves(); + + await assert.isRejected(refreshUntil({ refresh }, () => { + throw new Error("Invalid configuration shape"); + }, "updated values"), "Invalid configuration shape"); + sinon.assert.calledOnce(refresh); + }); +}); + +describe("integration response tracking", function () { + it("observes only 304 responses without replacing requests, responses, or failures", async () => { + const tracker = createNotModifiedResponseTracker(); + const request = createPipelineRequest({ url: "https://integration.azconfig.io/kv" }); + const notModifiedResponse = { request, headers: createHttpHeaders(), status: 304 }; + const next = sinon.stub<[PipelineRequest], Promise>().resolves(notModifiedResponse); + + assert.strictEqual(await tracker.policy.sendRequest(request, next), notModifiedResponse); + sinon.assert.calledOnceWithExactly(next, request); + assert.equal(tracker.count, 1); + + const changedResponse = { request, headers: createHttpHeaders(), status: 200 }; + next.resolves(changedResponse); + assert.strictEqual(await tracker.policy.sendRequest(request, next), changedResponse); + assert.equal(tracker.count, 1); + + next.rejects(new Error("Request failed")); + await assert.isRejected(tracker.policy.sendRequest(request, next), "Request failed"); + assert.equal(tracker.count, 1); + }); +});