Skip to content

Commit ddba496

Browse files
committed
Add integration tests
1 parent 1e2faf8 commit ddba496

7 files changed

Lines changed: 850 additions & 0 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"lint": "eslint src/ test/ examples/ --ext .js,.ts,.mjs",
3333
"fix-lint": "eslint src/ test/ examples/ --fix --ext .js,.ts,.mjs",
3434
"test": "mocha out/esm/test/*.test.js out/commonjs/test/*.test.js --parallel --timeout 100000",
35+
"test:integration": "npm run build-test-esm && mocha \"out/esm/test/integration/*.test.js\" --no-parallel --timeout 120000",
3536
"test-browser": "npx playwright install chromium && vitest --config vitest.browser.config.ts run"
3637
},
3738
"repository": {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { setTimeout as delay } from "node:timers/promises";
5+
import { assert } from "chai";
6+
import { DefaultAzureCredential } from "@azure/identity";
7+
import { load } from "../../src/index.js";
8+
import type { AzureAppConfiguration } from "../../src/index.js";
9+
import { createNotModifiedResponseTracker, getIntegrationTestEndpoint, IntegrationTestContext, REFRESH_INTERVAL_IN_MS, refreshUntil } from "./helpers.js";
10+
11+
interface FeatureFlag {
12+
id: string;
13+
enabled: boolean;
14+
conditions: {
15+
client_filters: unknown[];
16+
requirement_type: "Any" | "All";
17+
};
18+
}
19+
20+
function getFeatureFlags(settings: AzureAppConfiguration): FeatureFlag[] {
21+
const featureManagement = settings.get<{ feature_flags: FeatureFlag[] }>("feature_management");
22+
if (featureManagement === undefined) {
23+
throw new Error("Expected the provider to return feature_management.feature_flags.");
24+
}
25+
assert.hasAllKeys(featureManagement, ["feature_flags"]);
26+
assert.isArray(featureManagement.feature_flags);
27+
return featureManagement.feature_flags;
28+
}
29+
30+
describe("integration: feature flags", function () {
31+
let endpoint: string;
32+
let credential: DefaultAzureCredential;
33+
let context: IntegrationTestContext;
34+
35+
before(() => {
36+
endpoint = getIntegrationTestEndpoint();
37+
credential = new DefaultAzureCredential();
38+
});
39+
40+
beforeEach(() => {
41+
context = new IntegrationTestContext(endpoint, credential);
42+
console.info(`Integration test key prefix: ${context.keyPrefix}`);
43+
});
44+
45+
afterEach(async () => {
46+
await context.cleanup();
47+
});
48+
49+
it("loads only flags matching the selected IDs and label", async () => {
50+
await context.setSetting("message", "Ordinary value");
51+
await context.setFeatureFlag("selected-enabled", true, { label: "production" });
52+
await context.setFeatureFlag("selected-enabled", false, { label: "development" });
53+
await context.setFeatureFlag("selected-disabled", false, { label: "production" });
54+
await context.setFeatureFlag("excluded", true, { label: "production" });
55+
56+
const settings = await load(endpoint, credential, {
57+
selectors: [{ keyFilter: context.key("*") }],
58+
featureFlagOptions: {
59+
enabled: true,
60+
selectors: [{ keyFilter: context.key("selected-*"), labelFilter: "production" }]
61+
}
62+
});
63+
64+
assert.sameDeepMembers(getFeatureFlags(settings), [
65+
{
66+
id: context.key("selected-enabled"),
67+
enabled: true,
68+
conditions: { client_filters: [], requirement_type: "Any" }
69+
},
70+
{
71+
id: context.key("selected-disabled"),
72+
enabled: false,
73+
conditions: { client_filters: [], requirement_type: "Any" }
74+
}
75+
]);
76+
assert.equal(settings.get(context.key("message")), "Ordinary value");
77+
assert.sameMembers([...settings.keys()], [context.key("message"), "feature_management"]);
78+
});
79+
80+
it("refreshes changed, added, and deleted flags independently of ordinary settings", async () => {
81+
await context.setSetting("message", "Initial value");
82+
await context.setFeatureFlag("updated", true);
83+
await context.setFeatureFlag("removed", false);
84+
const notModified = createNotModifiedResponseTracker();
85+
const settings = await load(endpoint, credential, {
86+
selectors: [{ keyFilter: context.key("*") }],
87+
clientOptions: {
88+
additionalPolicies: [{ policy: notModified.policy, position: "perCall" }]
89+
},
90+
featureFlagOptions: {
91+
enabled: true,
92+
selectors: [{ keyFilter: context.key("*") }],
93+
refresh: { enabled: true, refreshIntervalInMs: REFRESH_INTERVAL_IN_MS }
94+
}
95+
});
96+
assert.sameDeepMembers(getFeatureFlags(settings), [
97+
{
98+
id: context.key("updated"),
99+
enabled: true,
100+
conditions: { client_filters: [], requirement_type: "Any" }
101+
},
102+
{
103+
id: context.key("removed"),
104+
enabled: false,
105+
conditions: { client_filters: [], requirement_type: "Any" }
106+
}
107+
]);
108+
assert.equal(settings.get(context.key("message")), "Initial value");
109+
110+
let notifications = 0;
111+
const listener = settings.onRefresh(() => notifications++);
112+
try {
113+
await context.setSetting("message", "Updated ordinary value");
114+
await context.setFeatureFlag("updated", false);
115+
await context.setFeatureFlag("added", true);
116+
await context.deleteFeatureFlag("removed");
117+
118+
await refreshUntil(settings, () => {
119+
const flags = getFeatureFlags(settings);
120+
return flags.some(flag => flag.id === context.key("updated") && !flag.enabled) &&
121+
flags.some(flag => flag.id === context.key("added") && flag.enabled) &&
122+
!flags.some(flag => flag.id === context.key("removed"));
123+
}, "feature flag updates, additions, and deletions");
124+
125+
assert.sameDeepMembers(getFeatureFlags(settings), [
126+
{
127+
id: context.key("updated"),
128+
enabled: false,
129+
conditions: { client_filters: [], requirement_type: "Any" }
130+
},
131+
{
132+
id: context.key("added"),
133+
enabled: true,
134+
conditions: { client_filters: [], requirement_type: "Any" }
135+
}
136+
]);
137+
assert.equal(settings.get(context.key("message")), "Initial value");
138+
assert.equal(notifications, 1);
139+
140+
const previousNotModifiedCount = notModified.count;
141+
await delay(REFRESH_INTERVAL_IN_MS);
142+
await settings.refresh();
143+
assert.isAbove(notModified.count, previousNotModifiedCount);
144+
assert.equal(notifications, 1);
145+
} finally {
146+
listener.dispose();
147+
}
148+
});
149+
});

‎test/integration/helpers.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { randomUUID } from "node:crypto";
5+
import { AppConfigurationClient, featureFlagContentType, featureFlagPrefix } from "@azure/app-configuration";
6+
import type { ConfigurationSettingId, FeatureFlagValue, SetConfigurationSettingParam } from "@azure/app-configuration";
7+
import type { PipelinePolicy } from "@azure/core-rest-pipeline";
8+
import type { TokenCredential } from "@azure/identity";
9+
import type { AzureAppConfiguration } from "../../src/index.js";
10+
11+
export const REFRESH_INTERVAL_IN_MS = 1_000;
12+
const REFRESH_TIMEOUT_IN_MS = 30_000;
13+
14+
export function getIntegrationTestEndpoint(): string {
15+
const endpoint = process.env.AZ_CONFIG_ENDPOINT?.trim();
16+
if (!endpoint) {
17+
throw new Error("Set AZ_CONFIG_ENDPOINT to an App Configuration test-store endpoint before running integration tests.");
18+
}
19+
20+
const invalidEndpointMessage = "AZ_CONFIG_ENDPOINT must be an HTTPS App Configuration endpoint without a path, query, fragment, or embedded credentials.";
21+
if (!URL.canParse(endpoint)) {
22+
throw new Error(invalidEndpointMessage);
23+
}
24+
25+
const url = new URL(endpoint);
26+
if (url.protocol !== "https:" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
27+
throw new Error(invalidEndpointMessage);
28+
}
29+
30+
return url.origin;
31+
}
32+
33+
export async function refreshUntil(
34+
settings: Pick<AzureAppConfiguration, "refresh">,
35+
condition: () => boolean,
36+
description: string
37+
): Promise<void> {
38+
const deadline = Date.now() + REFRESH_TIMEOUT_IN_MS;
39+
40+
while (Date.now() < deadline) {
41+
await settings.refresh();
42+
if (Date.now() < deadline && condition()) {
43+
return;
44+
}
45+
46+
const remaining = deadline - Date.now();
47+
if (remaining > 0) {
48+
await new Promise<void>(resolve => setTimeout(resolve, Math.min(REFRESH_INTERVAL_IN_MS, remaining)));
49+
}
50+
}
51+
52+
throw new Error(`Timed out after ${REFRESH_TIMEOUT_IN_MS} ms waiting for ${description}.`);
53+
}
54+
55+
export function createNotModifiedResponseTracker(): { readonly count: number; policy: PipelinePolicy } {
56+
let count = 0;
57+
return {
58+
get count() {
59+
return count;
60+
},
61+
policy: {
62+
name: "integration-test-not-modified-tracker",
63+
async sendRequest(request, next) {
64+
const response = await next(request);
65+
if (response.status === 304) {
66+
count++;
67+
}
68+
return response;
69+
}
70+
}
71+
};
72+
}
73+
74+
export class IntegrationTestContext {
75+
readonly keyPrefix = `jsprovider-it-${randomUUID()}-`;
76+
readonly #client: AppConfigurationClient;
77+
#settings: ConfigurationSettingId[] = [];
78+
79+
constructor(endpoint: string, credential: TokenCredential) {
80+
this.#client = new AppConfigurationClient(endpoint, credential);
81+
}
82+
83+
key(name: string): string {
84+
return `${this.keyPrefix}${name}`;
85+
}
86+
87+
featureFlagKey(name: string): string {
88+
return `${featureFlagPrefix}${this.key(name)}`;
89+
}
90+
91+
async setSetting(
92+
name: string,
93+
value: string,
94+
options: Pick<SetConfigurationSettingParam, "label" | "contentType"> = {}
95+
): Promise<void> {
96+
await this.#setSetting({ ...options, key: this.key(name), value });
97+
}
98+
99+
async setFeatureFlag(
100+
name: string,
101+
enabled: boolean,
102+
options: Pick<ConfigurationSettingId, "label"> = {}
103+
): Promise<void> {
104+
await this.#setSetting({
105+
...options,
106+
key: this.featureFlagKey(name),
107+
contentType: featureFlagContentType,
108+
value: {
109+
id: this.key(name),
110+
enabled,
111+
conditions: { clientFilters: [], requirementType: "Any" }
112+
}
113+
});
114+
}
115+
116+
async deleteSetting(name: string, options: Pick<ConfigurationSettingId, "label"> = {}): Promise<void> {
117+
await this.#client.deleteConfigurationSetting({ key: this.key(name), label: options.label });
118+
}
119+
120+
async deleteFeatureFlag(name: string, options: Pick<ConfigurationSettingId, "label"> = {}): Promise<void> {
121+
await this.#client.deleteConfigurationSetting({ key: this.featureFlagKey(name), label: options.label });
122+
}
123+
124+
async #setSetting(setting: SetConfigurationSettingParam | SetConfigurationSettingParam<FeatureFlagValue>): Promise<void> {
125+
// A failed request may still have written the setting, so track it before sending.
126+
if (!this.#settings.some(existing => existing.key === setting.key && existing.label === setting.label)) {
127+
this.#settings.push({ key: setting.key, label: setting.label });
128+
}
129+
130+
await this.#client.setConfigurationSetting(setting);
131+
}
132+
133+
async cleanup(): Promise<void> {
134+
const failedSettings: ConfigurationSettingId[] = [];
135+
const errors: Error[] = [];
136+
137+
for (const setting of this.#settings) {
138+
try {
139+
await this.#client.deleteConfigurationSetting(setting);
140+
} catch (error) {
141+
failedSettings.push(setting);
142+
errors.push(new Error(
143+
`Failed to delete integration test key '${setting.key}' with label '${setting.label ?? "<no label>"}'.`,
144+
{ cause: error }
145+
));
146+
}
147+
}
148+
149+
this.#settings = failedSettings;
150+
if (errors.length > 0) {
151+
throw new AggregateError(errors, `Failed to clean up integration test settings with prefix '${this.keyPrefix}'.`);
152+
}
153+
}
154+
}

