feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests
This commit is contained in:
89
feature-control-bff/README.md
Normal file
89
feature-control-bff/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Feature Control BFF
|
||||
|
||||
BFF mínimo para expor flags ao app sem expor `Atomenta-Token` no cliente.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- `POST /feature-control/bootstrap`
|
||||
- `POST /feature-control/telemetry/exposure`
|
||||
- `GET /health`
|
||||
|
||||
## Variáveis de ambiente
|
||||
|
||||
- `PORT` (default: `8787`)
|
||||
- `ATOMENTA_ORIGIN` (default: `https://atomenta.com.br`)
|
||||
- `FEATURE_CONTROL_MODULE_TOKEN` (ou `ATOMENTA_FEATURE_CONTROL_MODULE_ID`) **obrigatório**
|
||||
- `FEATURE_CONTROL_ENVIRONMENT` (default: `production`)
|
||||
- `FEATURE_CONTROL_CACHE_TTL_MS` (default: `60000`)
|
||||
- `FEATURE_CONTROL_TIMEOUT_MS` (default: `2000`)
|
||||
- `FEATURE_CONTROL_DEFAULTS_JSON` (opcional, JSON com defaults de fallback)
|
||||
|
||||
Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
|
||||
|
||||
```json
|
||||
{
|
||||
"fc.checkout_v2": { "enabled": false, "variant": "off" },
|
||||
"fc.search_ranking_v3": { "enabled": true, "variant": "on", "payload": { "model": "v3" } }
|
||||
}
|
||||
```
|
||||
|
||||
## Request (App -> BFF)
|
||||
|
||||
```json
|
||||
{
|
||||
"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": "Belo Horizonte",
|
||||
"tier": "gold"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` do JWT (`sub`/`customerId`/`id`) quando `context.subjectId` não vier.
|
||||
|
||||
## Response (BFF -> App)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "live",
|
||||
"configVersion": 7,
|
||||
"evaluatedAt": "2026-04-16T12:00:00.000Z",
|
||||
"flags": {
|
||||
"checkoutV2": true,
|
||||
"searchRankingV3": "off"
|
||||
},
|
||||
"raw": {
|
||||
"fc.checkout_v2": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
"reason": "rollout"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos.
|
||||
|
||||
## Execução local
|
||||
|
||||
```bash
|
||||
cd feature-control-bff
|
||||
FEATURE_CONTROL_MODULE_TOKEN="550e8400-e29b-41d4-a716-44665544000b" npm start
|
||||
```
|
||||
|
||||
## Testes
|
||||
|
||||
```bash
|
||||
cd feature-control-bff
|
||||
npm test
|
||||
```
|
||||
333
feature-control-bff/core.mjs
Normal file
333
feature-control-bff/core.mjs
Normal file
@@ -0,0 +1,333 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function loadConfig(env = process.env) {
|
||||
return {
|
||||
atomentaOrigin: (env.ATOMENTA_ORIGIN || "https://atomenta.com.br").replace(/\/+$/, ""),
|
||||
moduleToken: env.FEATURE_CONTROL_MODULE_TOKEN || env.ATOMENTA_FEATURE_CONTROL_MODULE_ID || "",
|
||||
defaultEnvironment: env.FEATURE_CONTROL_ENVIRONMENT || "production",
|
||||
cacheTtlMs: Number(env.FEATURE_CONTROL_CACHE_TTL_MS || 60_000),
|
||||
requestTimeoutMs: Number(env.FEATURE_CONTROL_TIMEOUT_MS || 2_000),
|
||||
staticDefaults: parseDefaults(env.FEATURE_CONTROL_DEFAULTS_JSON)
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntime(config) {
|
||||
return { cache: new Map(), inFlight: new Map(), config };
|
||||
}
|
||||
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function writeJson(res, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Cache-Control": "no-store"
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 128 * 1024;
|
||||
const MAX_KEYS = 100;
|
||||
const MAX_ATTRIBUTES_BYTES = 16 * 1024;
|
||||
const MAX_EXPOSURE_EVENTS = 100;
|
||||
|
||||
function parseDefaults(raw) {
|
||||
if (!raw) return {};
|
||||
const parsed = safeParseJson(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeParseJson(input) {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function getSubjectFromAuthHeader(authHeader) {
|
||||
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) return null;
|
||||
const token = authHeader.slice(7).trim();
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
try {
|
||||
const payloadBase64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = payloadBase64 + "=".repeat((4 - (payloadBase64.length % 4)) % 4);
|
||||
const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf-8"));
|
||||
const subjectId = normalizeString(payload.sub) || normalizeString(payload.customerId) || normalizeString(payload.id);
|
||||
if (!subjectId) return null;
|
||||
return { subjectType: "customer", subjectId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCacheKey(input) {
|
||||
const stable = JSON.stringify({
|
||||
env: input.environment,
|
||||
keys: input.keys,
|
||||
context: {
|
||||
subjectType: input.context.subjectType,
|
||||
subjectId: input.context.subjectId,
|
||||
storeId: input.context.storeId || "",
|
||||
platform: input.context.platform || "",
|
||||
appVersion: input.context.appVersion || "",
|
||||
attributes: input.context.attributes || {}
|
||||
}
|
||||
});
|
||||
return crypto.createHash("sha256").update(stable).digest("hex");
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
throw Object.assign(new Error("Payload muito grande"), { status: 413, code: "PAYLOAD_TOO_LARGE" });
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf-8");
|
||||
}
|
||||
|
||||
function validateBootstrapRequest(body, authHeader, config) {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" });
|
||||
}
|
||||
|
||||
const keys = Array.isArray(body.keys) ? body.keys.map(normalizeString).filter(Boolean) : [];
|
||||
if (!keys.length) throw Object.assign(new Error("`keys` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_KEYS_REQUIRED" });
|
||||
if (keys.length > MAX_KEYS) {
|
||||
throw Object.assign(new Error(`Máximo de ${MAX_KEYS} chaves por request`), { status: 400, code: "FEATURE_CONTROL_TOO_MANY_KEYS" });
|
||||
}
|
||||
|
||||
const environment = normalizeString(body.environment) || config.defaultEnvironment;
|
||||
const contextInput = body.context && typeof body.context === "object" ? body.context : {};
|
||||
const fromJwt = getSubjectFromAuthHeader(authHeader);
|
||||
const subjectType = normalizeString(contextInput.subjectType) || fromJwt?.subjectType || "customer";
|
||||
const subjectId = normalizeString(contextInput.subjectId) || fromJwt?.subjectId || "";
|
||||
if (!subjectId) {
|
||||
throw Object.assign(new Error("`context.subjectId` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_ID_REQUIRED" });
|
||||
}
|
||||
|
||||
const allowed = new Set(["user", "customer", "store", "anonymous"]);
|
||||
if (!allowed.has(subjectType)) {
|
||||
throw Object.assign(new Error("`context.subjectType` inválido"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_TYPE_INVALID" });
|
||||
}
|
||||
|
||||
const attributes = contextInput.attributes && typeof contextInput.attributes === "object" && !Array.isArray(contextInput.attributes)
|
||||
? contextInput.attributes
|
||||
: {};
|
||||
|
||||
if (Buffer.byteLength(JSON.stringify(attributes), "utf-8") > MAX_ATTRIBUTES_BYTES) {
|
||||
throw Object.assign(new Error("`context.attributes` excede limite"), { status: 400, code: "FEATURE_CONTROL_ATTRIBUTES_TOO_LARGE" });
|
||||
}
|
||||
|
||||
return {
|
||||
environment,
|
||||
keys: [...new Set(keys)].sort(),
|
||||
context: {
|
||||
subjectType,
|
||||
subjectId,
|
||||
storeId: normalizeString(contextInput.storeId) || undefined,
|
||||
platform: normalizeString(contextInput.platform) || undefined,
|
||||
appVersion: normalizeString(contextInput.appVersion) || undefined,
|
||||
attributes
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function validateExposureRequest(body) {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" });
|
||||
}
|
||||
if (!Array.isArray(body.events) || body.events.length === 0) {
|
||||
throw Object.assign(new Error("`events` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EVENTS_REQUIRED" });
|
||||
}
|
||||
if (body.events.length > MAX_EXPOSURE_EVENTS) {
|
||||
throw Object.assign(new Error(`Máximo de ${MAX_EXPOSURE_EVENTS} eventos por request`), {
|
||||
status: 400,
|
||||
code: "FEATURE_CONTROL_EXPOSURE_TOO_MANY_EVENTS"
|
||||
});
|
||||
}
|
||||
|
||||
const events = body.events.map((event) => ({
|
||||
featureKey: normalizeString(event?.featureKey),
|
||||
variant: normalizeString(event?.variant) || "off",
|
||||
subjectType: normalizeString(event?.subjectType) || "customer",
|
||||
storeId: normalizeString(event?.storeId) || undefined
|
||||
}));
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.featureKey) {
|
||||
throw Object.assign(new Error("`featureKey` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EXPOSURE_FEATURE_KEY_REQUIRED" });
|
||||
}
|
||||
}
|
||||
return { events };
|
||||
}
|
||||
|
||||
function buildFallbackFlags(keys, config) {
|
||||
const raw = {};
|
||||
const simplified = {};
|
||||
|
||||
for (const key of keys) {
|
||||
const fromEnv = config.staticDefaults[key];
|
||||
const enabled = Boolean(fromEnv?.enabled);
|
||||
const variant = typeof fromEnv?.variant === "string" ? fromEnv.variant : enabled ? "on" : "off";
|
||||
raw[key] = { enabled, variant, payload: fromEnv?.payload ?? null, reason: "fallback_default" };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, "");
|
||||
if (!simplified[simplifiedKey]) {
|
||||
simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant;
|
||||
}
|
||||
}
|
||||
|
||||
return { raw, simplified };
|
||||
}
|
||||
|
||||
function mapUpstreamError(error) {
|
||||
return {
|
||||
status: Number(error?.status || 500),
|
||||
code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR",
|
||||
message: normalizeString(error?.message) || "Erro ao avaliar flags"
|
||||
};
|
||||
}
|
||||
|
||||
async function callAtomenta(path, requestPayload, config) {
|
||||
if (!config.moduleToken) {
|
||||
throw Object.assign(new Error("Token de módulo não configurado"), { status: 500, code: "FEATURE_CONTROL_MODULE_TOKEN_MISSING" });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.atomentaOrigin}${path}`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Atomenta-Token": config.moduleToken
|
||||
},
|
||||
body: JSON.stringify(requestPayload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const parsed = safeParseJson(text) || {};
|
||||
if (!response.ok) {
|
||||
const upstreamCode = parsed.code || "FEATURE_CONTROL_UPSTREAM_ERROR";
|
||||
const upstreamMessage = parsed.message || `Erro upstream (${response.status})`;
|
||||
throw Object.assign(new Error(upstreamMessage), { status: response.status, code: upstreamCode });
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") {
|
||||
throw Object.assign(new Error("Timeout no upstream"), { status: 504, code: "FEATURE_CONTROL_UPSTREAM_TIMEOUT" });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBffResponse(evaluated, requestPayload, source = "live") {
|
||||
const result = evaluated?.result || {};
|
||||
const flags = result.flags && typeof result.flags === "object" ? result.flags : {};
|
||||
const simplified = {};
|
||||
for (const key of requestPayload.keys) {
|
||||
const value = flags[key];
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, "");
|
||||
if (!simplified[simplifiedKey]) simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant || "off";
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
source,
|
||||
configVersion: Number(result.configVersion || 0),
|
||||
evaluatedAt: result.evaluatedAt || nowIso(),
|
||||
flags: simplified,
|
||||
raw: flags
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateWithCache(requestPayload, runtime) {
|
||||
const cacheKey = buildCacheKey(requestPayload);
|
||||
const current = runtime.cache.get(cacheKey);
|
||||
if (current && current.expiresAt > Date.now()) return current.payload;
|
||||
|
||||
const inflight = runtime.inFlight.get(cacheKey);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const work = (async () => {
|
||||
const evaluated = await callAtomenta("/api/feature-control/evaluate", requestPayload, runtime.config);
|
||||
const payload = formatBffResponse(evaluated, requestPayload, "live");
|
||||
runtime.cache.set(cacheKey, { payload, expiresAt: Date.now() + runtime.config.cacheTtlMs });
|
||||
return payload;
|
||||
})();
|
||||
runtime.inFlight.set(cacheKey, work);
|
||||
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
runtime.inFlight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBootstrap(req, res, runtime) {
|
||||
try {
|
||||
const body = safeParseJson(await readBody(req));
|
||||
if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" });
|
||||
const requestPayload = validateBootstrapRequest(body, req.headers.authorization, runtime.config);
|
||||
try {
|
||||
const payload = await evaluateWithCache(requestPayload, runtime);
|
||||
return writeJson(res, 200, payload);
|
||||
} catch (error) {
|
||||
const mapped = mapUpstreamError(error);
|
||||
const fallback = buildFallbackFlags(requestPayload.keys, runtime.config);
|
||||
return writeJson(res, mapped.status === 400 ? 400 : 200, {
|
||||
ok: mapped.status === 400 ? false : true,
|
||||
source: "fallback",
|
||||
configVersion: 0,
|
||||
evaluatedAt: nowIso(),
|
||||
flags: fallback.simplified,
|
||||
raw: fallback.raw,
|
||||
upstreamError: mapped
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return writeJson(res, Number(error?.status || 500), {
|
||||
ok: false,
|
||||
code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR",
|
||||
message: normalizeString(error?.message) || "Erro interno"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleExposure(req, res, runtime) {
|
||||
try {
|
||||
const body = safeParseJson(await readBody(req));
|
||||
if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" });
|
||||
const requestPayload = validateExposureRequest(body);
|
||||
const response = await callAtomenta("/api/feature-control/telemetry/exposure", requestPayload, runtime.config);
|
||||
return writeJson(res, 200, { ok: true, code: "FEATURE_CONTROL_EXPOSURE_ACCEPTED", result: response?.result ?? null });
|
||||
} catch (error) {
|
||||
return writeJson(res, Number(error?.status || 500), {
|
||||
ok: false,
|
||||
code: normalizeString(error?.code) || "FEATURE_CONTROL_EXPOSURE_ERROR",
|
||||
message: normalizeString(error?.message) || "Falha ao enviar exposição"
|
||||
});
|
||||
}
|
||||
}
|
||||
10
feature-control-bff/package.json
Normal file
10
feature-control-bff/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "feature-control-bff",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
34
feature-control-bff/server.mjs
Normal file
34
feature-control-bff/server.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import http from "node:http";
|
||||
import { createRuntime, handleBootstrap, handleExposure, loadConfig, nowIso, writeJson } from "./core.mjs";
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const runtime = createRuntime(loadConfig(process.env));
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method || "GET";
|
||||
const url = req.url || "/";
|
||||
|
||||
if (method === "GET" && url === "/health") {
|
||||
return writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: "feature-control-bff",
|
||||
time: nowIso(),
|
||||
cacheEntries: runtime.cache.size,
|
||||
inFlight: runtime.inFlight.size
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/feature-control/bootstrap") {
|
||||
return handleBootstrap(req, res, runtime);
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/feature-control/telemetry/exposure") {
|
||||
return handleExposure(req, res, runtime);
|
||||
}
|
||||
|
||||
return writeJson(res, 404, { ok: false, code: "NOT_FOUND", message: "Rota não encontrada" });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[feature-control-bff] listening on :${PORT}`);
|
||||
});
|
||||
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