feat(pipeline): F4.1 — rotas GET /patterns e GET /stats (contrato C4-análise) #2415
- routes/sessions.ts: createSessionsAnalysisRouter com GET /patterns (filtros week/severity/key/days, paginado, Zod) e GET /stats (agregados: sessões totais e por dia, by_source, by_outcome, tool_top, skill_top, patterns_by_type, gate_blocks, avg_duration_sec, last_indexed_at, latest_pattern_week); montado em /api/sessions (C4) e /api (alias F4.1) - services/sessions/db.ts: listPatterns/countPatterns/getStats + rowToPattern/buildPatternsWhere; gate_blocks devolve null enquanto a tabela gate_events não existir (F1 arco C3-gates) - tests/sessions-analysis-route.test.ts: 15 testes (filtros, paginação, validação, gate_blocks condicional, não-colisão com /:id) - inclui camada session_events pendente de v2.7.3 (db.ts/indexer.ts — storeSessionEvents no indexAll; rota /:id lê da BD) já em execução, para a árvore commitada compilar coerente Validado contra BD real (11459 sessões, 160 padrões): curl 200 com JSON não-vazio em /api/patterns, /api/stats, /api/sessions/patterns?week=2026-W26, /api/sessions/stats?days=7.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Testes das rotas /patterns e /stats (contrato C4-análise, Pipeline QT F4.1).
|
||||
* Cobre: filtros da lista de padrões, agregados de stats, validação Zod,
|
||||
* gate_blocks condicional (tabela gate_events pode não existir) e a
|
||||
* não-colisão de /patterns com o param route /:id do router de sessões.
|
||||
* @author Descomplicar® | Projecto Observabilidade (Espelho)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import express from 'express'
|
||||
import request from 'supertest'
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { openSessionsDb, type SessionsDb, type PatternRecord } from '../services/sessions/db.js'
|
||||
import { createSessionsRouter, createSessionsAnalysisRouter } from '../routes/sessions.js'
|
||||
import type { SessionMeta } from '../types/session.js'
|
||||
|
||||
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's-' + Math.random().toString(36).slice(2, 10),
|
||||
source: 'claude',
|
||||
project_path: '/tmp/p',
|
||||
project_slug: 'p',
|
||||
jsonl_path: '/tmp/p/' + Math.random().toString(36).slice(2) + '.jsonl',
|
||||
model: null,
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
duration_sec: 60,
|
||||
event_count: 10,
|
||||
user_messages: 2,
|
||||
assistant_msgs: 5,
|
||||
tool_calls: 3,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
estimated_cost: null,
|
||||
first_prompt: 'teste',
|
||||
tools_used: ['Bash'],
|
||||
skills_invoked: [],
|
||||
outcome: 'completed',
|
||||
permission_mode: 'default',
|
||||
file_size: 1000,
|
||||
indexed_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function pattern(overrides: Partial<PatternRecord> = {}): PatternRecord {
|
||||
return {
|
||||
detected_at: new Date().toISOString(),
|
||||
week_iso: '2026-W27',
|
||||
pattern_key: 'abandoned_sessions',
|
||||
title: 'Sessões abandonadas',
|
||||
description: 'd',
|
||||
severity: 'warning',
|
||||
metric_value: 6,
|
||||
sample_session_ids: ['a', 'b'],
|
||||
affected_count: 6,
|
||||
consecutive_weeks: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('rotas de análise C4 (/patterns, /stats)', () => {
|
||||
let app: express.Express
|
||||
let db: SessionsDb
|
||||
|
||||
beforeAll(() => {
|
||||
const dbPath = join(mkdtempSync(join(tmpdir(), 'obs-c4-')), 'sessions.db')
|
||||
db = openSessionsDb(dbPath)
|
||||
|
||||
// Sessões: 2 claude completed hoje, 1 opencode error há 3 dias, 1 antiga (400 dias)
|
||||
const now = Date.now()
|
||||
db.upsertSession(meta({ session_id: 's1', tools_used: ['Bash', 'Read'], skills_invoked: ['wp-dev'] }))
|
||||
db.upsertSession(meta({ session_id: 's2', tools_used: ['Bash'] }))
|
||||
db.upsertSession(meta({
|
||||
session_id: 's3',
|
||||
source: 'opencode',
|
||||
outcome: 'error',
|
||||
started_at: new Date(now - 3 * 86400_000).toISOString(),
|
||||
}))
|
||||
db.upsertSession(meta({
|
||||
session_id: 's-antiga',
|
||||
started_at: new Date(now - 400 * 86400_000).toISOString(),
|
||||
}))
|
||||
|
||||
// Padrões: 2 semanas, 2 tipos, severities distintas
|
||||
db.upsertPattern(pattern({ week_iso: '2026-W26', pattern_key: 'skill_error_rate:wp-dev', severity: 'action' }))
|
||||
db.upsertPattern(pattern({ week_iso: '2026-W27', pattern_key: 'skill_error_rate:wp-dev', severity: 'action', consecutive_weeks: 2 }))
|
||||
db.upsertPattern(pattern({ week_iso: '2026-W27', pattern_key: 'abandoned_sessions', severity: 'warning' }))
|
||||
|
||||
app = express()
|
||||
app.use('/api/sessions', createSessionsRouter(db))
|
||||
app.use('/api', createSessionsAnalysisRouter(db))
|
||||
})
|
||||
|
||||
describe('GET /api/patterns', () => {
|
||||
it('lista todos os padrões por omissão, mais recentes primeiro', async () => {
|
||||
const res = await request(app).get('/api/patterns')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(3)
|
||||
expect(res.body.items).toHaveLength(3)
|
||||
expect(res.body.items[0].week_iso).toBe('2026-W27')
|
||||
})
|
||||
|
||||
it('filtra por semana ISO', async () => {
|
||||
const res = await request(app).get('/api/patterns').query({ week: '2026-W27' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(2)
|
||||
expect(res.body.week).toBe('2026-W27')
|
||||
expect(res.body.items.every((p: PatternRecord) => p.week_iso === '2026-W27')).toBe(true)
|
||||
})
|
||||
|
||||
it('filtra por severity e por key', async () => {
|
||||
const bySev = await request(app).get('/api/patterns').query({ severity: 'warning' })
|
||||
expect(bySev.body.total).toBe(1)
|
||||
expect(bySev.body.items[0].pattern_key).toBe('abandoned_sessions')
|
||||
|
||||
const byKey = await request(app).get('/api/patterns').query({ key: 'skill_error_rate:wp-dev' })
|
||||
expect(byKey.body.total).toBe(2)
|
||||
})
|
||||
|
||||
it('pagina com limit/offset', async () => {
|
||||
const res = await request(app).get('/api/patterns').query({ limit: 2, offset: 2 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(3)
|
||||
expect(res.body.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('devolve sample_session_ids e consecutive_weeks desserializados', async () => {
|
||||
const res = await request(app).get('/api/patterns').query({ week: '2026-W27', key: 'skill_error_rate:wp-dev' })
|
||||
expect(res.body.items[0].sample_session_ids).toEqual(['a', 'b'])
|
||||
expect(res.body.items[0].consecutive_weeks).toBe(2)
|
||||
})
|
||||
|
||||
it('rejeita week com formato inválido', async () => {
|
||||
const res = await request(app).get('/api/patterns').query({ week: '2026-27' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('responde também em /api/sessions/patterns sem colidir com /:id', async () => {
|
||||
const res = await request(app).get('/api/sessions/patterns')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.total).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/stats', () => {
|
||||
it('agrega sessões dos últimos 30 dias por omissão', async () => {
|
||||
const res = await request(app).get('/api/stats')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.days).toBe(30)
|
||||
expect(res.body.sessions).toBe(3) // s-antiga (400 dias) fica fora
|
||||
expect(res.body.by_source).toEqual({ claude: 2, opencode: 1 })
|
||||
expect(res.body.by_outcome).toEqual({ completed: 2, error: 1 })
|
||||
})
|
||||
|
||||
it('devolve by_day ordenado com contagens por dia', async () => {
|
||||
const res = await request(app).get('/api/stats')
|
||||
expect(res.body.by_day.length).toBeGreaterThanOrEqual(1)
|
||||
const total = res.body.by_day.reduce((s: number, d: { sessions: number }) => s + d.sessions, 0)
|
||||
expect(total).toBe(3)
|
||||
for (const d of res.body.by_day) {
|
||||
expect(d.day).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('devolve tool_top e skill_top a partir dos arrays JSON', async () => {
|
||||
const res = await request(app).get('/api/stats')
|
||||
const bash = res.body.tool_top.find((t: { tool: string }) => t.tool === 'Bash')
|
||||
expect(bash).toBeDefined()
|
||||
expect(bash.count).toBe(3)
|
||||
expect(res.body.skill_top[0]).toEqual({ skill: 'wp-dev', count: 1 })
|
||||
})
|
||||
|
||||
it('agrupa padrões por tipo (prefixo antes de ":")', async () => {
|
||||
const res = await request(app).get('/api/stats')
|
||||
const types = Object.fromEntries(
|
||||
res.body.patterns_by_type.map((p: { pattern_type: string; count: number }) => [p.pattern_type, p.count]),
|
||||
)
|
||||
expect(types.skill_error_rate).toBe(2)
|
||||
expect(types.abandoned_sessions).toBe(1)
|
||||
})
|
||||
|
||||
it('devolve última indexação e última semana de padrões', async () => {
|
||||
const res = await request(app).get('/api/stats')
|
||||
expect(res.body.last_indexed_at).toBeTruthy()
|
||||
expect(res.body.latest_pattern_week).toBe('2026-W27')
|
||||
expect(res.body.avg_duration_sec).toBe(60)
|
||||
})
|
||||
|
||||
it('gate_blocks é null sem tabela gate_events e conta blocks quando existe', async () => {
|
||||
const before = await request(app).get('/api/stats')
|
||||
expect(before.body.gate_blocks).toBeNull()
|
||||
|
||||
db.rawDb().exec(`
|
||||
CREATE TABLE gate_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL, hook TEXT NOT NULL, gate TEXT,
|
||||
session_id TEXT, project_slug TEXT,
|
||||
decision TEXT NOT NULL, reason TEXT, target TEXT, raw TEXT
|
||||
);
|
||||
`)
|
||||
db.rawDb().prepare("INSERT INTO gate_events (ts, hook, decision) VALUES (?, 'stale-identifier-gate', 'block')")
|
||||
.run(new Date().toISOString())
|
||||
db.rawDb().prepare("INSERT INTO gate_events (ts, hook, decision) VALUES (?, 'socrates-truth-gate', 'warn')")
|
||||
.run(new Date().toISOString())
|
||||
|
||||
const after = await request(app).get('/api/stats')
|
||||
expect(after.body.gate_blocks).toBe(1)
|
||||
})
|
||||
|
||||
it('aceita days custom e rejeita days inválido', async () => {
|
||||
const ok = await request(app).get('/api/stats').query({ days: 2 })
|
||||
expect(ok.status).toBe(200)
|
||||
expect(ok.body.days).toBe(2)
|
||||
expect(ok.body.sessions).toBe(2) // s3 (3 dias) fica fora
|
||||
|
||||
const bad = await request(app).get('/api/stats').query({ days: 'abc' })
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
|
||||
it('responde também em /api/sessions/stats', async () => {
|
||||
const res = await request(app).get('/api/sessions/stats')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.sessions).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user