Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
147 changes: 147 additions & 0 deletions test/integration/featureFlag.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
136 changes: 136 additions & 0 deletions test/integration/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<AzureAppConfiguration, "refresh">,
condition: () => boolean,
description: string
): Promise<void> {
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<void>(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<SetConfigurationSettingParam, "label" | "contentType"> = {}
): Promise<void> {
await this.#setSetting({ ...options, key: this.key(name), value });
}

async setFeatureFlag(
name: string,
enabled: boolean,
options: Pick<ConfigurationSettingId, "label"> = {}
): Promise<void> {
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<ConfigurationSettingId, "label"> = {}): Promise<void> {
await this.#client.deleteConfigurationSetting({ key: this.key(name), label: options.label });
}

async deleteFeatureFlag(name: string, options: Pick<ConfigurationSettingId, "label"> = {}): Promise<void> {
await this.#client.deleteConfigurationSetting({ key: this.featureFlagKey(name), label: options.label });
}

async #setSetting(setting: SetConfigurationSettingParam | SetConfigurationSettingParam<FeatureFlagValue>): Promise<void> {
// 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<void> {
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 ?? "<no 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}'.`);
}
}
}
55 changes: 55 additions & 0 deletions test/integration/json.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
});
});
});
Loading
Loading