Files
nav-carte/scripts/test-circuit-breaker.mjs
T
Jules NenyandClaude Opus 5.5 6ac5a2bdf7 fix(budget): le circuit breaker des chatbots lit enfin stats_usage
checkBudget filtrait stats_usage sur `timestamp` (where + fields) et
recevait un 422 à chaque appel : fail-open silencieux, budget jamais
vérifié côté chatbot. Aligné sur le worker : aucune colonne nommée dans
la requête, lecture paginée, mois filtré en JS (timestamp, puis
CreatedAt, puis created_at). Chaque lecture journalise le budget lu ;
un échec journalise « budget NON vérifié » et renvoie verified=false.

Test : scripts/test-circuit-breaker.mjs, faux NocoDB qui rejette en 422
toute colonne absente (15/15 ; 11 échecs si le filtre revient).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:19:06 +02:00

170 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Test du circuit breaker budget (server/utils/circuitBreaker.ts), AF5.
* Charge le VRAI module TS via jiti (comme test-submit-libre.mjs).
*
* Le faux NocoDB ci-dessous se comporte comme le vrai sur le point qui a cassé :
* toute colonne nommée dans `where`, `fields` ou `sort` et absente du schéma
* renvoie un 422. Réintroduire un filtre sur un champ absent fait donc échouer
* le cas « schéma sans timestamp ».
* Usage : node scripts/test-circuit-breaker.mjs
*/
import { createJiti } from 'jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const { checkBudget, BUDGET_MAX_EUR } = await jiti.import(
join(__dirname, '..', 'server', 'utils', 'circuitBreaker.ts'),
)
const NOW = new Date('2026-09-29T10:00:00Z')
/** Colonnes référencées par une requête NocoDB v2 (where, fields, sort). */
function colonnesReferencees(query) {
const cols = []
if (query.where) {
for (const m of String(query.where).matchAll(/\(([^,()]+),/g)) cols.push(m[1].trim())
}
if (query.fields) cols.push(...String(query.fields).split(',').map((s) => s.trim()))
if (query.sort) cols.push(...String(query.sort).split(',').map((s) => s.trim().replace(/^-/, '')))
return cols.filter(Boolean)
}
/** Faux NocoDB : schéma de colonnes + lignes ; 422 sur colonne inconnue ; pagination limit/offset. */
function fauxNocodb({ schema, rows, panne = false }) {
const appels = []
const fetchJson = async (url, { headers, query }) => {
appels.push({ url, query })
if (panne) throw Object.assign(new Error('[GET] 500 Internal Server Error'), { statusCode: 500 })
if (headers['xc-token'] !== 'tok') throw Object.assign(new Error('401'), { statusCode: 401 })
const inconnues = colonnesReferencees(query).filter((c) => !schema.includes(c))
if (inconnues.length) {
throw Object.assign(new Error(`[GET] 422 Field '${inconnues[0]}' not found`), { statusCode: 422 })
}
const limit = Number(query.limit ?? 25)
const offset = Number(query.offset ?? 0)
const list = rows.slice(offset, offset + limit)
return { list, pageInfo: { isLastPage: offset + limit >= rows.length } }
}
return { fetchJson, appels }
}
const base = { nocodbUrl: 'http://noco', nocodbToken: 'tok', statsTableId: 'mbbq7n47ixy19mc', now: NOW }
// Schéma supposé de la prod : pas de colonne `timestamp`, date système `CreatedAt`.
const SCHEMA_SANS_TS = ['Id', 'model', 'endpoint', 'tokens_in', 'tokens_out', 'cout_eur', 'orga_id', 'CreatedAt', 'UpdatedAt']
const SCHEMA_AVEC_TS = [...SCHEMA_SANS_TS, 'timestamp']
const results = []
function check(name, cond, detail = '') {
results.push({ name, ok: !!cond, detail })
}
// Journal capturé pour vérifier la ligne de budget
const logs = []
const origInfo = console.info
const origWarn = console.warn
console.info = (...a) => logs.push(['info', a.join(' ')])
console.warn = (...a) => logs.push(['warn', a.join(' ')])
try {
// 0. Le faux NocoDB rejette bien l'ancienne requête (sinon le test ne prouverait rien)
{
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [] })
let status = null
try {
await fetchJson('x', {
headers: { 'xc-token': 'tok' },
query: { where: '(timestamp,gte,2026-09-01T00:00:00.000Z)', limit: 1000, fields: 'cout_eur,timestamp' },
})
} catch (e) {
status = e.statusCode
}
check('faux NocoDB : ancienne requête (where/fields sur timestamp) → 422', status === 422, `statut ${status}`)
}
// 1. Schéma sans timestamp : lu, mois courant seul, aucune colonne nommée
{
const rows = [
{ Id: 1, cout_eur: 0.5, CreatedAt: '2026-09-03T08:00:00.000Z' },
{ Id: 2, cout_eur: '1.25', CreatedAt: '2026-09-28 22:10:00+00:00' },
{ Id: 3, cout_eur: 7, CreatedAt: '2026-08-31T23:59:00.000Z' }, // mois précédent
{ Id: 4, cout_eur: null, CreatedAt: '2026-09-10T00:00:00.000Z' },
]
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('sans timestamp : budget vérifié (pas de 422)', s.verified === true, JSON.stringify(s))
check('sans timestamp : cumul = 1,75 € (août exclu)', Math.abs(s.cumulEur - 1.75) < 1e-9, `cumul ${s.cumulEur}`)
check('sans timestamp : 3 lignes du mois sur 4', s.lignesMois === 3 && s.lignesLues === 4)
check('aucune colonne nommée dans la requête', appels.every((a) => colonnesReferencees(a.query).length === 0),
JSON.stringify(appels.map((a) => a.query)))
const ligne = logs.find(([niv, t]) => niv === 'info' && t.includes('[circuitBreaker] budget lu'))
check('journal : ligne « budget lu » avec le cumul', ligne && ligne[1].includes('1.7500 €'), ligne?.[1] ?? 'absente')
}
// 2. Schéma avec timestamp : timestamp prioritaire sur CreatedAt (ordre du worker)
{
const rows = [
{ Id: 1, cout_eur: 2, timestamp: '2026-09-15T12:00:00.000Z', CreatedAt: '2026-08-15T12:00:00.000Z' },
{ Id: 2, cout_eur: 3, timestamp: '2026-08-15T12:00:00.000Z', CreatedAt: '2026-09-15T12:00:00.000Z' },
]
const { fetchJson } = fauxNocodb({ schema: SCHEMA_AVEC_TS, rows })
const s = await checkBudget({ ...base, fetchJson })
check('avec timestamp : timestamp prioritaire (cumul 2 €)', s.verified && s.cumulEur === 2, `cumul ${s.cumulEur}`)
}
// 3. Lignes sans aucune date : ignorées et comptées
{
const rows = [{ Id: 1, cout_eur: 9 }, { Id: 2, cout_eur: 1, CreatedAt: '2026-09-02T00:00:00Z' }]
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('sans date : ignorée, comptée', s.cumulEur === 1 && s.lignesSansDate === 1)
check('journal : lignes sans date signalées', logs.some(([, t]) => t.includes('1 sans date ignorées')))
}
// 4. Pagination : 2 500 lignes du mois à 0,01 € → 25 € → bloqué
{
const rows = Array.from({ length: 2500 }, (_, i) => ({ Id: i + 1, cout_eur: 0.01, CreatedAt: '2026-09-20T00:00:00Z' }))
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('pagination : 2 500 lignes lues en 3 pages', s.lignesLues === 2500 && appels.length === 3, `${s.lignesLues} lignes, ${appels.length} appels`)
check(`seuil : 25 € ≥ ${BUDGET_MAX_EUR} € → blocked`, s.blocked === true && s.warning === true)
check('journal : BLOQUÉ', logs.some(([, t]) => t.includes('BLOQUÉ')))
}
// 5. Alerte entre 18 et 20 €
{
const rows = [{ Id: 1, cout_eur: 18.5, CreatedAt: '2026-09-20T00:00:00Z' }]
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
const s = await checkBudget({ ...base, fetchJson })
check('18,5 € → warning sans blocage', s.warning === true && s.blocked === false)
}
// 6. Panne NocoDB : fail-open, mais verified=false et avertissement au journal
{
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [], panne: true })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('panne : fail-open, verified=false', s.blocked === false && s.verified === false)
check('panne : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
}
} finally {
console.info = origInfo
console.warn = origWarn
}
let failed = 0
for (const r of results) {
if (!r.ok) failed++
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.ok ? '' : ` → ${r.detail}`}`)
}
console.log(failed ? `\nÉCHEC — ${failed}/${results.length} tests en échec` : `\nPASS — ${results.length}/${results.length} tests passés`)
process.exit(failed ? 1 : 0)