feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests
This commit is contained in:
275
feature-control-bff/tests/bff.test.mjs
Normal file
275
feature-control-bff/tests/bff.test.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
async function waitForHealth(url, timeoutMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`${url}/health`);
|
||||
if (response.ok) return;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("BFF health timeout");
|
||||
}
|
||||
|
||||
async function startBff(env) {
|
||||
const child = spawn("node", ["server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
const logs = [];
|
||||
child.stdout.on("data", (chunk) => logs.push(chunk.toString("utf-8")));
|
||||
child.stderr.on("data", (chunk) => logs.push(chunk.toString("utf-8")));
|
||||
|
||||
const port = Number(env.PORT);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await waitForHealth(baseUrl);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
logs,
|
||||
stop: async () => {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => child.on("exit", () => resolve()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createUpstreamStub(options = {}) {
|
||||
const state = {
|
||||
evaluateMode: options.evaluateMode || "success",
|
||||
evaluatePayload: null,
|
||||
exposurePayload: null
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === "POST" && req.url === "/api/feature-control/evaluate") {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
state.evaluatePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
||||
|
||||
if (state.evaluateMode === "429") {
|
||||
res.writeHead(429, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: "FEATURE_CONTROL_RATE_LIMIT", message: "rate" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.evaluateMode === "500") {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ code: "FEATURE_CONTROL_ERROR", message: "boom" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.evaluateMode === "timeout") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: false,
|
||||
code: "FEATURE_CONTROL_EVALUATED",
|
||||
result: {
|
||||
evaluatedAt: "2026-04-16T12:00:00.000Z",
|
||||
configVersion: 7,
|
||||
flags: {
|
||||
"fc.checkout_v2": { enabled: true, variant: "on", payload: null, reason: "rollout" },
|
||||
"fc.search_ranking_v3": { enabled: false, variant: "off", payload: null, reason: "default" }
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/feature-control/telemetry/exposure") {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
state.exposurePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: false, code: "FEATURE_CONTROL_EXPOSURE_TRACKED", result: { accepted: true } }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
return { server, state };
|
||||
}
|
||||
|
||||
function bootstrapBody() {
|
||||
return {
|
||||
environment: "production",
|
||||
keys: ["fc.checkout_v2", "fc.search_ranking_v3"],
|
||||
context: {
|
||||
subjectType: "customer",
|
||||
subjectId: "cust_123",
|
||||
storeId: "store_001",
|
||||
platform: "ios",
|
||||
appVersion: "2.3.1",
|
||||
attributes: { city: "Campinas" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("bootstrap sucesso retorna flags simplificadas e raw", async () => {
|
||||
const upstream = createUpstreamStub({ evaluateMode: "success" });
|
||||
const upstreamPort = await listen(upstream.server);
|
||||
const bffPort = upstreamPort + 1000;
|
||||
const bff = await startBff({
|
||||
PORT: String(bffPort),
|
||||
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||
FEATURE_CONTROL_MODULE_TOKEN: "token",
|
||||
FEATURE_CONTROL_TIMEOUT_MS: "300"
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bootstrapBody())
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const json = await response.json();
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.configVersion, 7);
|
||||
assert.equal(json.flags.checkoutV2, true);
|
||||
assert.equal(json.flags.searchRankingV3, "off");
|
||||
assert.ok(json.raw["fc.checkout_v2"]);
|
||||
assert.equal(upstream.state.evaluatePayload.context.subjectId, "cust_123");
|
||||
} finally {
|
||||
await bff.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("bootstrap com 429 devolve fallback sem quebrar cliente", async () => {
|
||||
const upstream = createUpstreamStub({ evaluateMode: "429" });
|
||||
const upstreamPort = await listen(upstream.server);
|
||||
const bffPort = upstreamPort + 1000;
|
||||
const bff = await startBff({
|
||||
PORT: String(bffPort),
|
||||
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||
FEATURE_CONTROL_MODULE_TOKEN: "token",
|
||||
FEATURE_CONTROL_DEFAULTS_JSON: JSON.stringify({
|
||||
"fc.checkout_v2": { enabled: false, variant: "off" }
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bootstrapBody())
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const json = await response.json();
|
||||
assert.equal(json.source, "fallback");
|
||||
assert.equal(json.upstreamError.status, 429);
|
||||
assert.equal(json.flags.checkoutV2, "off");
|
||||
} finally {
|
||||
await bff.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("bootstrap com timeout devolve fallback", async () => {
|
||||
const upstream = createUpstreamStub({ evaluateMode: "timeout" });
|
||||
const upstreamPort = await listen(upstream.server);
|
||||
const bffPort = upstreamPort + 1000;
|
||||
const bff = await startBff({
|
||||
PORT: String(bffPort),
|
||||
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||
FEATURE_CONTROL_MODULE_TOKEN: "token",
|
||||
FEATURE_CONTROL_TIMEOUT_MS: "100"
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bootstrapBody())
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const json = await response.json();
|
||||
assert.equal(json.source, "fallback");
|
||||
assert.equal(json.upstreamError.code, "FEATURE_CONTROL_UPSTREAM_TIMEOUT");
|
||||
} finally {
|
||||
await bff.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("telemetria de exposure encaminha lote para upstream", async () => {
|
||||
const upstream = createUpstreamStub({ evaluateMode: "success" });
|
||||
const upstreamPort = await listen(upstream.server);
|
||||
const bffPort = upstreamPort + 1000;
|
||||
const bff = await startBff({
|
||||
PORT: String(bffPort),
|
||||
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||
FEATURE_CONTROL_MODULE_TOKEN: "token"
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
events: [
|
||||
{ featureKey: "fc.checkout_v2", variant: "on", subjectType: "customer", storeId: "store_1" }
|
||||
]
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const json = await response.json();
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(upstream.state.exposurePayload.events[0].featureKey, "fc.checkout_v2");
|
||||
} finally {
|
||||
await bff.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("telemetria rejeita payload sem eventos", async () => {
|
||||
const upstream = createUpstreamStub({ evaluateMode: "success" });
|
||||
const upstreamPort = await listen(upstream.server);
|
||||
const bffPort = upstreamPort + 1000;
|
||||
const bff = await startBff({
|
||||
PORT: String(bffPort),
|
||||
ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`,
|
||||
FEATURE_CONTROL_MODULE_TOKEN: "token"
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: [] })
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
const json = await response.json();
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.code, "FEATURE_CONTROL_EVENTS_REQUIRED");
|
||||
} finally {
|
||||
await bff.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user