OmniRoute/tests/unit/cli-exit-codes.test.ts
diegosouzapw 2e494f8f07 feat(cli): Fase 0.3 — helpers base + convenções (api, i18n, output, runtime)
- bin/cli/CONVENTIONS.md: fonte normativa de flags, exit codes, output,
  retry/backoff, i18n, secrets, auditoria de ações destrutivas
- bin/cli/api.mjs: apiFetch() com retry/backoff, Retry-After, ApiError,
  statusToExitCode, isServerUp; computeBackoff/shouldRetryStatus exportados
- bin/cli/runtime.mjs: withRuntime/withHttp/withDb — server-first / DB-fallback;
  ServerOfflineError com exitCode 3
- bin/cli/i18n.mjs: t() com Map achatado (sem bracket em prototype), interpolação
  {vars}, setLocale/detectLocale/resetForTests; hardened contra __proto__ traversal
- bin/cli/output.mjs: emit() (table/json/jsonl/csv), EXIT_CODES, maskSecret,
  printSuccess/printError/printWarning/exitWith; output → stdout, diagnóstico → stderr
- bin/cli/locales/en.json + pt-BR.json: strings base (setup/doctor/providers/
  keys/combo/serve/backup/update/health/mcp/tunnel)
- bin/cli/README.md: mapa da estrutura e guia de uso dos helpers
- tests/unit/cli-exit-codes.test.ts: 10 casos — EXIT_CODES, statusToExitCode,
  backoff exponencial, jitter ±25%, t() i18n com pt-BR e anti-__proto__
- .env.example + docs/reference/ENVIRONMENT.md: documentar 4 novas env vars CLI
  (OMNIROUTE_LANG, OMNIROUTE_CLI_TOKEN, OMNIROUTE_HTTP_TIMEOUT_MS, OMNIROUTE_VERBOSE)
- scripts/check/check-env-doc-sync.mjs: adicionar LC_MESSAGES ao allowlist de sistema
2026-05-14 21:42:57 -03:00

110 lines
4.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { EXIT_CODES } from "../../bin/cli/output.mjs";
import { statusToExitCode, computeBackoff, RETRY_DEFAULTS } from "../../bin/cli/api.mjs";
import { t, resetForTests, setLocale } from "../../bin/cli/i18n.mjs";
// ─── exit code constants ──────────────────────────────────────────────────────
test("EXIT_CODES has expected values", () => {
assert.equal(EXIT_CODES.SUCCESS, 0);
assert.equal(EXIT_CODES.ERROR, 1);
assert.equal(EXIT_CODES.INVALID_ARG, 2);
assert.equal(EXIT_CODES.SERVER_OFFLINE, 3);
assert.equal(EXIT_CODES.AUTH, 4);
assert.equal(EXIT_CODES.RATE_LIMIT, 5);
assert.equal(EXIT_CODES.TIMEOUT, 124);
});
// ─── statusToExitCode mapping ────────────────────────────────────────────────
test("statusToExitCode maps HTTP statuses correctly", () => {
assert.equal(statusToExitCode(200), 0, "200 → 0");
assert.equal(statusToExitCode(201), 0, "201 → 0");
assert.equal(statusToExitCode(204), 0, "204 → 0");
assert.equal(statusToExitCode(400), 2, "400 → 2 (bad arg)");
assert.equal(statusToExitCode(401), 4, "401 → 4 (auth)");
assert.equal(statusToExitCode(403), 4, "403 → 4 (auth)");
assert.equal(statusToExitCode(404), 2, "404 → 2 (not found)");
assert.equal(statusToExitCode(408), 124, "408 → 124 (timeout)");
assert.equal(statusToExitCode(422), 2, "422 → 2 (validation)");
assert.equal(statusToExitCode(429), 5, "429 → 5 (rate limit)");
assert.equal(statusToExitCode(500), 1, "500 → 1 (server error)");
assert.equal(statusToExitCode(502), 1, "502 → 1 (gateway)");
assert.equal(statusToExitCode(503), 1, "503 → 1 (unavailable)");
assert.equal(statusToExitCode(504), 1, "504 → 1 (gateway timeout)");
});
// ─── retry backoff ────────────────────────────────────────────────────────────
test("computeBackoff respects Retry-After header", () => {
const delay = computeBackoff(1, "10");
assert.ok(delay <= RETRY_DEFAULTS.maxMs, "capped at maxMs");
assert.ok(delay <= 10_000, "respects 10s header");
assert.ok(delay > 0, "positive delay");
});
test("computeBackoff grows exponentially without header", () => {
const d1 = computeBackoff(1, null, { ...RETRY_DEFAULTS, jitter: false });
const d2 = computeBackoff(2, null, { ...RETRY_DEFAULTS, jitter: false });
const d3 = computeBackoff(3, null, { ...RETRY_DEFAULTS, jitter: false });
assert.ok(d2 > d1, "attempt 2 > attempt 1");
assert.ok(d3 >= d2, "attempt 3 >= attempt 2 (may cap)");
assert.ok(d3 <= RETRY_DEFAULTS.maxMs, "capped at maxMs");
});
test("computeBackoff with jitter stays within ±25% of base", () => {
const base = computeBackoff(1, null, { ...RETRY_DEFAULTS, jitter: false });
for (let i = 0; i < 20; i++) {
const jittered = computeBackoff(1, null, RETRY_DEFAULTS);
const tolerance = base * 0.25 + 1;
assert.ok(jittered >= base - tolerance, `jitter too low (${jittered} vs ${base})`);
assert.ok(jittered <= base + tolerance, `jitter too high (${jittered} vs ${base})`);
}
});
// ─── i18n ────────────────────────────────────────────────────────────────────
test("t() returns key for missing locale entry", () => {
resetForTests();
setLocale("en");
const result = t("nonexistent.key.that.does.not.exist");
assert.equal(result, "nonexistent.key.that.does.not.exist");
});
test("t() interpolates variables", () => {
resetForTests();
setLocale("en");
const result = t("common.error", { message: "disk full" });
assert.ok(result.includes("disk full"), `got: ${result}`);
});
test("t() falls back to en for unknown locale", () => {
resetForTests();
setLocale("xx-UNKNOWN");
const result = t("common.success");
assert.ok(result.length > 0 && result !== "common.success", `fallback failed: ${result}`);
});
test("t() supports pt-BR locale", () => {
resetForTests();
setLocale("pt-BR");
const en = (() => {
resetForTests();
setLocale("en");
return t("common.serverOffline");
})();
resetForTests();
setLocale("pt-BR");
const ptBR = t("common.serverOffline");
assert.notEqual(en, ptBR, "pt-BR should differ from en");
assert.ok(ptBR.length > 0 && ptBR !== "common.serverOffline");
});
test("t() does not expose __proto__ traversal", () => {
resetForTests();
setLocale("en");
const result = t("__proto__.polluted");
assert.equal(result, "__proto__.polluted", "should return key unchanged");
});