From ddba496d6dd3426012ec1bf70c6f3b25ecde2027 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Fri, 16 Oct 2026 16:42:17 +0800 Subject: [PATCH 1/3] Add integration tests --- package.json | 1 + test/integration/featureFlag.test.ts | 149 +++++++++++++ test/integration/helpers.ts | 154 +++++++++++++ test/integration/json.test.ts | 57 +++++ test/integration/load.test.ts | 60 +++++ test/integration/refresh.test.ts | 108 +++++++++ test/integrationHelpers.test.ts | 321 +++++++++++++++++++++++++++ 7 files changed, 850 insertions(+) create mode 100644 test/integration/featureFlag.test.ts create mode 100644 test/integration/helpers.ts create mode 100644 test/integration/json.test.ts create mode 100644 test/integration/load.test.ts create mode 100644 test/integration/refresh.test.ts create mode 100644 test/integrationHelpers.test.ts 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..1a88d03 --- /dev/null +++ b/test/integration/featureFlag.test.ts @@ -0,0 +1,149 @@ +// 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, getIntegrationTestEndpoint, 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 endpoint: string; + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + endpoint = getIntegrationTestEndpoint(); + 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..052ae0d --- /dev/null +++ b/test/integration/helpers.ts @@ -0,0 +1,154 @@ +// 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 REFRESH_INTERVAL_IN_MS = 1_000; +const REFRESH_TIMEOUT_IN_MS = 30_000; + +export function getIntegrationTestEndpoint(): string { + const endpoint = process.env.AZ_CONFIG_ENDPOINT?.trim(); + if (!endpoint) { + throw new Error("Set AZ_CONFIG_ENDPOINT to an App Configuration test-store endpoint before running integration tests."); + } + + const invalidEndpointMessage = "AZ_CONFIG_ENDPOINT must be an HTTPS App Configuration endpoint without a path, query, fragment, or embedded credentials."; + if (!URL.canParse(endpoint)) { + throw new Error(invalidEndpointMessage); + } + + const url = new URL(endpoint); + if (url.protocol !== "https:" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) { + throw new Error(invalidEndpointMessage); + } + + return url.origin; +} + +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..9e2b82d --- /dev/null +++ b/test/integration/json.test.ts @@ -0,0 +1,57 @@ +// 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 { getIntegrationTestEndpoint, IntegrationTestContext } from "./helpers.js"; + +describe("integration: JSON", function () { + let endpoint: string; + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + endpoint = getIntegrationTestEndpoint(); + 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..a532d39 --- /dev/null +++ b/test/integration/load.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import { DefaultAzureCredential } from "@azure/identity"; +import { LabelFilter, load } from "../../src/index.js"; +import { getIntegrationTestEndpoint, IntegrationTestContext } from "./helpers.js"; + +describe("integration: load", function () { + let endpoint: string; + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + endpoint = getIntegrationTestEndpoint(); + 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..7b55295 --- /dev/null +++ b/test/integration/refresh.test.ts @@ -0,0 +1,108 @@ +// 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, getIntegrationTestEndpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; + +describe("integration: refresh", function () { + let endpoint: string; + let credential: DefaultAzureCredential; + let context: IntegrationTestContext; + + before(() => { + endpoint = getIntegrationTestEndpoint(); + 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..0e3c394 --- /dev/null +++ b/test/integrationHelpers.test.ts @@ -0,0 +1,321 @@ +// 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, getIntegrationTestEndpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./integration/helpers.js"; + +chai.use(chaiAsPromised); +const assert = chai.assert; + +describe("integration test endpoint", function () { + const originalEndpoint = process.env.AZ_CONFIG_ENDPOINT; + + afterEach(() => { + if (originalEndpoint === undefined) { + delete process.env.AZ_CONFIG_ENDPOINT; + } else { + process.env.AZ_CONFIG_ENDPOINT = originalEndpoint; + } + }); + + it("requires an explicitly configured endpoint", () => { + delete process.env.AZ_CONFIG_ENDPOINT; + assert.throws(getIntegrationTestEndpoint, /Set AZ_CONFIG_ENDPOINT/); + }); + + it("normalizes an HTTPS endpoint", () => { + process.env.AZ_CONFIG_ENDPOINT = " https://integration.azconfig.io/ "; + assert.equal(getIntegrationTestEndpoint(), "https://integration.azconfig.io"); + }); + + it("rejects empty, malformed, or non-origin endpoints", () => { + for (const endpoint of [ + "", + " ", + "not-a-url", + "http://integration.azconfig.io", + "https://integration.azconfig.io/kv", + "https://integration.azconfig.io?query=value", + "https://integration.azconfig.io#fragment", + "https://user:password@integration.azconfig.io" + ]) { + process.env.AZ_CONFIG_ENDPOINT = endpoint; + assert.throws(getIntegrationTestEndpoint, /AZ_CONFIG_ENDPOINT/, endpoint); + } + }); +}); + +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); + }); +}); From d3e6ff264a3333be22f5ebc25709b3621f6064ee Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Fri, 16 Oct 2026 17:53:34 +0800 Subject: [PATCH 2/3] fix lint --- test/integration/load.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/load.test.ts b/test/integration/load.test.ts index a532d39..c0cce87 100644 --- a/test/integration/load.test.ts +++ b/test/integration/load.test.ts @@ -3,7 +3,7 @@ import { assert } from "chai"; import { DefaultAzureCredential } from "@azure/identity"; -import { LabelFilter, load } from "../../src/index.js"; +import { load } from "../../src/index.js"; import { getIntegrationTestEndpoint, IntegrationTestContext } from "./helpers.js"; describe("integration: load", function () { From 75c43108cb58ba4133128f181f2e5bd2bf3302bc Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Mon, 19 Oct 2026 15:00:21 +0800 Subject: [PATCH 3/3] update --- test/integration/featureFlag.test.ts | 4 +-- test/integration/helpers.ts | 20 +------------- test/integration/json.test.ts | 4 +-- test/integration/load.test.ts | 4 +-- test/integration/refresh.test.ts | 4 +-- test/integrationHelpers.test.ts | 40 +--------------------------- 6 files changed, 6 insertions(+), 70 deletions(-) diff --git a/test/integration/featureFlag.test.ts b/test/integration/featureFlag.test.ts index 1a88d03..bf5a978 100644 --- a/test/integration/featureFlag.test.ts +++ b/test/integration/featureFlag.test.ts @@ -6,7 +6,7 @@ import { assert } from "chai"; import { DefaultAzureCredential } from "@azure/identity"; import { load } from "../../src/index.js"; import type { AzureAppConfiguration } from "../../src/index.js"; -import { createNotModifiedResponseTracker, getIntegrationTestEndpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; +import { createNotModifiedResponseTracker, INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; interface FeatureFlag { id: string; @@ -28,12 +28,10 @@ function getFeatureFlags(settings: AzureAppConfiguration): FeatureFlag[] { } describe("integration: feature flags", function () { - let endpoint: string; let credential: DefaultAzureCredential; let context: IntegrationTestContext; before(() => { - endpoint = getIntegrationTestEndpoint(); credential = new DefaultAzureCredential(); }); diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts index 052ae0d..3608287 100644 --- a/test/integration/helpers.ts +++ b/test/integration/helpers.ts @@ -8,28 +8,10 @@ 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 function getIntegrationTestEndpoint(): string { - const endpoint = process.env.AZ_CONFIG_ENDPOINT?.trim(); - if (!endpoint) { - throw new Error("Set AZ_CONFIG_ENDPOINT to an App Configuration test-store endpoint before running integration tests."); - } - - const invalidEndpointMessage = "AZ_CONFIG_ENDPOINT must be an HTTPS App Configuration endpoint without a path, query, fragment, or embedded credentials."; - if (!URL.canParse(endpoint)) { - throw new Error(invalidEndpointMessage); - } - - const url = new URL(endpoint); - if (url.protocol !== "https:" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) { - throw new Error(invalidEndpointMessage); - } - - return url.origin; -} - export async function refreshUntil( settings: Pick, condition: () => boolean, diff --git a/test/integration/json.test.ts b/test/integration/json.test.ts index 9e2b82d..39063f1 100644 --- a/test/integration/json.test.ts +++ b/test/integration/json.test.ts @@ -4,15 +4,13 @@ import { assert } from "chai"; import { DefaultAzureCredential } from "@azure/identity"; import { load } from "../../src/index.js"; -import { getIntegrationTestEndpoint, IntegrationTestContext } from "./helpers.js"; +import { INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext } from "./helpers.js"; describe("integration: JSON", function () { - let endpoint: string; let credential: DefaultAzureCredential; let context: IntegrationTestContext; before(() => { - endpoint = getIntegrationTestEndpoint(); credential = new DefaultAzureCredential(); }); diff --git a/test/integration/load.test.ts b/test/integration/load.test.ts index c0cce87..f313903 100644 --- a/test/integration/load.test.ts +++ b/test/integration/load.test.ts @@ -4,15 +4,13 @@ import { assert } from "chai"; import { DefaultAzureCredential } from "@azure/identity"; import { load } from "../../src/index.js"; -import { getIntegrationTestEndpoint, IntegrationTestContext } from "./helpers.js"; +import { INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext } from "./helpers.js"; describe("integration: load", function () { - let endpoint: string; let credential: DefaultAzureCredential; let context: IntegrationTestContext; before(() => { - endpoint = getIntegrationTestEndpoint(); credential = new DefaultAzureCredential(); }); diff --git a/test/integration/refresh.test.ts b/test/integration/refresh.test.ts index 7b55295..28ce7fb 100644 --- a/test/integration/refresh.test.ts +++ b/test/integration/refresh.test.ts @@ -5,15 +5,13 @@ 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, getIntegrationTestEndpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; +import { createNotModifiedResponseTracker, INTEGRATION_TEST_ENDPOINT as endpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js"; describe("integration: refresh", function () { - let endpoint: string; let credential: DefaultAzureCredential; let context: IntegrationTestContext; before(() => { - endpoint = getIntegrationTestEndpoint(); credential = new DefaultAzureCredential(); }); diff --git a/test/integrationHelpers.test.ts b/test/integrationHelpers.test.ts index 0e3c394..93239da 100644 --- a/test/integrationHelpers.test.ts +++ b/test/integrationHelpers.test.ts @@ -8,49 +8,11 @@ import { AppConfigurationClient, featureFlagContentType } from "@azure/app-confi 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, getIntegrationTestEndpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./integration/helpers.js"; +import { createNotModifiedResponseTracker, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./integration/helpers.js"; chai.use(chaiAsPromised); const assert = chai.assert; -describe("integration test endpoint", function () { - const originalEndpoint = process.env.AZ_CONFIG_ENDPOINT; - - afterEach(() => { - if (originalEndpoint === undefined) { - delete process.env.AZ_CONFIG_ENDPOINT; - } else { - process.env.AZ_CONFIG_ENDPOINT = originalEndpoint; - } - }); - - it("requires an explicitly configured endpoint", () => { - delete process.env.AZ_CONFIG_ENDPOINT; - assert.throws(getIntegrationTestEndpoint, /Set AZ_CONFIG_ENDPOINT/); - }); - - it("normalizes an HTTPS endpoint", () => { - process.env.AZ_CONFIG_ENDPOINT = " https://integration.azconfig.io/ "; - assert.equal(getIntegrationTestEndpoint(), "https://integration.azconfig.io"); - }); - - it("rejects empty, malformed, or non-origin endpoints", () => { - for (const endpoint of [ - "", - " ", - "not-a-url", - "http://integration.azconfig.io", - "https://integration.azconfig.io/kv", - "https://integration.azconfig.io?query=value", - "https://integration.azconfig.io#fragment", - "https://user:password@integration.azconfig.io" - ]) { - process.env.AZ_CONFIG_ENDPOINT = endpoint; - assert.throws(getIntegrationTestEndpoint, /AZ_CONFIG_ENDPOINT/, endpoint); - } - }); -}); - describe("integration test context", function () { const endpoint = "https://integration.azconfig.io"; const credential: TokenCredential = {