Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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