‎test/integration/json.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { assert } from "chai";
5+
import { DefaultAzureCredential } from "@azure/identity";
6+
import { load } from "../../src/index.js";
7+
import { getIntegrationTestEndpoint, IntegrationTestContext } from "./helpers.js";
8+
9+
describe("integration: JSON", function () {
10+
let endpoint: string;
11+
let credential: DefaultAzureCredential;
12+
let context: IntegrationTestContext;
13+
14+
before(() => {
15+
endpoint = getIntegrationTestEndpoint();
16+
credential = new DefaultAzureCredential();
17+
});
18+
19+
beforeEach(() => {
20+
context = new IntegrationTestContext(endpoint, credential);
21+
console.info(`Integration test key prefix: ${context.keyPrefix}`);
22+
});
23+
24+
afterEach(async () => {
25+
await context.cleanup();
26+
});
27+
28+
it("parses JSON values and constructs a hierarchical configuration object", async () => {
29+
const service = { enabled: true, retries: 3, endpoints: ["primary", "secondary"] };
30+
const values: [string, unknown][] = [
31+
["settings.service", service],
32+
["settings.enabled", false],
33+
["settings.count", 0],
34+
["settings.tags", ["one", "two"]],
35+
["settings.empty", null]
36+
];
37+
for (const [key, value] of values) {
38+
await context.setSetting(key, JSON.stringify(value), { contentType: "application/json" });
39+
}
40+
41+
const settings = await load(endpoint, credential, {
42+
selectors: [{ keyFilter: context.key("*") }],
43+
trimKeyPrefixes: [context.keyPrefix]
44+
});
45+
46+
assert.sameDeepMembers([...settings], values);
47+
assert.deepEqual(settings.constructConfigurationObject(), {
48+
settings: {
49+
service,
50+
enabled: false,
51+
count: 0,
52+
tags: ["one", "two"],
53+
empty: null
54+
}
55+
});
56+
});
57+
});

0 commit comments

Comments
 (0)