Files
melgarafaelandClaude Opus 5.5 ed1400f82d test(ia): a spec de tela prova o aviso de parar de receber e a contagem por mensagem
H8 — a primeira execução da spec (HEAD 2c0181eff) passou verde; o único
achado da tela foi "percebeu 0 pedidos", que o H1 troca por frase própria.
A spec passa a conferir, pela tela:
 - as frases novas do cartão (nenhuma / uma / duas mensagens, nos dois
   pedidos) e a frase do cartão observando;
 - o pedido de parar de receber em "Avisar a equipe": o diálogo ("o Jev nunca
   bloqueia ninguém"), o pedido natural de sair que a regra não reconhece
   ("apaguem meu telefone do cadastro" — medido com ehPedidoDeOptOut e
   ehOptOutProvavel reais) abrindo UM aviso na Central com a instrução
   ("…peça que ele responda PARAR…"), sem a frase do cliente, com "Abrir a
   conversa", e o contato sem bloqueio;
 - o H3 pelo caminho de produção: abrir a conversa pelo aviso, "Assumir" no
   cabeçalho, e o aviso de parar de receber segue aberto na Central.

O dublê ganha DUBLE_JEV_RESPOSTAS_POR_TRECHO (a resposta por mensagem, por
cima da geral): a mensagem de quem sai pede parar, e não uma pessoa. Unit do
dublê cobre o trecho, a precedência e o mapa ilegível.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:29:06 -03:00

195 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Dublê HTTP do Jev (System One, da TypeSafe AI) para o e2e.
*
* Servidor de verdade, em processo separado, pelo mesmo motivo do
* `scripts/duble-saas-e2e.mjs`: quem fala com o Jev é o servidor Next (o
* worker de clima dentro do dreno do event_log), não o processo do Playwright.
* Um intercept de `fetch` no teste deixaria o produto batendo na internet.
*
* Responde o formato REAL, medido contra a API em 2026-09-23 (o plano do Jev
* guarda as amostras):
* GET /v1/models → 200 { models: [...] } com o Bearer certo; 401 com
* outro; 403 sem nenhum. É o que valida a chave.
* POST /v1/systemone → 200 { model, answers, usage }. Para `score`, a
* posição na escala (0..n-1) vem de DUBLE_JEV_SCORE.
* DUBLE_JEV_RESPOSTAS (JSON, id da pergunta → resposta
* no formato do fornecedor) troca a resposta das
* perguntas que nomeia — é como a spec força uma
* discordância; as outras seguem o padrão acima.
* DUBLE_JEV_RESPOSTAS_POR_TRECHO (JSON, trecho da
* mensagem → { id da pergunta → resposta }) vale só
* para a chamada cujo `state` contém o trecho, e por
* cima das duas acima: uma spec com duas mensagens que
* pedem coisas diferentes responde cada uma.
*
* E GRAVA cada chamada num arquivo JSON (DUBLE_JEV_ARQUIVO), que a spec LÊ
* para provar que o Jev foi chamado e com o quê. Grava o corpo (é por ele que
* se prova que o telefone saiu apagado) e se o Bearer bateu — nunca o Bearer.
*
* GET /__duble/saude → { ok, porta, arquivo } — a spec confere que o
* dublê que respondeu é o dela, e não um que sobrou.
*
* Zero dependências. Uso:
* DUBLE_JEV_ARQUIVO=/tmp/chamadas.json node scripts/duble-jev-e2e.mjs
*/
import { writeFileSync } from "node:fs";
import http from "node:http";
/** 3996: vizinha de 3997 (dublê dos SaaS), 3998 (Redis HTTP) e 3999 (WAHA). */
const PORTA = Number(process.env.DUBLE_JEV_PORTA ?? 3996);
const HOST = process.env.DUBLE_JEV_HOST ?? "127.0.0.1";
const CHAVE = process.env.DUBLE_JEV_CHAVE ?? "apikey_e2e_duble_do_jev_0123456789abcdef";
const ARQUIVO = process.env.DUBLE_JEV_ARQUIVO ?? "";
/** Posição na escala de 5 níveis: 3 = "satisfeito" — longe do limiar de passagem. */
const SCORE = Number(process.env.DUBLE_JEV_SCORE ?? 3);
const MODELO = "jev-1.13.0";
if (!ARQUIVO) {
console.error("[duble-jev] DUBLE_JEV_ARQUIVO é obrigatório: é por ele que a spec prova a chamada");
process.exit(2);
}
/**
* Um mapa JSON de uma variável de ambiente. Ilegível derruba o dublê na subida:
* um mapa ignorado em silêncio faria a spec provar o padrão achando que provou
* a discordância.
*/
function lerMapa(variavel, oQue) {
const cru = process.env[variavel];
if (!cru) return {};
let lido;
try {
lido = JSON.parse(cru);
} catch {
lido = null;
}
if (lido === null || typeof lido !== "object" || Array.isArray(lido)) {
console.error(`[duble-jev] ${variavel} precisa ser um objeto JSON: ${oQue}`);
process.exit(2);
}
return lido;
}
const RESPOSTAS_FORCADAS = lerMapa("DUBLE_JEV_RESPOSTAS", "id da pergunta → resposta");
const RESPOSTAS_POR_TRECHO = lerMapa("DUBLE_JEV_RESPOSTAS_POR_TRECHO", "trecho da mensagem → { id da pergunta → resposta }");
/** @type {Array<{t: string, metodo: string, caminho: string, autorizado: boolean, corpo: unknown}>} */
const chamadas = [];
function gravar() {
writeFileSync(ARQUIVO, JSON.stringify(chamadas, null, 2));
}
gravar();
function responder(res, status, corpo) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(corpo));
}
async function lerCorpo(req) {
const pedacos = [];
for await (const p of req) pedacos.push(p);
const cru = Buffer.concat(pedacos).toString("utf8");
if (!cru) return undefined;
try {
return JSON.parse(cru);
} catch {
// Guardado como veio: formato novo do produto é o que o teste precisa ver.
return cru;
}
}
/** A resposta de uma pergunta, no formato do fornecedor. */
function responderPergunta(pergunta) {
if (pergunta?.type === "score") {
const niveis = Array.isArray(pergunta.criteria) ? pergunta.criteria : [];
const teto = Math.max(niveis.length - 1, 0);
const score = Math.min(Math.max(SCORE, 0), teto);
const legend = {};
const probabilities = {};
niveis.forEach((texto, i) => {
legend[String(i)] = texto;
probabilities[String(i)] = i === Math.round(score) ? 0.9 : 0.1 / Math.max(teto, 1);
});
return { type: "score", score, confidence: 0.9, legend, probabilities };
}
if (pergunta?.type === "choice") {
const opcoes = Object.keys(pergunta.criteria ?? {});
const probabilities = Object.fromEntries(opcoes.map((o, i) => [o, i === 0 ? 0.9 : 0.1]));
return { type: "choice", choice: opcoes[0] ?? "", confidence: 0.9, probabilities };
}
return { type: "noul", noul: 0.5 };
}
const servidor = http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? "/", `http://${HOST}:${PORTA}`);
const metodo = req.method ?? "GET";
if (url.pathname === "/__duble/saude") {
return responder(res, 200, { ok: true, porta: PORTA, arquivo: ARQUIVO, chamadas: chamadas.length });
}
const corpo = metodo === "POST" ? await lerCorpo(req) : undefined;
const bearer = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? "")?.[1] ?? null;
const autorizado = bearer === CHAVE;
chamadas.push({ t: new Date().toISOString(), metodo, caminho: url.pathname, autorizado, corpo });
gravar();
// A API real devolve 403 sem chave e 401 com chave errada — os dois viram
// "chave recusada" no produto, e o dublê não pode ser mais permissivo que ela.
if (bearer === null) return responder(res, 403, { detail: "Not authenticated" });
if (!autorizado) {
return responder(res, 401, {
detail: { error_type: "authentication_error", message: "Invalid API key" },
});
}
if (metodo === "GET" && url.pathname === "/v1/models") {
return responder(res, 200, {
models: [{ name: MODELO, description: "dublê do e2e", release_date: "2026-09-01" }],
});
}
if (metodo === "POST" && url.pathname === "/v1/systemone") {
const perguntas = corpo && typeof corpo === "object" ? (corpo.questions ?? {}) : {};
const estado = typeof corpo?.state === "string" ? corpo.state : JSON.stringify(corpo?.state ?? "");
const doTrecho = Object.entries(RESPOSTAS_POR_TRECHO).find(([trecho]) => estado.includes(trecho))?.[1] ?? {};
const answers = Object.fromEntries(
Object.entries(perguntas).map(([id, p]) => [
id,
Object.hasOwn(doTrecho, id)
? doTrecho[id]
: Object.hasOwn(RESPOSTAS_FORCADAS, id)
? RESPOSTAS_FORCADAS[id]
: responderPergunta(p),
]),
);
return responder(res, 200, {
model: MODELO,
answers,
usage: { input_tokens: Math.max(1, Math.ceil(estado.length / 4)), output_tokens: 1 },
});
}
return responder(res, 404, { error_type: "not_found", message: `rota desconhecida: ${url.pathname}` });
} catch (erro) {
// O detalhe fica no log deste processo (a spec imprime a saída do dublê);
// a resposta HTTP não carrega mensagem nem pilha de erro.
console.error("[duble-jev] falhou ao responder:", erro);
return responder(res, 500, { error_type: "erro_no_duble", message: "o dublê falhou — veja o log dele" });
}
});
servidor.listen(PORTA, HOST, () => {
console.info(`[duble-jev] ouvindo em http://${HOST}:${PORTA} — chamadas em ${ARQUIVO}`);
});
// Sai na hora, sem `servidor.close()`: ele espera as conexões keep-alive do
// servidor Next fecharem, e o dublê seguiria preso à porta depois da spec. Não
// há o que perder — cada chamada já foi gravada no arquivo, de forma síncrona.
for (const sinal of ["SIGINT", "SIGTERM"]) {
process.on(sinal, () => process.exit(0));
}