/** * 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 { 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 { 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) }) }) })