From f8d2fb8eb71fdc9e50ef46daf6946cf7e0961b0f Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 16 Apr 2026 14:46:38 -0300 Subject: [PATCH] feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests --- feature-control-bff/README.md | 89 +++++++ feature-control-bff/core.mjs | 333 +++++++++++++++++++++++++ feature-control-bff/package.json | 10 + feature-control-bff/server.mjs | 34 +++ feature-control-bff/tests/bff.test.mjs | 275 ++++++++++++++++++++ 5 files changed, 741 insertions(+) create mode 100644 feature-control-bff/README.md create mode 100644 feature-control-bff/core.mjs create mode 100644 feature-control-bff/package.json create mode 100644 feature-control-bff/server.mjs create mode 100644 feature-control-bff/tests/bff.test.mjs diff --git a/feature-control-bff/README.md b/feature-control-bff/README.md new file mode 100644 index 0000000..ac6f863 --- /dev/null +++ b/feature-control-bff/README.md @@ -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 `, 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 +``` diff --git a/feature-control-bff/core.mjs b/feature-control-bff/core.mjs new file mode 100644 index 0000000..d5e3a22 --- /dev/null +++ b/feature-control-bff/core.mjs @@ -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" + }); + } +} diff --git a/feature-control-bff/package.json b/feature-control-bff/package.json new file mode 100644 index 0000000..6e8d9f9 --- /dev/null +++ b/feature-control-bff/package.json @@ -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" + } +} diff --git a/feature-control-bff/server.mjs b/feature-control-bff/server.mjs new file mode 100644 index 0000000..e8188cc --- /dev/null +++ b/feature-control-bff/server.mjs @@ -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}`); +}); diff --git a/feature-control-bff/tests/bff.test.mjs b/feature-control-bff/tests/bff.test.mjs new file mode 100644 index 0000000..98ba28d --- /dev/null +++ b/feature-control-bff/tests/bff.test.mjs @@ -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); + } +});