diff --git a/.github/workflows/report-review.yml b/.github/workflows/report-review.yml new file mode 100644 index 0000000..94e6cb4 --- /dev/null +++ b/.github/workflows/report-review.yml @@ -0,0 +1,41 @@ +name: QA de relatorios e acervo +on: + pull_request: + branches: [main] + paths: ['backend/**', 'frontend/**', 'ai-service/**', 'database/**', 'e2e/**', '.github/workflows/report-review.yml'] + workflow_dispatch: +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + E2E_CHROME_PATH: /usr/bin/google-chrome + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: | + backend/package-lock.json + frontend/package-lock.json + e2e/package-lock.json + - name: Instalar dependencias de QA + run: | + npm ci --prefix backend + npm ci --prefix frontend + npm ci --prefix e2e + - name: Compilar e testar contratos e interface + run: | + npm run build --prefix backend + npm test --prefix backend + npm run build --prefix frontend + npm test --prefix frontend + - name: Atomicidade, concorrencia, idempotencia e proveniencia no PostgreSQL + run: npm run test:integration:indexing --prefix backend + - name: Busca, aprovacao e expurgo no navegador autenticado + run: npm run test:integration:review --prefix backend + - name: Harness real e regressao do copiloto + run: | + docker build -t sinapse-s209-ai-test ai-service + npm run test:integration:copilot --prefix backend diff --git a/backend/package.json b/backend/package.json index 4ac3c49..f207cea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,13 @@ "start": "node dist/index.js", "typecheck": "tsc --noEmit", "migrate": "tsx src/database/migrate.ts", - "bootstrap:admin": "tsx scripts/bootstrap-admin.mts" + "bootstrap:admin": "tsx scripts/bootstrap-admin.mts", + "test": "node scripts/test.mjs", + "test:integration:indexing": "tsx scripts/test-indexing-db.mts", + "test:integration:harness": "tsx scripts/validate-harness.mts", + "test:integration:copilot": "tsx scripts/validate-harness.mts --browser", + "test:integration:review": "tsx scripts/test-browser-qa.mts", + "audit:security": "npm audit --omit=dev" }, "dependencies": { "@types/multer": "^2.3.0", diff --git a/backend/scripts/harness-deadline.mjs b/backend/scripts/harness-deadline.mjs new file mode 100644 index 0000000..f3237ea --- /dev/null +++ b/backend/scripts/harness-deadline.mjs @@ -0,0 +1,19 @@ +// AbortSignal.timeout usa um timer sem referência; sozinho ele não mantém o processo de teste ativo. +export async function withHarnessDeadline(operation, timeoutMs = 5000) { + const controller = new AbortController(); + let timer; + try { + return await Promise.race([ + Promise.resolve().then(() => operation(controller.signal)), + new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error('Tempo limite na comunicação com o serviço de teste.'); + controller.abort(error); + reject(error); + }, timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/backend/scripts/harness-model.mjs b/backend/scripts/harness-model.mjs new file mode 100644 index 0000000..6a469a5 --- /dev/null +++ b/backend/scripts/harness-model.mjs @@ -0,0 +1,129 @@ +import { createServer } from 'node:http'; +export function createHarnessModel(token) { + if (!token) + throw new Error('Token obrigatório'); + const state = { invalid: false, clarificationOnly: false, unavailable: false, delayMs: 0, captures: [] }; + const MAX_BODY = 256 * 1024; + const server = createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + const prefix = '/' + token + '/'; + if (!req.url?.startsWith(prefix)) { + res.writeHead(401); + res.end('{}'); + return; + } + const route = '/' + req.url.slice(prefix.length); + if (req.method === 'GET') { + res.end(JSON.stringify(route === '/control' ? state : { models: [] })); + return; + } + let size = 0; + const parts = []; + try { + for await (const part of req) { + size += part.length; + if (size > MAX_BODY) { + res.writeHead(413); + res.end('{"error":"Corpo excede o limite"}'); + return; + } + parts.push(part); + } + const payload = JSON.parse(Buffer.concat(parts).toString('utf8')); + if (route === '/control') { + for (const key of ['invalid', 'clarificationOnly', 'unavailable']) + if (typeof payload[key] === 'boolean') + state[key] = payload[key]; + if (Number.isInteger(payload.delayMs) && payload.delayMs >= 0 && payload.delayMs <= 10000) + state.delayMs = payload.delayMs; + res.end(JSON.stringify(state)); + return; + } + if (route === '/api/embeddings') { + res.statusCode = state.unavailable ? 503 : 200; + res.end(JSON.stringify(state.unavailable ? { error: 'Indisponivel' } : { embedding: Array(1024).fill(0.1) })); + return; + } + state.captures.push(payload); + if (state.captures.length > 200) + state.captures.shift(); + const data = JSON.parse(payload.prompt); + if (state.unavailable) { + res.statusCode = 503; + res.end(JSON.stringify({ error: "Indisponível" })); + return; + } + if (state.delayMs) + await new Promise((resolve) => setTimeout(resolve, state.delayMs)); + const proposalSchema = payload.format.properties.proposal; + const scenario = (nome, tipo, entao) => ({ + nome, + tipo, + dado: "que existe um pedido registrado", + quando: "o usuário solicita a aprovação", + entao, + }); + const result = state.invalid + ? { questions: [], summary: "SEGREDO", extra: "proibido" } + : state.clarificationOnly && payload.format.properties.scenarios + ? { scenarios: [], questions: ["Qual o resultado esperado quando o pedido nao existir?"] } + : payload.format.properties.scenarios + ? { + scenarios: [ + scenario("Aprovar pedido com permissão", "caminho_principal", "o pedido passa a constar como aprovado"), + ...(payload.system.includes("obrigatoriamente") + ? [ + scenario("Recusar aprovação sem permissão", "acesso_nao_autorizado", "o sistema recusa a ação e o pedido permanece inalterado"), + ] + : []), + ], + questions: [], + } + : proposalSchema + ? { + proposal: Object.fromEntries(Object.keys((proposalSchema.anyOf + ? proposalSchema.anyOf.find((item) => item.type === "object") + : proposalSchema).properties).map((field) => [ + field, + field === "titulo" + ? "Consultar pagamento padronizado" + : field === "regras_observacoes" + ? "" + : data.fields[field] || "Resultado funcional do produto", + ])), + questions: [], + } + : payload.format.properties.questions + ? { + questions: data.fields.titulo?.includes("pagamento") + ? [ + { + dimension: "validacoes", + question: "Qual o tempo máximo para considerar a consulta rápida?", + }, + { + dimension: "permissoes_seguranca", + question: "Quem pode consultar o pagamento?", + }, + ] + : [], + summary: payload.system.includes("orientacao-v2") + ? "Nova revisão" + : "Revisão inicial", + } + : { answer: "Evidência do projeto", sources: [data.context[0].id] }; + res.end(JSON.stringify({ response: JSON.stringify(result) })); + } + catch { + if (!res.headersSent) + res.writeHead(400); + res.end('{"error":"Requisição inválida"}'); + } + }); + server.requestTimeout = 5000; + server.headersTimeout = 5000; + // Acesso pela rede Docker temporária; a porta publicada no host usa apenas loopback. + return server; +} +if (process.env.HARNESS_TOKEN) + createHarnessModel(process.env.HARNESS_TOKEN).listen(11434, '0.0.0.0'); diff --git a/backend/scripts/test-browser-qa.mts b/backend/scripts/test-browser-qa.mts new file mode 100644 index 0000000..077524b --- /dev/null +++ b/backend/scripts/test-browser-qa.mts @@ -0,0 +1,69 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Client } from 'pg'; +import { applyUntil } from '../src/database/migration-test-utils.js'; + +const id = 'sinapse-review-' + randomUUID().slice(0, 8); +const password = randomUUID(); +const storage = await mkdtemp(join(tmpdir(), id + '-')); +let started = false; +let embeddings: import('node:http').Server | undefined; +let api: ReturnType | undefined; +let web: { httpServer: import('node:http').Server } | undefined; +let db: { end(): Promise } | undefined; +function docker(args: string[]) { + const result = spawnSync('docker', args, { encoding: 'utf8' }); + if (result.status !== 0) throw new Error('Docker indisponível para o QA isolado.'); + return result.stdout.trim(); +} +try { + docker(['run', '-d', '--name', id, '-e', 'POSTGRES_USER=sinapse', '-e', 'POSTGRES_PASSWORD=' + password, + '-e', 'POSTGRES_DB=sinapse_e2e_test', '-p', '127.0.0.1::5432', 'pgvector/pgvector:pg16']); + started = true; + const port = JSON.parse(docker(['inspect', id]))[0].NetworkSettings.Ports['5432/tcp'][0].HostPort; + const url = 'postgresql://sinapse:' + password + '@127.0.0.1:' + port + '/sinapse_e2e_test'; + let ready = false; + for (let attempt = 0; attempt < 40; attempt++) { + if (spawnSync('docker', ['exec', id, 'pg_isready', '-h', '127.0.0.1', '-U', 'sinapse'], { stdio: 'ignore' }).status === 0) { ready = true; break; } + await new Promise(r => setTimeout(r, 500)); + } + if (!ready) throw new Error('PostgreSQL não iniciou.'); + Object.assign(process.env, { NODE_ENV: 'test', DATABASE_URL: url, E2E_DATABASE_URL: url, + DOCUMENT_STORAGE_DIR: storage, AI_SERVICE_URL: 'http://127.0.0.1:9', REPO_ANALYZER_URL: 'http://127.0.0.1:9' }); + const migration = new Client({ connectionString: url }); + await migration.connect(); + try { await applyUntil(migration, true, () => false); } finally { await migration.end(); } + const { startEmbeddingService } = await import('../../e2e/embedding-service.mjs'); + const embeddingService = await startEmbeddingService(); + embeddings = embeddingService.server; + process.env.AI_SERVICE_URL = embeddingService.url; + const { app } = await import('../src/app.js'); + const { pool } = await import('../src/database/db.js'); + db = pool; + api = app.listen(0, '127.0.0.1'); + await new Promise(r => api!.once('listening', r)); + const apiUrl = 'http://127.0.0.1:' + (api.address() as { port: number }).port; + const { preview } = await import('../../frontend/node_modules/vite/dist/node/index.js'); + const frontend = await preview({ root: resolve('../frontend'), configFile: false, + preview: { host: '127.0.0.1', port: 5190, strictPort: true, proxy: { '/api': { target: apiUrl, changeOrigin: true } } } }); + web = frontend; + process.exitCode = await new Promise((resolveExit, reject) => { + const child = spawn(process.execPath, ['run.mjs'], { cwd: resolve('../e2e'), stdio: 'inherit', + env: { ...process.env, E2E_API_URL: apiUrl + '/api/v1', E2E_APP_URL: 'http://127.0.0.1:5190', + ...(process.platform === 'win32' && !process.env.E2E_CHROME_PATH ? { E2E_CHROME_PATH: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' } : {}) } }); + child.once('error', reject); + child.once('exit', code => resolveExit(code ?? 1)); + }); +} finally { + if (web) await new Promise(r => web!.httpServer.close(() => r())); + if (api) { api.closeAllConnections(); await new Promise(r => api!.close(() => r())); } + if (db) await db.end(); + if (embeddings) { embeddings.closeAllConnections(); await new Promise(r => embeddings!.close(() => r())); } + if (started) docker(['rm', '-f', '-v', id]); + if (!storage.startsWith(join(tmpdir(), id + '-'))) throw new Error('Diretório temporário inesperado.'); + await rm(storage, { recursive: true, force: true }); + console.log('Banco e arquivos temporários de QA removidos.'); +} diff --git a/backend/scripts/test-indexing-db.mts b/backend/scripts/test-indexing-db.mts new file mode 100644 index 0000000..7563c5f --- /dev/null +++ b/backend/scripts/test-indexing-db.mts @@ -0,0 +1,75 @@ +import { createServer, type Server } from "node:http"; +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { Client } from "pg"; +import { applyUntil } from "../src/database/migration-test-utils.js"; + +const name = "sinapse-s203-test-" + randomUUID().slice(0,8); +const password = randomUUID(); +function docker(args: string[]) { + const result = spawnSync("docker", args, { encoding: "utf8" }); + if (result.status !== 0) throw new Error("Não foi possível preparar o PostgreSQL de testes."); + return result.stdout.trim(); +} +let started = false; +let bridge: Server | undefined; +try { + docker(["run","-d","--name",name,"-e","POSTGRES_USER=sinapse","-e","POSTGRES_PASSWORD="+password, + "-e","POSTGRES_DB=sinapse_s203_test","-p","127.0.0.1::5432","pgvector/pgvector:pg16"]); + started = true; + const port = JSON.parse(docker(["inspect",name]))[0].NetworkSettings.Ports["5432/tcp"][0].HostPort; + const url = "postgresql://sinapse:"+password+"@127.0.0.1:"+port+"/sinapse_s203_test"; + let ready = false; + for (let i=0;i<40;i++) { + const result=spawnSync("docker",["exec",name,"pg_isready","-h","127.0.0.1","-U","sinapse","-d","sinapse_s203_test"],{encoding:"utf8"}); + if (result.status===0) { ready=true; break; } + await new Promise(r=>setTimeout(r,500)); + } + if (!ready) throw new Error("PostgreSQL não ficou pronto."); + const client = new Client({connectionString:url}); + await client.connect(); + try { await applyUntil(client,true,()=>false); } finally { await client.end(); } + console.log("Todas as migrações aplicadas em banco isolado com pgvector."); + let realEndpoint = ""; + if (process.argv.includes("--real-ai")) { + bridge = createServer(async (req, res) => { + const buffers = []; + for await (const part of req) buffers.push(part); + const python = [ + "import os,sys,urllib.request", + "payload=sys.stdin.buffer.read()", + "request=urllib.request.Request('http://127.0.0.1:8000/ingest/entity',data=payload,headers={'Content-Type':'application/json','X-Service-Token':os.environ['AI_SERVICE_TOKEN']})", + "response=urllib.request.urlopen(request,timeout=120)", + "sys.stdout.buffer.write(response.read())", + ].join("\n"); + const response = spawnSync("docker", ["exec","-i","sinapse-ai-service","python","-c",python], { + input: Buffer.concat(buffers), maxBuffer: 8 * 1024 * 1024, timeout: 125000, + }); + res.setHeader("Content-Type", "application/json"); + res.statusCode = response.status === 0 ? 200 : 502; + res.end(response.status === 0 ? response.stdout : JSON.stringify({ error: "Serviço IA indisponível." })); + }); + await new Promise(r => bridge!.listen(0, "127.0.0.1", r)); + realEndpoint = "http://127.0.0.1:" + (bridge.address() as { port: number }).port; + } + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(process.execPath,["--import","tsx","--test", + "src/modules/indexing/indexing.db.test.ts","src/modules/indexing/indexing.migration.db.test.ts","src/modules/indexing/indexing.transport.db.test.ts","src/modules/indexing/indexing.state.db.test.ts","src/modules/indexing/indexing.recovery.db.test.ts","src/modules/indexing/indexing.worker.test.ts","src/modules/indexing/indexing.integration.db.test.ts","src/modules/repo-analyses/repo-analyses.indexing.db.test.ts"],{ + env:{...process.env,NODE_ENV:"test",DATABASE_URL:url,INDEX_TEST_DATABASE_URL:url,INDEX_AI_ENDPOINT:realEndpoint}, + stdio:"inherit", + }); + child.once("error", reject); + child.once("exit", code => resolve(code ?? 1)); + }); + process.exitCode=exitCode; + if (exitCode === 0) { + const suggestions = spawnSync(process.execPath, ["--import", "tsx", "--test", "src/modules/suggestions/suggestions.db.test.ts"], { + env: { ...process.env, NODE_ENV: "test", DATABASE_URL: url, ARCHIVE_TEST_DATABASE_URL: url }, + stdio: "inherit", + }); + process.exitCode = suggestions.status ?? 1; + } +} finally { + if (bridge) { bridge.closeAllConnections(); await new Promise(r => bridge!.close(() => r())); } + if (started) { docker(["rm","-f","-v",name]); console.log("Banco temporário removido."); } +} diff --git a/backend/scripts/test.mjs b/backend/scripts/test.mjs new file mode 100644 index 0000000..137418e --- /dev/null +++ b/backend/scripts/test.mjs @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +function discoverTests(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return discoverTests(path); + return entry.name.endsWith(".test.ts") ? [path] : []; + }); +} + +const files = discoverTests("src").sort(); +if (files.length === 0) throw new Error("Nenhum teste de backend encontrado."); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...files], { + env: { ...process.env, NODE_ENV: "test" }, + stdio: "inherit", +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/backend/scripts/validate-harness.mts b/backend/scripts/validate-harness.mts new file mode 100644 index 0000000..ca577b9 --- /dev/null +++ b/backend/scripts/validate-harness.mts @@ -0,0 +1,366 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { resolve } from "node:path"; +import { Client } from "pg"; +import { applyUntil } from "../src/database/migration-test-utils.js"; +const { withHarnessDeadline } = await import(new URL("./harness-deadline.mjs", import.meta.url).href); +const id = "sinapse-s209-" + randomUUID().slice(0, 8), + password = randomUUID(), + token = randomUUID(), + started: string[] = []; +function docker(args: string[]) { + const r = spawnSync("docker", args, { encoding: "utf8" }); + if (r.status !== 0) throw Error("Docker: " + args[0] + " falhou"); + return r.stdout.trim(); +} +function port(name: string, p: string) { + return JSON.parse(docker(["inspect", name]))[0].NetworkSettings.Ports[p][0] + .HostPort; +} +let captures: any[] = []; +const network=id+'-private'; +let modelUrl=''; +async function control(update?: Record) { + await withHarnessDeadline(async (signal: AbortSignal) => { + const response=await fetch(modelUrl+'/control', {signal, ...(update ? {method:'POST',body:JSON.stringify(update)} : {})}); + assert.equal(response.status,200); + const snapshot=await response.json() as {captures:any[]}; captures=snapshot.captures; + }); +} +let api: ReturnType | undefined, + db: { end: () => Promise } | undefined; +try { + docker(['network','create',network]); + const modelName=id+'-model'; + docker(['run','-d','--name',modelName,'--network',network,'--network-alias','harness-model','-e','HARNESS_TOKEN='+token,'--mount','type=bind,source='+resolve('scripts/harness-model.mjs')+',target=/mock.mjs,readonly','-p','127.0.0.1::11434','node:20-slim','node','/mock.mjs']); + started.push(modelName); + modelUrl='http://127.0.0.1:'+port(modelName,'11434/tcp')+'/'+token; + for(let attempt=0;attempt<40;attempt++){try{await control();break;}catch(error){if(attempt===39)throw error;await new Promise(r=>setTimeout(r,250));}} + assert.equal((await fetch(modelUrl+'/api/generate',{method:'POST',body:'x'.repeat(256*1024+1)})).status,413); + assert.equal((await fetch(modelUrl+'/api/generate',{method:'POST',body:'{'})).status,400); + assert.equal((await fetch(modelUrl.replace(token,'invalid')+'/control')).status,401); + const pgName = id + "-db", + aiName = id + "-ai"; + docker([ + "run", + "-d", + "--name", + pgName, + "-e", + "POSTGRES_USER=sinapse", + "-e", + "POSTGRES_PASSWORD=" + password, + "-e", + "POSTGRES_DB=harness_test", + "-p", + "127.0.0.1::5432", + "pgvector/pgvector:pg16", + ]); + started.push(pgName); + for (let i = 0; i < 40; i++) { + if ( + spawnSync( + "docker", + ["exec", pgName, "pg_isready", "-h", "127.0.0.1", "-U", "sinapse"], + { stdio: "ignore" }, + ).status === 0 + ) + break; + await new Promise((r) => setTimeout(r, 500)); + } + Object.assign(process.env, { + NODE_ENV: "test", + AI_SERVICE_TOKEN: token, + DOCUMENT_INGESTION_TOKEN: token, + DATABASE_URL: + "postgresql://sinapse:" + + password + + "@127.0.0.1:" + + port(pgName, "5432/tcp") + + "/harness_test", + }); + const migration = new Client({ connectionString: process.env.DATABASE_URL }); + await migration.connect(); + try { + await applyUntil(migration, true, () => false); + await applyUntil(migration, true, () => false); + } finally { + await migration.end(); + } + docker([ + "run", + "-d", + "--name", + aiName, + "--network", + network, + "-e", + "AI_SERVICE_TOKEN=" + token, + "-e", + "DOCUMENT_INGESTION_TOKEN=" + token, + "-e", + "WORKSPACE_DIR=/tmp/analyzer", + "-e", + "PYTHONDONTWRITEBYTECODE=1", + "-e", + "OLLAMA_BASE_URL=http://harness-model:11434/" + token, + "--mount", + "type=bind,source=" + resolve("../ai-service") + ",target=/app,readonly", + "-p", + "127.0.0.1::8000", + "sinapse-s209-ai-test", + ]); + started.push(aiName); + process.env.AI_SERVICE_URL = "http://127.0.0.1:" + port(aiName, "8000/tcp"); + let ready = false; + for (let i = 0; i < 60; i++) { + try { + if ( + ( + await withHarnessDeadline((signal: AbortSignal) => fetch(process.env.AI_SERVICE_URL + "/openapi.json", { + signal, headers: { "X-Service-Token": token }, + })) + ).ok + ) { + ready = true; + break; + } + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + assert.ok(ready, "IA não iniciou"); + const { pool } = await import("../src/database/db.js"); + db = pool; + const { app } = await import("../src/app.js"); + const { sessionService } = + await import("../src/modules/auth/session.service.js"); + const { defaultConfig } = + await import("../src/modules/harness/harness.contract.js"); + const { HarnessService } = + await import("../src/modules/harness/harness.service.js"); + const [admin, po, dev, a, b, ca, cb] = Array.from({ length: 7 }, () => + randomUUID(), + ); + await pool.query( + "INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES($1::uuid,'Admin',$1::text||'@example.test','sintetico','admin'),($2::uuid,'PO',$2::text||'@example.test','sintetico','po'),($3::uuid,'Dev',$3::text||'@example.test','sintetico','dev')", + [admin, po, dev], + ); + await pool.query( + "INSERT INTO projeto(id,nome,cliente,status) VALUES($1::uuid,$1::text,'Sintético','ativo'),($2::uuid,$2::text,'Sintético','ativo')", + [a, b], + ); + await pool.query( + "INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto) VALUES($1,$3,'pbi',$1,'Consultar pedido no projeto A'),($2,$4,'pbi',$2,'Consultar pedido secreto do projeto B')", + [ca, cb, a, b], + ); + const sessions = await Promise.all( + [admin, po, dev].map((user) => sessionService.createSession(user)), + ); + api = app.listen(0, "127.0.0.1"); + await new Promise((r) => api!.once("listening", r)); + const base = + "http://127.0.0.1:" + (api.address() as { port: number }).port + "/api/v1"; + async function call( + path: string, + role: number | null = 0, + method = "GET", + body?: unknown, + ) { + const r = await fetch(base + path, { + method, + headers: { + "Content-Type": "application/json", + ...(role === null + ? {} + : { Authorization: "Bearer " + sessions[role].token }), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + await control(); + return { status: r.status, body: await r.json() }; + } + const configPath = "/admin/ai-harness/levantamento", + run = "/projects/" + a + "/ai/requests", + request = { + mode: "levantamento", + operation: "questionar", + level: "pbi", + fields: { titulo: "Consultar pedido" }, + }; + assert.equal((await call(configPath, null)).status, 401); + assert.equal((await call(configPath, 1)).status, 403); + assert.equal((await call(configPath)).body.version, 0); + assert.equal((await call(run, 2, "POST", request)).status, 404); + const initial = await call(run, 1, "POST", request); + assert.equal(initial.status, 200, JSON.stringify(initial.body)); + assert.equal(initial.body.version, 0); + const config = { + ...defaultConfig("levantamento"), + instructions: "orientacao-v2: questionar somente lacunas relevantes", + }; + assert.equal( + (await call(configPath, 0, "PUT", { expected_version: 0, config })).status, + 201, + ); + const next = await call(run, 1, "POST", request); + assert.equal(next.body.version, 1); + assert.equal(next.body.result.summary, "Nova revisão"); + assert.equal((await new HarnessService().current("levantamento")).version, 1); + assert.equal((await call("/admin/ai-harness/consulta")).body.version, 0); + const concurrent = await Promise.all( + [1, 2].map(() => + call(configPath, 0, "PUT", { expected_version: 1, config }), + ), + ); + assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]); + assert.deepEqual( + (await call(configPath + "/history")).body.items.map((r: any) => r.version), + [2, 1], + ); + assert.equal( + ( + await call(configPath, 0, "PUT", { + expected_version: 2, + config: defaultConfig("levantamento"), + }) + ).status, + 201, + ); + assert.equal( + (await call(run, 1, "POST", request)).body.result.summary, + "Revisão inicial", + ); + await control({invalid:true}); + const bad = await call(run, 1, "POST", request); + assert.equal(bad.status, 503); + assert.ok(!JSON.stringify(bad.body).includes("SEGREDO")); + await control({invalid:false}); + // S2-11: história incompleta não chega ao modelo; com permissão, vem o cenário de acesso não autorizado. + const story = { + titulo: "Aprovar pedido", + historia_como_um: "gestor", + historia_eu_quero: "aprovar pedidos com o perfil de administrador", + historia_para_que: "liberar a entrega", + }; + const scenarioRequest = { ...request, operation: "cenarios", fields: story }; + const modelCalls = captures.length; + const incomplete = await call(run, 1, "POST", { + ...scenarioRequest, + fields: { ...story, historia_para_que: "" }, + }); + assert.equal(incomplete.status, 400); + assert.equal(incomplete.body.code, "HISTORIA_INCOMPLETA"); + assert.deepEqual(incomplete.body.details.campos, ["historia_para_que"]); + assert.equal(captures.length, modelCalls); + const scenarios = await call(run, 1, "POST", scenarioRequest); + assert.equal(scenarios.status, 200, JSON.stringify(scenarios.body)); + assert.deepEqual( + scenarios.body.result.scenarios.map((item: any) => item.tipo), + ["caminho_principal", "acesso_nao_autorizado"], + ); + assert.ok(captures.at(-1).system.includes("acesso_nao_autorizado")); + await control({clarificationOnly:true}); + try { + const clarification = await call(run, 1, "POST", scenarioRequest); + assert.equal(clarification.status, 200, JSON.stringify(clarification.body)); + assert.deepEqual(clarification.body.result.scenarios, []); + assert.deepEqual(clarification.body.result.questions, ["Qual o resultado esperado quando o pedido nao existir?"]); + } finally { + await control({clarificationOnly:false}); + } + const answer = await call(run, 1, "POST", { + mode: "consulta", + operation: "responder", + fields: { pergunta: "Consultar pedido" }, + }); + assert.equal(answer.status, 200, JSON.stringify(answer.body)); + assert.deepEqual(answer.body.result.sources, [ca]); + assert.ok(!captures.at(-1).prompt.includes(cb)); + const chat = await call("/chat/query", 1, "POST", { + pergunta: "Consultar pedido", + projeto_id: a, + }); + assert.equal(chat.status, 200, JSON.stringify(chat.body)); + assert.equal( + (await pool.query("SELECT count(*)::int AS n FROM projeto")).rows[0].n, + 2, + ); + assert.equal( + (await pool.query("SELECT count(*)::int AS n FROM ai_harness_revision")) + .rows[0].n, + 3, + ); + // Relatório concluído passa pelo mesmo endpoint de processamento dos documentos. + const analysisId = randomUUID(); + await pool.query(`INSERT INTO analise_repositorio(id,projeto_id,usuario_id,repositorio_url,run_id,status,relatorio_markdown,metadados) + VALUES($1,$2,$3,'https://github.com/test/relatorio',$4,'concluido',$5,$6::jsonb)`, + [analysisId, a, po, 'report-' + analysisId, '# Arquitetura S223\n\nPostgreSQL em src/app.ts', JSON.stringify({ source_commit: 'a'.repeat(40), source_ref: 'main', analyzed_paths: ['src/app.ts'] })]); + const { ReportIndexingWorker } = await import('../src/modules/repo-analyses/repo-analyses.indexing.worker.js'); + const reportWorker = new ReportIndexingWorker(); + await reportWorker.tick(); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM chunk WHERE entidade_id=$1',[analysisId])).rows[0].n,0); + const revision=(await pool.query('SELECT relatorio_revisao FROM analise_repositorio WHERE id=$1',[analysisId])).rows[0].relatorio_revisao; + assert.equal((await call(`/projects/${a}/repo-analyses/${analysisId}/report/approve`,1,'POST',{relatorio_revisao:revision})).status,200); + await reportWorker.tick(); + const reportChunks = (await pool.query("SELECT * FROM chunk WHERE entidade_id=$1 AND entidade_tipo='analise_repositorio'", [analysisId])).rows; + assert.ok(reportChunks.length > 0, 'Relatório deve ser indexado sem upload'); + assert.equal(reportChunks[0].metadados_json.source_commit, 'a'.repeat(40)); + assert.equal((await call(`/projects/${a}/repo-analyses/${analysisId}/indexing/retry`, 2, 'POST', {})).status, 404); // Dev sem alocação não pode descobrir a origem. + assert.equal((await call(`/projects/${b}/repo-analyses/${analysisId}/indexing/retry`, 0, 'POST', {})).status, 404); + assert.equal((await call(`/projects/${a}/repo-analyses/${analysisId}/indexing/retry`, 1, 'POST', {})).status, 200); + await reportWorker.tick(); + assert.equal(Number((await pool.query('SELECT count(*) AS n FROM chunk WHERE entidade_id=$1', [analysisId])).rows[0].n), reportChunks.length); + const reportSearch = await call(`/search?q=Arquitetura&projeto_id=${a}`); + assert.equal(reportSearch.status, 200); + assert.ok(reportSearch.body.items.some((item: any) => item.entity_id === analysisId)); + const isolatedSearch = await call(`/search?q=Arquitetura&projeto_id=${b}`); + assert.equal(isolatedSearch.status, 200); + assert.ok(!isolatedSearch.body.items.some((item: any) => item.entity_id === analysisId)); + const reportAnswer = await call('/chat/query', 0, 'POST', { pergunta: 'Arquitetura S223', projeto_id: a }); + assert.equal(reportAnswer.status, 200); + assert.ok(reportAnswer.body.fontes.some((source: any) => source.titulo.includes('report-' + analysisId))); + console.log('S2-23 aprovada: relatório -> processamento de documento -> embeddings -> PostgreSQL -> busca e chat, sem duplicação e com isolamento.'); + + if (process.argv.includes("--browser")) { + const { verifyCopilotBrowser } = + await import("./verify-copilot-browser.mjs"); + await verifyCopilotBrowser({ + harnessEvidence: { + before: { version: initial.body.version, result: initial.body.result }, + after: { version: next.body.version, result: next.body.result }, + history: (await call(configPath + "/history")).body.items.map( + (item: any) => ({ + version: item.version, + created_at: item.created_at, + }), + ), + control: { + consulta_version: (await call("/admin/ai-harness/consulta")).body + .version, + concurrent_statuses: concurrent.map((item) => item.status), + rollback_creates_new_version: 3, + }, + }, + apiUrl: base.replace(/\/api\/v1$/, ""), + pool, + token: sessions[1].token, + projectId: a, + setUnavailable: (value: boolean) => control({unavailable:value}), + setDelay: (value: number) => control({delayMs:value}), + }); + } + console.log( + "S2-09 aprovada: PostgreSQL + backend + FastAPI + Ollama simulado; revisão imediata, histórico, concorrência, rollback, autenticação, isolamento, schema inválido e chat.", + ); +} finally { + if (api) { + api.closeAllConnections(); + await new Promise((r) => api!.close(() => r())); + } + if (db) await db.end(); + for (const name of started.reverse()) docker(["rm", "-f", "-v", name]); + docker(["network", "rm", network]); + console.log("Containers temporários removidos."); +} diff --git a/backend/scripts/verify-copilot-browser.mts b/backend/scripts/verify-copilot-browser.mts new file mode 100644 index 0000000..3437567 --- /dev/null +++ b/backend/scripts/verify-copilot-browser.mts @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { Pool } from "pg"; +const require = createRequire(import.meta.url); +const { chromium } = require("../../e2e/node_modules/playwright-core"); +export async function verifyCopilotBrowser({ + apiUrl, + pool, + token, + projectId, + setUnavailable, + setDelay, + harnessEvidence, +}: { + apiUrl: string; + pool: Pool; + token: string; + projectId: string; + setUnavailable: (value: boolean) => Promise; + setDelay: (value: number) => Promise; + harnessEvidence?: unknown; +}) { + const { preview } = + await import("../../frontend/node_modules/vite/dist/node/index.js"); + const [epic, feature, pbi, criterion] = Array.from({ length: 4 }, () => + randomUUID(), + ); + await pool.query( + "UPDATE projeto SET nome='Projeto Demonstração S2-10' WHERE id=$1", + [projectId], + ); + await pool.query( + "INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,'Digitalizar pagamentos','rascunho')", + [epic, projectId], + ); + await pool.query( + "INSERT INTO feature(id,epico_id,titulo,status) VALUES($1,$2,'Consultar pagamentos','rascunho')", + [feature, epic], + ); + await pool.query( + "INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,status,requer_interface) VALUES($1,$2,'PBI-210','Consultar pagamento','cliente','consultar pagamento de forma rápida','acompanhar o recebimento','rascunho',true)", + [pbi, feature], + ); + await pool.query( + "INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,texto,nome,dado,quando,entao,ordem) VALUES($1,'pbi',$2,'Consultar pagamento válido','Caminho principal','Cliente autenticado','Consultar pagamento','Exibir pagamento',1)", + [criterion, pbi], + ); + const web = await preview({ + root: resolve("../frontend"), + configFile: false, + preview: { + host: "127.0.0.1", + port: 5189, + strictPort: true, + proxy: { "/api": { target: apiUrl, changeOrigin: true } }, + }, + }); + let browser: any; + try { + browser = await chromium.launch({ + headless: true, + ...(process.env.E2E_CHROME_PATH + ? { executablePath: process.env.E2E_CHROME_PATH } + : { channel: process.platform === "win32" ? "msedge" : "chromium" }), + }); + const page = await browser.newPage({ + viewport: { width: 1440, height: 1100 }, + }), + errors: string[] = []; + page.on("pageerror", (error: Error) => errors.push(error.message)); + await page.addInitScript( + (value: string) => localStorage.setItem("app_auth_token", value), + token, + ); + await page.goto( + "http://127.0.0.1:5189/projects/" + + projectId + + "/epics/" + + epic + + "/features/" + + feature + + "/pbis/" + + pbi, + ); + await page.getByRole("button", { name: "Editar", exact: true }).click(); + const title = page.getByLabel("Título", { exact: true }); + await title.fill("Consultar pagamento em edição"); + await page + .getByRole("button", { name: "Analisar lacunas", exact: true }) + .click(); + await page + .getByText("Quem pode consultar o pagamento?", { exact: false }) + .waitFor(); + assert.equal(await title.inputValue(), "Consultar pagamento em edição"); + assert.equal( + (await pool.query("SELECT titulo FROM pbi WHERE id=$1", [pbi])).rows[0] + .titulo, + "Consultar pagamento", + ); + const artifacts = resolve( + process.env.DEMO_OUTPUT_DIR ?? "../docs/evidence", + ); + await mkdir(artifacts, { recursive: true }); + async function capture(name: string, selector = ".copilot-panel") { + const target = page.locator(selector); + const size = await target.boundingBox(); + const viewport = page.viewportSize(); + await page.setViewportSize({ + width: viewport.width, + height: Math.max(1100, Math.ceil(size.height) + 280), + }); + await target.evaluate((node: HTMLElement) => + window.scrollTo( + 0, + node.getBoundingClientRect().top + window.scrollY - 150, + ), + ); + await target.screenshot({ path: resolve(artifacts, name) }); + await page.setViewportSize(viewport); + } + + await capture("S2-10-questionador.png"); + await page.getByRole("button", { name: "Descartar resposta" }).click(); + await setDelay(6000); + await page + .getByRole("button", { name: "Analisar lacunas", exact: true }) + .click(); + await page.getByText(/ultrapassou 5 segundos/).waitFor(); + assert.equal(await title.isEnabled(), true); + await capture("S2-10-demora.png"); + await page.getByRole("button", { name: "Cancelar análise" }).click(); + await page + .getByText("Análise cancelada. Sua edição foi preservada.") + .waitFor(); + await setDelay(0); + await page.getByRole("radio", { name: "Padronizar texto" }).check(); + await page.getByRole("button", { name: "Propor padronização" }).click(); + await page + .getByText("Consultar pagamento padronizado", { exact: true }) + .waitFor(); + assert.equal(await title.inputValue(), "Consultar pagamento em edição"); + assert.equal( + (await pool.query("SELECT count(*)::int AS n FROM sugestao_ia")).rows[0] + .n, + 0, + ); + await capture("S2-10-padronizador.png"); + await page.addScriptTag({ + path: require.resolve("../../e2e/node_modules/axe-core/axe.min.js"), + }); + const violations = await page.evaluate(async () => { + const report = await (window as any).axe.run( + document.querySelector(".copilot-panel"), + ); + return report.violations + .filter((v: any) => ["serious", "critical"].includes(v.impact)) + .map((v: any) => v.id); + }); + assert.deepEqual(violations, []); + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + await capture("S2-10-mobile.png"); + await page.getByRole("button", { name: "Descartar resposta" }).click(); + await setUnavailable(true); + await page.getByRole("button", { name: "Propor padronização" }).click(); + await page + .getByRole("alert") + .filter({ hasText: "Sua edição foi preservada" }) + .waitFor(); + assert.equal(await title.inputValue(), "Consultar pagamento em edição"); + await page.setViewportSize({ width: 1440, height: 1100 }); + await capture("S2-10-indisponibilidade.png", "article:has(.copilot-panel)"); + await page + .getByRole("button", { name: "Salvar alterações", exact: true }) + .click(); + await page.getByRole("button", { name: "Editar", exact: true }).waitFor(); + assert.equal( + (await pool.query("SELECT titulo FROM pbi WHERE id=$1", [pbi])).rows[0] + .titulo, + "Consultar pagamento em edição", + ); + assert.deepEqual(errors, []); + await setUnavailable(false); + await page.setViewportSize({ width: 1440, height: 1100 }); + for (const [level, path] of [ + ["epico", "/projects/" + projectId + "/epics/" + epic], + [ + "feature", + "/projects/" + projectId + "/epics/" + epic + "/features/" + feature, + ], + ]) { + await page.goto("http://127.0.0.1:5189" + path); + await page.getByRole("button", { name: "Editar", exact: true }).click(); + await page + .getByLabel("Descrição", { exact: true }) + .fill("Acompanhar pagamentos do cliente"); + await page + .getByLabel("Objetivo", { exact: true }) + .fill("Dar transparência ao recebimento"); + if (level === "epico") { + await page + .getByLabel("Escopo macro", { exact: true }) + .fill("Consulta e acompanhamento de pagamentos"); + await page + .getByLabel("Resultado esperado", { exact: true }) + .fill("Cliente acompanha recebimentos"); + } + await page.getByRole("button", { name: "Propor padronização" }).click(); + await page + .getByText("Consultar pagamento padronizado", { exact: true }) + .waitFor(); + assert.equal( + await page + .locator(".copilot-proposal") + .getByText("Como um", { exact: true }) + .count(), + 0, + ); + if (level === "epico") + assert.equal( + await page + .locator(".copilot-proposal") + .getByText("Escopo macro", { exact: true }) + .count(), + 1, + ); + assert.equal( + await page.getByLabel("Descrição", { exact: true }).inputValue(), + "Acompanhar pagamentos do cliente", + ); + await capture("S2-10-" + level + ".png"); + } + if (harnessEvidence) { + const report = await browser.newPage({ + viewport: { width: 1200, height: 1000 }, + }); + const evidence = harnessEvidence as any; + const escaped = (value: unknown) => + JSON.stringify(value, null, 2) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + await report.setContent( + 'Evidência técnica S2-09

S2-09 · Harness versionado

Relatório de demonstração com respostas reais da API e banco isolado. Não é uma tela administrativa do produto. As respostas do Ollama foram simuladas.

Antes da alteração

' +
+          escaped(evidence.before) +
+          "

Solicitação seguinte, sem reiniciar

" +
+          escaped(evidence.after) +
+          "

Histórico preservado

" +
+          escaped(evidence.history) +
+          "

Independência e concorrência

" +
+          escaped(evidence.control) +
+          "
", + ); + await report.screenshot({ + path: resolve(artifacts, "S2-09-harness.png"), + fullPage: true, + }); + await report.close(); + } + console.log( + "S2-10 navegador aprovado: questionador, proposta separada, demora, cancelamento, IA indisponível com edição salva, mobile, acessibilidade e padronização de épico/feature.", + ); + } finally { + await setUnavailable(false); + await setDelay(0); + await browser?.close(); + await new Promise((r) => web.httpServer.close(() => r())); + } +} diff --git a/backend/src/database/migration-test-utils.ts b/backend/src/database/migration-test-utils.ts new file mode 100644 index 0000000..fec94a9 --- /dev/null +++ b/backend/src/database/migration-test-utils.ts @@ -0,0 +1,55 @@ +import { randomUUID } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Client } from "pg"; + +const migrationsDir = resolve(process.cwd(), "../database/migrations"); + +export async function migrationFiles(): Promise { + return (await readdir(migrationsDir)).filter((file) => /^\d+_.*\.sql$/.test(file)).sort(); +} + +function connectionFor(adminUrl: string, database: string): string { + const url = new URL(adminUrl); + url.pathname = `/${database}`; + return url.toString(); +} + +export async function loadMigration(file: string, vectorAvailable: boolean): Promise { + let sql = await readFile(join(migrationsDir, file), "utf8"); + sql = sql.replaceAll("\\ir ../init.sql", await readFile(join(migrationsDir, "../init.sql"), "utf8")); + if (vectorAvailable) return sql; + return sql + .replace(/CREATE EXTENSION IF NOT EXISTS vector;/g, "") + .replace(/vector\(1024\)/g, "real[]") + .replace(/^.*USING hnsw.*$/gm, ""); +} + +export async function applyUntil(client: Client, vectorAvailable: boolean, stop: (file: string) => boolean): Promise { + await client.query("CREATE TABLE IF NOT EXISTS _schema_migrations (version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP)"); + for (const file of await migrationFiles()) { + if (stop(file)) return; + const applied = await client.query("SELECT 1 FROM _schema_migrations WHERE version=$1", [file]); + if (applied.rowCount) continue; + await client.query(await loadMigration(file, vectorAvailable)); + await client.query("INSERT INTO _schema_migrations (version) VALUES ($1)", [file]); + } +} + +export async function withDatabase(adminUrl: string, run: (client: Client, vectorAvailable: boolean) => Promise): Promise { + const admin = new Client({ connectionString: adminUrl }); + await admin.connect(); + const database = `migration_${randomUUID().replaceAll("-", "").slice(0, 16)}_test`; + const vector = await admin.query("SELECT 1 FROM pg_available_extensions WHERE name='vector'"); + const vectorAvailable = Boolean(vector.rowCount); + await admin.query(`CREATE DATABASE ${database}`); + const client = new Client({ connectionString: connectionFor(adminUrl, database) }); + try { + await client.connect(); + await run(client, vectorAvailable); + } finally { + await client.end().catch(() => undefined); + await admin.query(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + await admin.end(); + } +} diff --git a/backend/src/harness-deadline.test.ts b/backend/src/harness-deadline.test.ts new file mode 100644 index 0000000..40e104e --- /dev/null +++ b/backend/src/harness-deadline.test.ts @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const helper = pathToFileURL(resolve('scripts/harness-deadline.mjs')).href; +test('harness: espera sem sockets ativos termina por timeout, sem encerrar com código 13', () => { + const child = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import { withHarnessDeadline } from ${JSON.stringify(helper)}; + try { + await withHarnessDeadline(() => new Promise(() => {}), 30); + process.exitCode = 1; + } catch (error) { + if (!error.message.includes('Tempo limite')) throw error; + console.log('timeout-controlado'); + } + `], { encoding: 'utf8', timeout: 3000 }); + assert.equal(child.error, undefined); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /timeout-controlado/); +}); +test('harness: sucesso libera o timer e permite encerramento imediato', () => { + const child = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import { withHarnessDeadline } from ${JSON.stringify(helper)}; + const value = await withHarnessDeadline(() => Promise.resolve('pronto'), 60000); + console.log(value); + `], { encoding: 'utf8', timeout: 3000 }); + assert.equal(child.error, undefined); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /pronto/); +}); diff --git a/backend/src/harness-model.test.ts b/backend/src/harness-model.test.ts new file mode 100644 index 0000000..45470ee --- /dev/null +++ b/backend/src/harness-model.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +test('harness: mock exige token, limita corpo e recupera após JSON inválido', async (t) => { + const { createHarnessModel } = await import(pathToFileURL(resolve('scripts/harness-model.mjs')).href); + const token = randomUUID(); + const server: Server = createHarnessModel(token); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + t.after(async () => { + server.closeAllConnections(); + await new Promise(r => server.close(() => r())); + }); + const base = 'http://127.0.0.1:' + (server.address() as AddressInfo).port; + assert.equal((await fetch(base + '/control')).status, 401); + const call = (body: string) => fetch(base + '/' + token + '/api/embeddings', { method: 'POST', body }); + assert.equal((await call('x'.repeat(256 * 1024 + 1))).status, 413); + assert.equal((await call('{')).status, 400); + const response = await call('{}'); + assert.equal(response.status, 200); + assert.equal((await response.json() as { + embedding: number[]; + }).embedding.length, 1024); + await fetch(base + '/' + token + '/control', { method: 'POST', body: JSON.stringify({ unavailable: true }) }); + assert.equal((await call('{}')).status, 503); +}); diff --git a/backend/src/modules/admin/admin.repository.ts b/backend/src/modules/admin/admin.repository.ts index f828d47..d22c20f 100644 --- a/backend/src/modules/admin/admin.repository.ts +++ b/backend/src/modules/admin/admin.repository.ts @@ -19,6 +19,7 @@ export interface PurgeProjectResult { projeto_nome: string; chunks_removidos: number; documentos_cancelados: number; + relatorios_cancelados: number; eventos_removidos: number; status: "sucesso"; message: string; @@ -115,6 +116,15 @@ export class AdminRepository { ); const eventosRemovidos = eventsCountRes.rowCount ?? 0; + const reports = await client.query( + `UPDATE analise_repositorio SET aprovacao_hash=NULL, aprovado_por=NULL, aprovado_em=NULL, + relatorio_revisao=relatorio_revisao+1, + indexacao_status=CASE WHEN status='concluido' AND NULLIF(btrim(relatorio_markdown),'') IS NOT NULL + THEN 'aguardando_aprovacao' ELSE 'nao_aplicavel' END, + indexacao_lease_id=NULL, indexacao_bloqueado_ate=NULL, indexacao_tentativas=0, indexacao_erro=NULL + WHERE projeto_id=$1`, [projectId], + ); + await auditService.record({ usuario_id: userId, entidade_tipo: "projeto", @@ -125,6 +135,7 @@ export class AdminRepository { projeto_nome: project.nome, chunks_removidos: chunksRemovidos, documentos_cancelados: documentosCanceladosRes.rowCount ?? 0, + relatorios_cancelados: reports.rowCount ?? 0, eventos_removidos: eventosRemovidos, }, }, client); @@ -134,9 +145,10 @@ export class AdminRepository { projeto_nome: project.nome, chunks_removidos: chunksRemovidos, documentos_cancelados: documentosCanceladosRes.rowCount ?? 0, + relatorios_cancelados: reports.rowCount ?? 0, eventos_removidos: eventosRemovidos, status: "sucesso", - message: `Contexto indexado do projeto expurgado com sucesso. ${documentosCanceladosRes.rowCount ?? 0} documento(s) foram retirados da fila de reindexação.`, + message: `Contexto indexado do projeto expurgado com sucesso. ${documentosCanceladosRes.rowCount ?? 0} documento(s) foram retirados da fila de reindexação. Relatórios exigem nova aprovação antes de voltar ao acervo.`, }; }); } diff --git a/backend/src/modules/chat/chat.repository.ts b/backend/src/modules/chat/chat.repository.ts index 54c9f64..78243a0 100644 --- a/backend/src/modules/chat/chat.repository.ts +++ b/backend/src/modules/chat/chat.repository.ts @@ -93,6 +93,10 @@ export class ChatRepository { const result = await this.pool.query( `SELECT id, entidade_tipo, entidade_id, texto, metadados_json FROM chunk WHERE projeto_id = $1 AND texto ILIKE ANY($2::text[]) + AND (entidade_tipo <> 'analise_repositorio' OR EXISTS (SELECT 1 FROM analise_repositorio reviewed + WHERE reviewed.id=chunk.entidade_id AND reviewed.projeto_id=chunk.projeto_id + AND reviewed.aprovacao_hash=encode(sha256(convert_to(reviewed.relatorio_markdown,'UTF8')),'hex') + AND reviewed.status='concluido' AND reviewed.aprovado_por IS NOT NULL AND reviewed.aprovado_em IS NOT NULL)) ORDER BY created_at DESC, id LIMIT $3`, [projetoId, patterns, limit], ); diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts index 2f3d39d..a78a663 100644 --- a/backend/src/modules/chat/chat.service.ts +++ b/backend/src/modules/chat/chat.service.ts @@ -59,7 +59,7 @@ export function citedSources(text: string, chunks: Array<{id: string; entidade_t const chunk = byId.get(id)!; const metadata = chunk.metadados_json; const title = chunk.entidade_tipo === 'analise_repositorio' && typeof metadata?.run_id === 'string' - ? `Relatório ${String(metadata.repositorio_url ?? '')} · execução ${metadata.run_id}` + ? `Relatório de IA revisado ${String(metadata.repositorio_url ?? '')} · execução ${metadata.run_id}` : 'Trecho citado de ' + chunk.entidade_tipo; return { id, titulo: title, tipo: chunk.entidade_tipo }; }); diff --git a/backend/src/modules/indexing/indexing.db.test.ts b/backend/src/modules/indexing/indexing.db.test.ts new file mode 100644 index 0000000..a9378e6 --- /dev/null +++ b/backend/src/modules/indexing/indexing.db.test.ts @@ -0,0 +1,299 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { Pool } from "pg"; +import { SearchRepository } from "../search/search.repository.js"; +import { validateTarget } from "../../database/seed-lib.js"; +import { IndexingRepository } from "./indexing.repository.js"; +import { IndexingWorker } from "./indexing.worker.js"; + +test("S2-03: eventos transacionais, reindexação, falha, fencing, arquivamento e isolamento", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test") }); + const repository = new IndexingRepository(db); + const [project, other, epic, feature, pbi, technology] = Array.from({ length: 6 }, () => randomUUID()); + let mode: "ok" | "fail" | "scope" = "ok"; + let requests = 0; + const server = createServer(async (req, res) => { + const buffers = []; + for await (const part of req) buffers.push(part); + const request = JSON.parse(Buffer.concat(buffers).toString()); + requests++; + assert.equal(request.data.project_id, project); + res.setHeader("Content-Type", "application/json"); + res.statusCode = mode === "fail" ? 503 : 200; + res.end(JSON.stringify({ + entity_type: request.entity_type, + content: request.data.title + " " + request.data.description, + metadata: { project_id: mode === "scope" ? other : project }, + embedding: Array(1024).fill(0.1), + })); + }); + await new Promise(r => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as { port: number }).port; + const worker = new IndexingWorker(repository, "http://127.0.0.1:" + port); + const event = async (id: string = pbi) => (await db.query("SELECT * FROM indice_evento WHERE projeto_id=$1 AND entidade_id=$2", [project, id])).rows[0]; + const chunks = async (id: string = pbi) => (await db.query("SELECT * FROM chunk WHERE projeto_id=$1 AND entidade_id=$2", [project, id])).rows; + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,'Teste S2-03','Sintético','ativo'),($2,'Outro','Sintético','ativo')", [project, other]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,'Épico','rascunho')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo,status) VALUES($1,$2,'Feature','rascunho')", [feature, epic]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,status) VALUES($1::uuid,$2,$1::text,'Consultar acervo','PO','consultar','reutilizar','rascunho')", [pbi, feature]); + assert.equal(await event(), undefined); + const client = await db.connect(); + try { + await client.query("BEGIN"); + await client.query("UPDATE pbi SET status='concluido' WHERE id=$1", [pbi]); + assert.equal((await client.query("SELECT count(*)::int n FROM indice_evento WHERE projeto_id=$1", [project])).rows[0].n, 1); + await client.query("ROLLBACK"); + } finally { client.release(); } + assert.equal(await event(), undefined); + + await db.query("UPDATE pbi SET status='concluido' WHERE id=$1", [pbi]); + const published = await event(); + assert.equal(published.payload.project_id, project); + assert.equal(published.payload.level, "pbi"); + assert.equal(published.payload.status, "concluido"); + await worker.tick(project); + assert.equal((await event()).status, "processado"); + assert.equal((await chunks()).length, 1); + await worker.tick(project); + assert.equal(requests, 1); + const originalChunk = (await chunks())[0].id; + + await db.query("SELECT sincronizar_indice_projeto($1)", [project]); + assert.equal((await event()).evento_id, published.evento_id); + await repository.retry(project, published.evento_id); + const queued = await event(); + await repository.retry(project, published.evento_id); + assert.equal((await event()).updated_at.getTime(), queued.updated_at.getTime()); + await worker.tick(project); + assert.equal((await chunks()).length, 1); + assert.equal((await chunks())[0].id, originalChunk); + + await db.query("UPDATE pbi SET titulo='Consultar versão nova' WHERE id=$1", [pbi]); + assert.equal((await chunks()).length, 0); + mode = "fail"; await worker.tick(project); + assert.equal((await event()).status, "falha"); + assert.equal((await event()).erro_codigo, "SERVICE_HTTP_ERROR"); + await repository.retry(project, (await event()).evento_id); + mode = "scope"; await worker.tick(project); + assert.equal((await event()).erro_codigo, "INVALID_SCOPE"); + assert.equal((await chunks()).length, 0); + mode = "ok"; + await repository.retry(project, (await event()).evento_id); + await worker.tick(project); + assert.match((await chunks())[0].texto, /versão nova/); + + await db.query("INSERT INTO tecnologia(id,nome) VALUES($1::uuid,$1::text)", [technology]); + await db.query("INSERT INTO entidade_tecnologia(entidade_tipo,entidade_id,tecnologia_id) VALUES('pbi',$1,$2)", [pbi, technology]); + assert.deepEqual((await event()).payload.tecnologias_ids, [technology]); + await db.query("INSERT INTO criterio_aceitacao(entidade_tipo,entidade_id,nome,dado,quando,entao,ordem) VALUES('pbi',$1,'Busca','Documento indexado','Consultar','Deve encontrar conteúdo',1)", [pbi]); + assert.match((await event()).payload.acceptanceCriteria[0], /encontrar/); + const beforeRename = (await event()).evento_id; + await db.query("UPDATE criterio_aceitacao SET nome='Busca por origem' WHERE entidade_id=$1", [pbi]); + assert.notEqual((await event()).evento_id, beforeRename); + assert.match((await event()).payload.acceptanceCriteria[0], /Busca por origem/); + await worker.tick(project); + + await repository.retry(project, (await event()).evento_id); + const stale = (await repository.claim(project))!; + assert.ok(stale); + assert.equal(await repository.claim(project), null); + await assert.rejects(repository.retry(project, stale.evento_id), /processamento/); + await db.query("UPDATE pbi SET titulo='Consultar terceira versão' WHERE id=$1", [pbi]); + await repository.finish(stale, { content: "Conteúdo atrasado", embedding: Array(1024).fill(0.1) }); + await repository.fail(stale, "PROCESSING_ERROR"); + assert.equal((await event()).status, "pendente"); + assert.equal((await chunks()).length, 0); + await worker.tick(project); + assert.match((await chunks())[0].texto, /terceira versão/); + + await db.query("UPDATE epico SET status='concluido' WHERE id=$1", [epic]); + await db.query("UPDATE feature SET status='concluido' WHERE id=$1", [feature]); + assert.equal((await event(epic)).payload.level, "epico"); + assert.equal((await event(feature)).payload.level, "feature"); + await worker.tick(project); + await worker.tick(project); + assert.equal((await chunks(epic)).length, 1); + assert.equal((await chunks(feature)).length, 1); + const decision = randomUUID(); + await db.query("INSERT INTO decisao(id,entidade_tipo,entidade_id,titulo,contexto,decisao,justificativa) VALUES($1,'feature',$2,'Usar fila','Concorrência','Fila persistida','Recuperação')", [decision, feature]); + assert.equal((await event(decision)).payload.level, "feature"); + await worker.tick(project); + assert.equal((await chunks(decision)).length, 1); + assert.deepEqual((await repository.list(other)).items, []); + await assert.rejects(repository.retry(other, (await event()).evento_id), /não encontrado/); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [other])).rows[0].n, 0); + + const search = new SearchRepository(db); + const searchProject = (id: string) => search.hybridSearch({ query: "versão", projectId: id, limit: 20 }, Array(1024).fill(0.1), 0.5); + assert.ok((await searchProject(project)).some(row => row.entity_id === pbi)); + assert.equal((await searchProject(other)).length, 0); + await db.query("UPDATE decisao SET decisao='Fila revisada' WHERE id=$1", [decision]); + assert.match((await event(decision)).payload.decisionsAndRationale, /revisada/); + await worker.tick(project); + const inFlight = (await event()).evento_id; + await repository.retry(project, inFlight); + const beforeArchive = (await repository.claim(project))!; + await db.query("UPDATE epico SET status='arquivado' WHERE id=$1", [epic]); + assert.equal((await chunks()).length, 0); + assert.equal((await chunks(decision)).length, 0); + assert.equal((await searchProject(project)).length, 0); + await repository.finish(beforeArchive, { content: "Arquivado", embedding: Array(1024).fill(0.1) }); + assert.equal((await chunks()).length, 0); + await worker.tick(project); await worker.tick(project); + assert.equal((await event()).operacao, "remover"); + + await db.query("UPDATE epico SET status='rascunho' WHERE id=$1", [epic]); + const expired = (await repository.claim(project))!; + await db.query("UPDATE indice_evento SET tentativas=5,bloqueado_ate=CURRENT_TIMESTAMP-INTERVAL '1 second' WHERE evento_id=$1", [expired.evento_id]); + await repository.claim(project); + assert.equal((await event(expired.entidade_id)).erro_codigo, "ATTEMPTS_EXHAUSTED"); + await repository.retry(project, expired.evento_id); + await db.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [project]); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 0); + } finally { + server.closeAllConnections(); + await new Promise(r => server.close(() => r())); + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[project, other]]); + await db.query("DELETE FROM tecnologia WHERE id=$1", [technology]); + await db.end(); + } +}); + + +test("S2-03 HTTP: autenticação, escopo, observabilidade e nova tentativa", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const { pool } = await import("../../database/db.js"); + const { app } = await import("../../app.js"); + const { sessionService } = await import("../auth/session.service.js"); + const [project, other, epic, admin, developer] = Array.from({ length: 5 }, () => randomUUID()); + const api = app.listen(0, "127.0.0.1"); + await new Promise(r => api.once("listening", r)); + const base = "http://127.0.0.1:" + (api.address() as { port: number }).port; + try { + await pool.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,'HTTP','Teste','ativo'),($2,'Outro','Teste','ativo')", [project, other]); + await pool.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES($1::uuid,'Admin',$1::text||'@example.test','inutilizado','admin'),($2::uuid,'Dev',$2::text||'@example.test','inutilizado','dev')", [admin, developer]); + const adminSession = await sessionService.createSession(admin); + const devSession = await sessionService.createSession(developer); + const headers = { Authorization: "Bearer " + adminSession.token }; + const path = "/api/v1/projects/" + project + "/index-events"; + assert.equal((await fetch(base + path)).status, 401); + assert.equal((await fetch(base + path, { headers: { Authorization: "Bearer " + devSession.token } })).status, 404); + await pool.query("INSERT INTO epico(id,projeto_id,titulo,descricao,objetivo,escopo_macro,resultado_esperado,status) VALUES($1,$2,'Consultar índice','Descrição','Objetivo','Escopo','Resultado','rascunho')", [epic, project]); + assert.equal((await pool.query("SELECT count(*)::int n FROM indice_evento WHERE projeto_id=$1", [project])).rows[0].n, 0); + const completePath = base + "/api/v1/epics/" + epic + "/complete"; + assert.equal((await fetch(completePath, { method: "PATCH", headers })).status, 400); + assert.equal((await pool.query("SELECT count(*)::int n FROM indice_evento WHERE projeto_id=$1", [project])).rows[0].n, 0); + const criterion = await fetch(base + "/api/v1/criteria", { + method: "POST", headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ entidade_tipo: "epico", entidade_id: epic, texto: "Encontrar origem" }), + }); + assert.equal(criterion.status, 201); + assert.equal((await fetch(completePath, { method: "PATCH", headers })).status, 200); + const listing = await fetch(base + path, { headers }); + assert.equal(listing.status, 200); + const body = await listing.json() as { items: { evento_id: string; status: string; payload?: unknown }[] }; + assert.equal(body.items.length, 1); + assert.equal(body.items[0].status, "pendente"); + assert.equal(body.items[0].payload, undefined); + const developerProfile = randomUUID(); + await pool.query("INSERT INTO desenvolvedor(id,usuario_id) VALUES($1,$2)", [developerProfile, developer]); + await pool.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id,papel) VALUES($1,$2,'Teste')", [developerProfile, project]); + const devHeaders = { Authorization: "Bearer " + devSession.token }; + assert.equal((await fetch(base + path, { headers: devHeaders })).status, 200); + assert.equal((await fetch(base + path + "/" + body.items[0].evento_id + "/reprocess", { method: "POST", headers: devHeaders })).status, 403); + const eventId = body.items[0].evento_id; + assert.equal((await fetch(completePath, { method: "PATCH", headers })).status, 200); + assert.equal((await pool.query("SELECT evento_id FROM indice_evento WHERE projeto_id=$1", [project])).rows[0].evento_id, eventId); + assert.equal((await fetch(base + path + "/" + eventId + "/reprocess", { method: "POST", headers })).status, 202); + assert.equal((await fetch(base + "/api/v1/projects/" + other + "/index-events/" + eventId + "/reprocess", { method: "POST", headers })).status, 404); + assert.equal((await fetch(base + path + "/invalido/reprocess", { method: "POST", headers })).status, 400); + const savedDecision = await fetch(base + "/api/v1/epics/" + epic + "/decisions", { + method: "POST", headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ titulo: "Usar fila", contexto: "Concorrência", decisao: "Fila persistida", justificativa: "Recuperação" }), + }); + assert.equal(savedDecision.status, 201); + const saved = await savedDecision.json() as { id: string }; + const decisionEvent = (await pool.query("SELECT payload FROM indice_evento WHERE entidade_tipo='decisao' AND entidade_id=$1", [saved.id])).rows[0]; + assert.equal(decisionEvent.payload.project_id, project); + assert.equal(decisionEvent.payload.level, "epico"); + await pool.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [project]); + const current = (await pool.query("SELECT evento_id FROM indice_evento WHERE projeto_id=$1", [project])).rows[0].evento_id; + assert.equal((await fetch(base + path + "/" + current + "/reprocess", { method: "POST", headers })).status, 409); + } finally { + api.closeAllConnections(); + await new Promise(r => api.close(() => r())); + await pool.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[project, other]]); + await pool.query("DELETE FROM usuario WHERE id=ANY($1::uuid[])", [[admin, developer]]); + await pool.end(); + } +}); + +test("S2-03 regressão: excluir ancestral em rascunho retira decisões órfãs do índice", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test") }); + const repository = new IndexingRepository(db); + const [project, epic, feature, decision] = Array.from({ length: 4 }, () => randomUUID()); + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,'Regressão','Teste','ativo')", [project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,'Épico','rascunho')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo,status) VALUES($1,$2,'Feature','rascunho')", [feature, epic]); + await db.query("INSERT INTO decisao(id,entidade_tipo,entidade_id,titulo,contexto,decisao,justificativa) VALUES($1,'feature',$2,'Fila','Contexto','Usar fila','Recuperação')", [decision, feature]); + const job = (await repository.claim(project))!; + await repository.finish(job, { content: "Usar fila", embedding: Array(1024).fill(0.1) }); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 1); + await db.query("DELETE FROM feature WHERE id=$1", [feature]); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 0); + assert.equal((await db.query("SELECT operacao FROM indice_evento WHERE projeto_id=$1", [project])).rows[0].operacao, "remover"); + } finally { + await db.query("DELETE FROM projeto WHERE id=$1", [project]); + await db.end(); + } +}); + +test("S2-03 regressão: reserva expirada enquanto aguarda lock não grava resposta", { + skip: !process.env.INDEX_TEST_DATABASE_URL, + timeout: 10000, +}, async () => { + const db = new Pool({ + connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test"), + application_name: "s203-lease-regression", + }); + const repository = new IndexingRepository(db); + const [project, epic] = [randomUUID(), randomUUID()]; + const blocker = await db.connect(); + let finishing: Promise | undefined; + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,'Reserva','Teste','ativo')", [project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,'Épico','concluido')", [epic, project]); + const event = (await repository.claim(project))!; + await blocker.query("BEGIN"); + await blocker.query("SELECT id FROM projeto WHERE id=$1 FOR UPDATE", [project]); + finishing = repository.finish(event, { content: "Resposta atrasada", embedding: Array(1024).fill(0.1) }); + let waiting = false; + for (let i = 0; i < 50; i++) { + const activity = await db.query("SELECT 1 FROM pg_stat_activity WHERE application_name='s203-lease-regression' AND wait_event_type='Lock'"); + if (activity.rowCount) { waiting = true; break; } + await new Promise(r => setTimeout(r, 10)); + } + assert.ok(waiting, "A conclusão deve estar esperando o lock do projeto."); + await db.query("SELECT pg_sleep(0.05)"); + await db.query("UPDATE indice_evento SET bloqueado_ate=clock_timestamp()-INTERVAL '1 millisecond' WHERE evento_id=$1", [event.evento_id]); + await blocker.query("COMMIT"); + await finishing; + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 0); + assert.equal((await db.query("SELECT status FROM indice_evento WHERE evento_id=$1", [event.evento_id])).rows[0].status, "processando"); + } finally { + await blocker.query("ROLLBACK"); + blocker.release(); + await finishing; + await db.query("DELETE FROM projeto WHERE id=$1", [project]); + await db.end(); + } +}); diff --git a/backend/src/modules/indexing/indexing.integration.db.test.ts b/backend/src/modules/indexing/indexing.integration.db.test.ts new file mode 100644 index 0000000..1b99f77 --- /dev/null +++ b/backend/src/modules/indexing/indexing.integration.db.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { IndexingRepository } from "./indexing.repository.js"; +import { IndexingWorker } from "./indexing.worker.js"; +import { SearchRepository } from "../search/search.repository.js"; + +test("S2-03/S2-02: serviço IA real, embedding, substituição e arquivamento", { + skip: !process.env.INDEX_TEST_DATABASE_URL || !process.env.INDEX_AI_ENDPOINT, + timeout: 180000, +}, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test") }); + const [project, epic] = [randomUUID(), randomUUID()]; + const worker = new IndexingWorker(new IndexingRepository(db), process.env.INDEX_AI_ENDPOINT); + const search = new SearchRepository(db); + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,'Integração S2-03','Sintético','ativo')", [project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,objetivo,status) VALUES($1,$2,'Consultar acervo sintético','Preservar requisitos de integração','concluido')", [epic, project]); + await worker.tick(project); + const state = async () => (await db.query("SELECT status,erro_codigo FROM indice_evento WHERE projeto_id=$1", [project])).rows[0]; + assert.equal((await state()).status, "processado", JSON.stringify(await state())); + const original = (await db.query("SELECT id,texto,embedding::text FROM chunk WHERE projeto_id=$1", [project])).rows[0]; + assert.match(original.texto, /Preservar requisitos/); + const embedding = JSON.parse(original.embedding) as number[]; + assert.equal(embedding.length, 1024); + assert.ok(embedding.some(value => value !== 0)); + assert.equal((await search.hybridSearch({ projectId: project, query: "acervo", limit: 5 }, embedding, 0.5)).length, 1); + await db.query("UPDATE epico SET objetivo='Consultar conhecimento atualizado de integração' WHERE id=$1", [epic]); + await worker.tick(project); + assert.equal((await state()).status, "processado"); + const revised = (await db.query("SELECT id,texto FROM chunk WHERE projeto_id=$1", [project])).rows; + assert.equal(revised.length, 1); + assert.equal(revised[0].id, original.id); + assert.match(revised[0].texto, /conhecimento atualizado/); + assert.doesNotMatch(revised[0].texto, /Preservar requisitos/); + await db.query("UPDATE epico SET status='arquivado' WHERE id=$1", [epic]); + assert.equal((await search.hybridSearch({ projectId: project, query: "acervo", limit: 5 }, embedding, 0.5)).length, 0); + console.log("IA real: vetor 1024d, busca, substituição sem duplicação e retirada imediata ao arquivar."); + } finally { + await db.query("DELETE FROM projeto WHERE id=$1", [project]); + await db.end(); + } +}); diff --git a/backend/src/modules/indexing/indexing.migration.db.test.ts b/backend/src/modules/indexing/indexing.migration.db.test.ts new file mode 100644 index 0000000..67ac27e --- /dev/null +++ b/backend/src/modules/indexing/indexing.migration.db.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { applyUntil, withDatabase } from "../../database/migration-test-utils.js"; +import { validateTarget } from "../../database/seed-lib.js"; + +test("S2-03: migration 019 atualiza banco existente e não republica ao reaplicar", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const adminUrl = validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test"); + await withDatabase(adminUrl, async (client, vectorAvailable) => { + assert.ok(vectorAvailable); + await applyUntil(client, true, file => file.startsWith("019_")); + const [project, archivedProject, epic, archivedEpic, nestedEpic, decision, archivedDecision] = Array.from({ length: 7 }, () => randomUUID()); + await client.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1::uuid,$1::text,'Migração','ativo'),($2::uuid,$2::text,'Migração','arquivado')", [project, archivedProject]); + await client.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$4,'Ativo','concluido'),($2,$4,'Arquivado','arquivado'),($3,$5,'Ancestral arquivado','concluido')", [epic, archivedEpic, nestedEpic, project, archivedProject]); + await client.query("INSERT INTO decisao(id,entidade_tipo,entidade_id,titulo,contexto,decisao,justificativa) VALUES($1,'projeto',$3,'Ativa','Contexto','Fila','Recuperação'),($2,'epico',$4,'Arquivada','Contexto','Fila','Recuperação')", [decision, archivedDecision, project, archivedEpic]); + for (const [kind, id, projectId] of [ + ["epico", epic, project], ["epico", epic, project], + ["epico", archivedEpic, project], ["decisao", decision, project], + ["decisao", archivedDecision, project], ["epico", nestedEpic, archivedProject], + ]) { + await client.query("INSERT INTO chunk(projeto_id,entidade_tipo,entidade_id,texto) VALUES($1,$2,$3,'Legado')", [projectId, kind, id]); + } + await applyUntil(client, true, () => false); + const events = (await client.query("SELECT projeto_id,entidade_id,status,evento_id FROM indice_evento ORDER BY entidade_id")).rows; + assert.equal(events.length, 2); + assert.deepEqual(new Set(events.map(row => row.entidade_id)), new Set([epic, decision])); + assert.ok(events.every(row => row.projeto_id === project && row.status === "pendente")); + assert.equal((await client.query("SELECT count(*)::int n FROM chunk")).rows[0].n, 0); + await applyUntil(client, true, () => false); + assert.deepEqual((await client.query("SELECT projeto_id,entidade_id,status,evento_id FROM indice_evento ORDER BY entidade_id")).rows, events); + assert.equal((await client.query("SELECT count(*)::int n FROM decisao")).rows[0].n, 2); + assert.equal((await client.query("SELECT count(*)::int n FROM epico")).rows[0].n, 3); + }); +}); diff --git a/backend/src/modules/indexing/indexing.recovery.db.test.ts b/backend/src/modules/indexing/indexing.recovery.db.test.ts new file mode 100644 index 0000000..fbe5308 --- /dev/null +++ b/backend/src/modules/indexing/indexing.recovery.db.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { IndexingRepository } from "./indexing.repository.js"; + +async function fixture() { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test") }); + const repository = new IndexingRepository(db); + const [project, other, epic, otherEpic] = Array.from({ length: 4 }, () => randomUUID()); + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1::uuid,'Relógio '||$1::text,'Teste','ativo'),($2::uuid,'Outro '||$2::text,'Teste','ativo')", [project, other]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,'Épico','concluido'),($3,$4,'Outro','concluido')", [epic, project, otherEpic, other]); + async function close() { + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[project, other]]); + await db.end(); + } + return { db, repository, project, other, epic, close }; +} + +test("S2-03: diferença no relógio do backend não libera uma reserva ativa", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const { db, repository, project, close } = await fixture(); + const now = Date.now; + try { + const event = (await repository.claim(project))!; + Date.now = () => now() + 10 * 60 * 1000; + await assert.rejects(repository.retry(project, event.evento_id), /em processamento/); + assert.equal((await db.query("SELECT status FROM indice_evento WHERE evento_id=$1", [event.evento_id])).rows[0].status, "processando"); + } finally { + Date.now = now; + await close(); + } +}); + +test("S2-03: claim de um projeto não altera reservas expiradas de outro", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const { db, repository, project, other, close } = await fixture(); + try { + const event = (await repository.claim(other))!; + await db.query("UPDATE indice_evento SET tentativas=5,bloqueado_ate=clock_timestamp()-INTERVAL '1 second' WHERE evento_id=$1", [event.evento_id]); + await repository.claim(project); + assert.equal((await db.query("SELECT status FROM indice_evento WHERE evento_id=$1", [event.evento_id])).rows[0].status, "processando"); + await repository.claim(other); + assert.equal((await db.query("SELECT erro_codigo FROM indice_evento WHERE evento_id=$1", [event.evento_id])).rows[0].erro_codigo, "ATTEMPTS_EXHAUSTED"); + } finally { await close(); } +}); + +test("S2-03: mover origem entre projetos remove índice antigo e descarta resposta atrasada", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const { db, repository, project, other, epic, close } = await fixture(); + const prepared = { content: "Conteúdo sintético", embedding: Array(1024).fill(0.1) }; + try { + const initial = (await repository.claim(project))!; + await repository.finish(initial, prepared); + await repository.retry(project, initial.evento_id); + const stale = (await repository.claim(project))!; + await db.query("UPDATE epico SET projeto_id=$1 WHERE id=$2", [other, epic]); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 0); + await repository.finish(stale, prepared); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 0); + await repository.finish((await repository.claim(other))!, prepared); + await repository.finish((await repository.claim(other))!, prepared); + const moved = (await db.query("SELECT projeto_id,metadados_json FROM chunk WHERE entidade_id=$1", [epic])).rows; + assert.equal(moved.length, 1); + assert.equal(moved[0].projeto_id, other); + assert.equal(moved[0].metadados_json.project_id, other); + } finally { await close(); } +}); + + +test("S2-03: workers concorrentes reservam eventos distintos e não duplicam uma origem", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const { db, repository, project, close } = await fixture(); + try { + const firstWave = await Promise.all([repository.claim(project), repository.claim(project)]); + assert.equal(firstWave.filter(Boolean).length, 1); + const first = firstWave.find(event => event !== null)!; + await repository.finish(first, { content: "Concorrência", embedding: Array(1024).fill(0.1) }); + const ids = [randomUUID(), randomUUID()]; + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$3,'Segundo','concluido'),($2,$3,'Terceiro','concluido')", [...ids, project]); + const secondWave = await Promise.all([repository.claim(project), repository.claim(project)]); + assert.ok(secondWave[0] && secondWave[1]); + assert.notEqual(secondWave[0].evento_id, secondWave[1].evento_id); + await Promise.all(secondWave.map(event => repository.finish(event!, { content: "Concorrência", embedding: Array(1024).fill(0.1) }))); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 3); + } finally { await close(); } +}); + +test("S2-03: falha na gravação preserva o índice anterior e permite recuperação", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async () => { + const { db, repository, project, close } = await fixture(); + const prepared = { content: "Versão válida", embedding: Array(1024).fill(0.1) }; + try { + const first = (await repository.claim(project))!; + await repository.finish(first, prepared); + const before = (await db.query("SELECT id,texto FROM chunk WHERE projeto_id=$1", [project])).rows; + await repository.retry(project, first.evento_id); + const retry = (await repository.claim(project))!; + await assert.rejects(repository.finish(retry, { content: "Não deve persistir", embedding: [1] })); + await repository.fail(retry, "PROCESSING_ERROR"); + assert.deepEqual((await db.query("SELECT id,texto FROM chunk WHERE projeto_id=$1", [project])).rows, before); + assert.equal((await db.query("SELECT status FROM indice_evento WHERE evento_id=$1", [first.evento_id])).rows[0].status, "falha"); + await repository.retry(project, first.evento_id); + await repository.finish((await repository.claim(project))!, prepared); + assert.deepEqual((await db.query("SELECT id,texto FROM chunk WHERE projeto_id=$1", [project])).rows, before); + } finally { await close(); } +}); diff --git a/backend/src/modules/indexing/indexing.state.db.test.ts b/backend/src/modules/indexing/indexing.state.db.test.ts new file mode 100644 index 0000000..4d29641 --- /dev/null +++ b/backend/src/modules/indexing/indexing.state.db.test.ts @@ -0,0 +1,222 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { IndexingRepository, type IndexEvent } from "./indexing.repository.js"; + +type Kind = "projeto" | "epico" | "feature" | "pbi" | "decisao"; +interface Source { id: string; kind: Kind; parent?: string; status: string; title: string } +function random(seed: number) { + let state = seed; + return (max: number) => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return Math.floor(state / 0x100000000 * max); }; +} + +test("S2-03: sequências combinadas mantêm o índice equivalente às origens", { + skip: !process.env.INDEX_TEST_DATABASE_URL, + timeout: 180000, +}, async t => { + const firstSeed = Number(process.env.INDEX_FIRST_SEED ?? 1); + for (let seed = firstSeed; seed < firstSeed + 8; seed++) { + await t.test("sequência " + seed + ": 80 operações", async () => { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test") }); + const repository = new IndexingRepository(db); + const sources = new Map(); + const projects: string[] = []; + const flights: IndexEvent[] = []; + const pick = random(seed); + const vector = Array(1024).fill(0.1); + const technology = randomUUID(); + const links = new Set(); + const criteria = new Map(); + let technologyName = "Tecnologia " + technology; + function all(kind?: Kind) { return [...sources.values()].filter(s => !kind || s.kind === kind); } + function choose(values: T[]) { return values[pick(values.length)]; } + function ancestry(source: Source): Source[] { + const chain = [source]; + let cursor = source; + while (cursor.parent) { + const parent = sources.get(cursor.parent); + if (!parent) return []; + chain.push(parent); + cursor = parent; + } + return cursor.kind === "projeto" ? chain : []; + } + function scope(source: Source) { + const chain = ancestry(source); + const active = chain.length > 0 && chain.every(s => s.status !== "arquivado"); + return { + project: chain.at(-1)?.id, + eligible: active && (source.kind === "decisao" || source.status === "concluido"), + level: source.kind === "decisao" ? sources.get(source.parent!)?.kind : source.kind, + }; + } + function prepare(event: IndexEvent) { + return { content: String(event.payload.title) + " " + String(event.payload.description), embedding: vector }; + } + async function claimLocal() { + for (const project of projects) { + const event = await repository.claim(project); + if (event) return event; + } + return null; + } + async function settle() { + for (const flight of flights.splice(0)) await repository.finish(flight, prepare(flight)); + // Simula recuperação após interrupção sem alterar a versão da origem. + await db.query("UPDATE indice_evento SET bloqueado_ate=clock_timestamp()-INTERVAL '1 second' WHERE projeto_id=ANY($1::uuid[]) AND status='processando'", [projects]); + await db.query("UPDATE indice_evento SET proxima_tentativa=clock_timestamp(),tentativas=0 WHERE projeto_id=ANY($1::uuid[]) AND status='falha'", [projects]); + for (let i = 0; i < 100; i++) { + const event = await claimLocal(); + if (!event) return; + await repository.finish(event, prepare(event)); + } + assert.fail("A fila não esvaziou."); + } + async function invariant(drained = false) { + const rows = (await db.query("SELECT entidade_id,entidade_tipo,projeto_id,metadados_json,vector_dims(embedding) AS dimensions FROM chunk WHERE projeto_id=ANY($1::uuid[])", [projects])).rows; + const seen = new Set(); + for (const row of rows) { + const source = sources.get(row.entidade_id); + assert.ok(source, "Origem excluída não pode continuar indexada."); + const expected = scope(source); + assert.ok(expected.eligible, "Origem em rascunho/arquivada não pode estar indexada."); + assert.equal(row.projeto_id, expected.project); + assert.equal(row.metadados_json.project_id, expected.project); + assert.equal(row.metadados_json.level, expected.level); + assert.equal(row.metadados_json.title, source.title); + assert.equal(row.metadados_json.status, source.kind === "decisao" ? sources.get(source.parent!)!.status : source.status); + assert.equal(row.metadados_json.entity_id, source.id); + assert.deepEqual(row.metadados_json.ancestor_ids, ancestry(source).filter(s => s.kind !== "decisao").map(s => s.id).reverse()); + const technologySource = source.kind === "decisao" ? source.parent! : source.id; + assert.deepEqual(row.metadados_json.acceptanceCriteria, criteria.get(technologySource) ?? []); + assert.deepEqual(row.metadados_json.tecnologias_ids, links.has(technologySource) ? [technology] : []); + if (links.has(technologySource)) assert.equal(row.metadados_json.technologies[0].nome, technologyName); + assert.equal(row.dimensions, 1024); + const key = row.projeto_id + ":" + row.entidade_tipo + ":" + row.entidade_id; + assert.ok(!seen.has(key), "Origem duplicada."); + seen.add(key); + } + if (drained) { + const expected = all().filter(s => s.kind !== "projeto" && scope(s).eligible).length; + assert.equal(rows.length, expected, "Toda origem elegível deve estar indexada após esvaziar a fila."); + } + } + async function create(kind: Kind, parent?: string) { + const id = randomUUID(), title = "Sintético " + id; + const source: Source = { id, kind, parent, title, status: kind === "projeto" ? "ativo" : "concluido" }; + if (kind === "projeto") { + projects.push(id); + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,$2,'Estado','ativo')", [id, title]); + } else if (kind === "epico") { + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,$3,'concluido')", [id, parent, title]); + } else if (kind === "feature") { + await db.query("INSERT INTO feature(id,epico_id,titulo,status) VALUES($1,$2,$3,'concluido')", [id, parent, title]); + } else if (kind === "pbi") { + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,status) VALUES($1::uuid,$2,$1::text,$3,'PO','consultar','reutilizar','concluido')", [id, parent, title]); + } else { + await db.query("INSERT INTO decisao(id,entidade_tipo,entidade_id,titulo,contexto,decisao,justificativa) VALUES($1,$2,$3,$4,'Contexto','Usar fila','Recuperação')", [id, sources.get(parent!)!.kind, parent, title]); + } + sources.set(id, source); + return source; + } + try { + for (let i = 0; i < 2; i++) { + const project = await create("projeto"); + const epic = await create("epico", project.id); + const feature = await create("feature", epic.id); + const pbi = await create("pbi", feature.id); + await create("decisao", project.id); + await create("decisao", feature.id); + await create("decisao", pbi.id); + } + await db.query("INSERT INTO tecnologia(id,nome) VALUES($1,$2)", [technology, technologyName]); + await settle(); await invariant(true); + for (let step = 0; step < 80; step++) { + const operation = pick(14); + const source = choose(all()); + if (operation === 0 && source.kind !== "decisao") { + source.status = choose(source.kind === "projeto" ? ["ativo", "arquivado"] : ["rascunho", "concluido", "arquivado"]); + await db.query("UPDATE " + source.kind + " SET status=$1 WHERE id=$2", [source.status, source.id]); + } else if (operation === 1 && source.kind !== "projeto") { + source.title = "Revisão " + seed + "-" + step; + await db.query("UPDATE " + source.kind + " SET titulo=$1 WHERE id=$2", [source.title, source.id]); + } else if (operation === 2 && source.kind !== "projeto") { + if (source.kind === "decisao") { + const parent = choose(all().filter(s => s.kind !== "decisao")); + await db.query("UPDATE decisao SET entidade_tipo=$1,entidade_id=$2 WHERE id=$3", [parent.kind, parent.id, source.id]); + source.parent = parent.id; + } else { + const parentKind = { epico: "projeto", feature: "epico", pbi: "feature" }[source.kind] as Kind; + const column = { epico: "projeto_id", feature: "epico_id", pbi: "feature_id" }[source.kind]; + const parent = choose(all(parentKind)); + await db.query("UPDATE " + source.kind + " SET " + column + "=$1 WHERE id=$2", [parent.id, source.id]); + source.parent = parent.id; + } + } else if (operation === 3) { + const client = await db.connect(); + try { + await client.query("BEGIN"); + await client.query("UPDATE " + source.kind + " SET " + (source.kind === "projeto" ? "nome" : "titulo") + "='Rollback' WHERE id=$1", [source.id]); + await client.query("ROLLBACK"); + } finally { client.release(); } + } else if (operation === 4) { + const event = await claimLocal(); + if (event) flights.push(event); + } else if (operation === 5 && flights.length) { + const flight = flights.splice(pick(flights.length), 1)[0]; + await repository.finish(flight, prepare(flight)); + await repository.finish(flight, prepare(flight)); + } else if (operation === 6 && flights.length) { + const flight = flights.splice(pick(flights.length), 1)[0]; + await repository.fail(flight, "SERVICE_UNAVAILABLE"); + } else if (operation === 7 && source.kind !== "decisao") { + if (links.has(source.id)) { + await db.query("DELETE FROM entidade_tecnologia WHERE entidade_tipo=$1 AND entidade_id=$2", [source.kind, source.id]); + links.delete(source.id); + } else { + await db.query("INSERT INTO entidade_tecnologia(entidade_tipo,entidade_id,tecnologia_id) VALUES($1,$2,$3)", [source.kind, source.id, technology]); + links.add(source.id); + } + } else if (operation === 8) { + technologyName = "Stack " + technology + "-" + step; + await db.query("UPDATE tecnologia SET nome=$1 WHERE id=$2", [technologyName, technology]); + } else if (operation === 9) { + const decision = choose(all("decisao")); + if (decision) { + await db.query("DELETE FROM decisao WHERE id=$1", [decision.id]); + sources.delete(decision.id); + } else await create("decisao", choose(all("pbi")).id); + } else if (operation === 10 && flights.length) { + const flight = choose(flights); + await db.query("UPDATE indice_evento SET bloqueado_ate=clock_timestamp()-INTERVAL '1 second' WHERE evento_id=$1 AND lease_id=$2", [flight.evento_id, flight.lease_id]); + } else if (operation === 12) { + const pbi = choose(all("pbi")); + if (pbi) { + await db.query("INSERT INTO criterio_aceitacao(entidade_tipo,entidade_id,nome,dado,quando,entao,ordem) VALUES('pbi',$1,$2,'Acervo','Consultar','Encontrar origem',$3)", [pbi.id, "Cenário " + seed + "-" + step, step + 1]); + criteria.set(pbi.id, [...(criteria.get(pbi.id) ?? []), "Cenário " + seed + "-" + step + " Acervo Consultar Encontrar origem"]); + } + } else if (operation === 13) { + await db.query("DELETE FROM tecnologia WHERE id=$1", [technology]); + links.clear(); + await db.query("INSERT INTO tecnologia(id,nome) VALUES($1,$2)", [technology, technologyName]); + } else if (operation === 11) { + const candidate = (await db.query("SELECT projeto_id,evento_id FROM indice_evento WHERE projeto_id=ANY($1::uuid[]) AND status='processado' ORDER BY evento_id LIMIT 1", [projects])).rows[0]; + if (candidate && sources.get(candidate.projeto_id)?.status !== "arquivado") { + await repository.retry(candidate.projeto_id, candidate.evento_id); + await repository.retry(candidate.projeto_id, candidate.evento_id); + } + } + await invariant(); + if (step % 10 === 9) { await settle(); await invariant(true); } + } + await settle(); await invariant(true); + } finally { + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [projects]); + await db.query("DELETE FROM tecnologia WHERE id=$1", [technology]); + await db.end(); + } + }); + } +}); diff --git a/backend/src/modules/indexing/indexing.transport.db.test.ts b/backend/src/modules/indexing/indexing.transport.db.test.ts new file mode 100644 index 0000000..a87a9e5 --- /dev/null +++ b/backend/src/modules/indexing/indexing.transport.db.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { IndexingRepository } from "./indexing.repository.js"; +import { IndexingWorker } from "./indexing.worker.js"; + +test("S2-03: respostas inválidas e erros HTTP não gravam trechos e permitem recuperação", { + skip: !process.env.INDEX_TEST_DATABASE_URL, +}, async t => { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, "test") }); + const repository = new IndexingRepository(db); + const [project, epic] = [randomUUID(), randomUUID()]; + let mode = "ok"; + const server = createServer((_req, res) => { + res.setHeader("Content-Type", "application/json"); + if (mode.startsWith("http-")) { + res.statusCode = Number(mode.slice(5)); + res.end(JSON.stringify({ message: "Não deve ser persistido: segredo" })); + return; + } + const response = { + entity_type: mode === "wrong-type" ? "pbi" : "epico", + content: mode === "empty" ? "" : "Conteúdo sintético", + metadata: { project_id: mode === "wrong-project" ? randomUUID() : project }, + embedding: mode === "short-vector" ? [1] : Array(1024).fill(mode === "zero-vector" ? 0 : 0.1), + }; + res.end(mode === "invalid-json" ? "{" : JSON.stringify(response)); + }); + await new Promise(r => server.listen(0, "127.0.0.1", r)); + const worker = new IndexingWorker(repository, "http://127.0.0.1:" + (server.address() as { port: number }).port); + const state = async () => (await db.query("SELECT status,erro_codigo,evento_id FROM indice_evento WHERE projeto_id=$1", [project])).rows[0]; + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,$1::uuid::text,'Transporte','ativo')", [project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES($1,$2,'Consultar acervo','concluido')", [epic, project]); + for (const scenario of ["empty", "short-vector", "zero-vector", "invalid-json", "wrong-type", "wrong-project", "http-401", "http-429", "http-503"]) { + await t.test(scenario, async () => { + mode = scenario; + await repository.retry(project, (await state()).evento_id); + await worker.tick(project); + const failed = await state(); + assert.equal(failed.status, "falha"); + const expected = scenario.startsWith("http-") ? "SERVICE_HTTP_ERROR" + : scenario.startsWith("wrong-") ? "INVALID_SCOPE" : "INVALID_RESPONSE"; + assert.equal(failed.erro_codigo, expected); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 0); + assert.doesNotMatch(JSON.stringify(await repository.list(project)), /segredo/); + }); + } + mode = "ok"; + await repository.retry(project, (await state()).evento_id); + await worker.tick(project); + assert.equal((await state()).status, "processado"); + assert.equal((await state()).erro_codigo, null); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 1); + } finally { + server.closeAllConnections(); + await new Promise(r => server.close(() => r())); + await db.query("DELETE FROM projeto WHERE id=$1", [project]); + await db.end(); + } +}); diff --git a/backend/src/modules/indexing/indexing.worker.test.ts b/backend/src/modules/indexing/indexing.worker.test.ts new file mode 100644 index 0000000..7fc512b --- /dev/null +++ b/backend/src/modules/indexing/indexing.worker.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { indexingFailureCode, validatePreparedEntity } from "./indexing.worker.js"; + +test("valida vetor, conteúdo e escopo retornados pela IA", () => { + const project = randomUUID(); + const data = { entity_type: "pbi", content: "Requisito", metadata: { project_id: project }, embedding: Array(1024).fill(0.1) }; + assert.equal(validatePreparedEntity(data, "pbi", project).content, "Requisito"); + for (const invalid of [ + { ...data, content: "" }, { ...data, embedding: [1] }, + { ...data, embedding: Array(1024).fill(0) }, { ...data, embedding: Array(1024).fill(Infinity) }, + { ...data, metadata: { project_id: randomUUID() } }, { ...data, entity_type: "decisao" }, + ]) assert.throws(() => validatePreparedEntity(invalid, "pbi", project)); +}); + +test("diagnósticos não expõem mensagens, corpo HTTP ou credenciais", () => { + assert.equal(indexingFailureCode({ isAxiosError: true, code: "ECONNABORTED" }), "TIMEOUT"); + assert.equal(indexingFailureCode({ isAxiosError: true, response: { status: 503, data: "segredo" } }), "SERVICE_HTTP_ERROR"); + assert.equal(indexingFailureCode(new Error("token secreto")), "PROCESSING_ERROR"); + assert.equal(indexingFailureCode(new Error("INVALID_SCOPE")), "INVALID_SCOPE"); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.indexing.db.test.ts b/backend/src/modules/repo-analyses/repo-analyses.indexing.db.test.ts new file mode 100644 index 0000000..3d538a1 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.indexing.db.test.ts @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { applyUntil, withDatabase } from '../../database/migration-test-utils.js'; +import { validateTarget } from '../../database/seed-lib.js'; +import { ReportIndexingRepository } from './repo-analyses.indexing.repository.js'; +import { SearchRepository } from '../search/search.repository.js'; +import { ChatRepository } from '../chat/chat.repository.js'; +import { AdminRepository } from '../admin/admin.repository.js'; + +test('S2-23 PostgreSQL: fila atomica, idempotencia, revisao, recuperacao, fencing, arquivo e isolamento', { skip: !process.env.INDEX_TEST_DATABASE_URL }, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.INDEX_TEST_DATABASE_URL, 'test') }); + const repository = new ReportIndexingRepository(db); + const [project, other, user, analysis] = Array.from({ length: 4 }, () => randomUUID()); + const vector = Array(1024).fill(0.1); + const chunk = { chunk_index: 0, text: 'Arquitetura PostgreSQL do relatorio', embedding: vector, metadata: { run_id: analysis } }; + const count = async () => Number((await db.query('SELECT count(*) AS n FROM chunk WHERE entidade_id=$1', [analysis])).rows[0].n); + const approve = async () => repository.approveReport(analysis, project, user, (await record()).relatorio_revisao); + const record = async () => (await db.query('SELECT * FROM analise_repositorio WHERE id=$1', [analysis])).rows[0]; + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1,'QA S223','QA','ativo'),($2,'QA Outro','QA','ativo')", [project, other]); + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'QA',$2,'test','po')", [user, user + '@test.local']); + await db.query("INSERT INTO analise_repositorio(id,projeto_id,usuario_id,repositorio_url,run_id,status) VALUES ($1,$2,$3,'https://github.com/test/repo',$4,'em_execucao')", [analysis, project, user, 'run-' + analysis]); + await db.query("UPDATE analise_repositorio SET status='concluido',relatorio_markdown='Versao 1' WHERE id=$1", [analysis]); + assert.equal((await record()).indexacao_status, 'aguardando_aprovacao'); + assert.equal(await repository.claimPendingIndexing(), null); + await assert.rejects(repository.retryIndexing(analysis, project)); + await assert.rejects(repository.approveReport(analysis, project, user, 999)); + await db.query("INSERT INTO chunk(projeto_id,entidade_tipo,entidade_id,texto,embedding) VALUES($1,'analise_repositorio',$2,'Arquitetura sem revisao',$3::vector)", [project,analysis,JSON.stringify(vector)]); + assert.deepEqual(await new SearchRepository(db).hybridSearch({projectId:project,query:'Arquitetura',limit:10},vector,0,0),[]); + assert.deepEqual(await new ChatRepository(db).searchChunks(project,['%Arquitetura%'],5),[]); + await approve(); + let claim = (await repository.claimPendingIndexing())!; assert.equal(claim.id, analysis); + await repository.completeIndexing(claim, [chunk]); assert.equal(await count(), 1); + const approvedVersion = await record(); + const transaction = await db.connect(); + try { + await transaction.query('BEGIN'); + await transaction.query("UPDATE analise_repositorio SET relatorio_markdown='Nao deve persistir' WHERE id=$1", [analysis]); + const changed = (await transaction.query('SELECT * FROM analise_repositorio WHERE id=$1',[analysis])).rows[0]; + assert.equal(changed.indexacao_status,'aguardando_aprovacao'); + assert.equal(changed.aprovacao_hash,null); + assert.equal((await transaction.query('SELECT count(*)::int AS n FROM chunk WHERE entidade_id=$1',[analysis])).rows[0].n,0); + await transaction.query("UPDATE analise_repositorio SET aprovacao_hash=encode(sha256(convert_to(relatorio_markdown,'UTF8')),'hex'),aprovado_por=$2,aprovado_em=NOW() WHERE id=$1",[analysis,user]); + assert.equal((await transaction.query('SELECT indexacao_status FROM analise_repositorio WHERE id=$1',[analysis])).rows[0].indexacao_status,'pendente'); + await transaction.query('ROLLBACK'); + } finally { transaction.release(); } + assert.equal((await record()).relatorio_markdown, 'Versao 1'); + assert.equal((await record()).indexacao_status, 'indexado'); + assert.equal((await record()).aprovacao_hash, approvedVersion.aprovacao_hash); + assert.equal((await record()).aprovado_por, user); + assert.equal(await count(), 1); + await db.query('UPDATE analise_repositorio SET relatorio_markdown=NULL WHERE id=$1', [analysis]); + assert.equal(await count(), 0); + assert.equal((await record()).indexacao_status, 'nao_aplicavel'); + await db.query("UPDATE analise_repositorio SET relatorio_markdown='Versao 1' WHERE id=$1", [analysis]); + await approve(); + claim = (await repository.claimPendingIndexing())!; + await repository.completeIndexing(claim, [chunk]); + assert.equal(await count(), 1); + await repository.retryIndexing(analysis, project); + claim = (await repository.claimPendingIndexing())!; + await repository.retryIndexing(analysis, project); + assert.equal((await record()).indexacao_lease_id, claim.indexacao_lease_id); + await repository.completeIndexing(claim, [chunk]); assert.equal(await count(), 1); + const second = randomUUID(); + await db.query("INSERT INTO analise_repositorio(id,projeto_id,usuario_id,repositorio_url,run_id,status,relatorio_markdown) VALUES($1,$2,$3,'https://github.com/test/second',$4,'concluido','Concorrencia')",[second,project,user,'second-'+second]); + await repository.approveReport(second,project,user,1); + await repository.retryIndexing(analysis,project); + const anotherWorker=new ReportIndexingRepository(db); + const claims=await Promise.all([repository.claimPendingIndexing(),anotherWorker.claimPendingIndexing()]); + assert.ok(claims.every(Boolean)); + assert.deepEqual(new Set(claims.map(item=>item!.id)),new Set([analysis,second])); + assert.equal(await anotherWorker.claimPendingIndexing(),null); + await Promise.all(claims.map(item=>repository.completeIndexing(item!,[chunk]))); + await db.query('DELETE FROM analise_repositorio WHERE id=$1',[second]); + const search = new SearchRepository(db); + const query = { projectId: project, query: 'Arquitetura', limit: 10 }; + const hits = await search.hybridSearch(query, vector, 0, 0); + assert.equal(hits.length, 1); assert.equal(hits[0].source_url, `/projects/${project}#repo-analyzer=${analysis}`); + assert.equal((await search.hybridSearch({ ...query, projectId: other }, vector, 0, 0)).length, 0); + await repository.retryIndexing(analysis, other); assert.equal((await record()).indexacao_status, 'indexado'); + await repository.retryIndexing(analysis, project); claim = (await repository.claimPendingIndexing())!; + await db.query("UPDATE analise_repositorio SET relatorio_markdown='Versao 2' WHERE id=$1", [analysis]); + assert.equal(await count(), 0); assert.equal((await record()).indexacao_status, 'aguardando_aprovacao'); + await repository.completeIndexing(claim, [chunk]); assert.equal(await count(), 0); + await approve(); + claim = (await repository.claimPendingIndexing())!; + await db.query("UPDATE analise_repositorio SET indexacao_bloqueado_ate=NOW()-INTERVAL '1 second' WHERE id=$1", [analysis]); + await repository.completeIndexing(claim, [chunk]); assert.equal(await count(), 0); + claim = (await repository.claimPendingIndexing())!; + await repository.failIndexing(claim, 'TIMEOUT'); + assert.equal(await repository.claimPendingIndexing(), null); + await db.query("UPDATE analise_repositorio SET indexacao_tentativas=4,indexacao_bloqueado_ate=NULL WHERE id=$1", [analysis]); + claim = (await repository.claimPendingIndexing())!; await repository.failIndexing(claim, 'TIMEOUT'); + assert.equal((await record()).indexacao_status, 'falha'); + await repository.retryIndexing(analysis, project); claim = (await repository.claimPendingIndexing())!; + await repository.completeIndexing(claim, [chunk]); assert.equal(await count(), 1); + await repository.retryIndexing(analysis, project); claim = (await repository.claimPendingIndexing())!; + const revisionBeforePurge = (await record()).relatorio_revisao; + await new AdminRepository(db).purgeProjectContext(project,'QA S223',user,'QA isolado'); + assert.equal((await record()).aprovacao_hash,null); + assert.equal((await record()).indexacao_status,'aguardando_aprovacao'); + await assert.rejects(repository.approveReport(analysis,project,user,revisionBeforePurge),/relatório mudou/); + await repository.completeIndexing(claim,[chunk]); assert.equal(await count(),0); + assert.equal(await repository.claimPendingIndexing(),null); + await approve(); claim=(await repository.claimPendingIndexing())!; + await repository.completeIndexing(claim,[chunk]); assert.equal(await count(),1); + await repository.retryIndexing(analysis, project); claim = (await repository.claimPendingIndexing())!; + await db.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [project]); + assert.equal(await count(), 0); + await repository.completeIndexing(claim, [chunk]); assert.equal(await count(), 0); + await assert.rejects(repository.retryIndexing(analysis, project)); + assert.equal(await repository.claimPendingIndexing(), null); + } finally { + await db.query('DELETE FROM projeto WHERE id=ANY($1::uuid[])', [[project, other]]); + await db.query('DELETE FROM usuario WHERE id=$1', [user]); + await db.end(); + } +}); + +test('S2-23: backfill remove apenas relatórios legados e exige revisão humana', {skip:!process.env.INDEX_TEST_DATABASE_URL},async()=>{ + await withDatabase(process.env.INDEX_TEST_DATABASE_URL!,async(client,vectorAvailable)=>{ + await applyUntil(client,vectorAvailable,file=>file.startsWith('022_')); + const [project,user,analysis]=Array.from({length:3},()=>randomUUID()); + await client.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1,'Legado QA','QA','ativo')",[project]); + await client.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES($1,'QA',$2,'test','po')",[user,user+'@test.local']); + await client.query("INSERT INTO analise_repositorio(id,projeto_id,usuario_id,repositorio_url,run_id,status,relatorio_markdown) VALUES($1,$2,$3,'https://github.com/test/legacy',$4,'concluido','Legado')",[analysis,project,user,'legacy-'+analysis]); + await client.query("INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto) VALUES($1,$2,'analise_repositorio',$3,'Legado'),($4,$2,'pbi',$5,'Preservar')",[randomUUID(),project,analysis,randomUUID(),randomUUID()]); + await client.query("UPDATE analise_repositorio SET indexacao_status='indexado' WHERE id=$1",[analysis]); + await applyUntil(client,vectorAvailable,()=>false); + const report=(await client.query('SELECT * FROM analise_repositorio WHERE id=$1',[analysis])).rows[0]; + assert.equal(report.indexacao_status,'aguardando_aprovacao'); + assert.equal(report.aprovacao_hash,null);assert.equal(report.aprovado_por,null); + const chunks=(await client.query('SELECT entidade_tipo FROM chunk WHERE projeto_id=$1',[project])).rows; + assert.deepEqual(chunks,[{entidade_tipo:'pbi'}]); + }); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.indexing.repository.ts b/backend/src/modules/repo-analyses/repo-analyses.indexing.repository.ts index e62bc6e..8dcfd25 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.indexing.repository.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.indexing.repository.ts @@ -15,6 +15,9 @@ export interface PendingReportIndexing { relatorio_markdown: string; indexacao_lease_id: string; indexacao_tentativas: number; + aprovacao_hash?: string; + aprovado_por?: string; + aprovado_em?: string; metadados?: Record; } @@ -61,13 +64,15 @@ export class ReportIndexingRepository { AND indexacao_tentativas < 5 AND relatorio_markdown IS NOT NULL AND status = 'concluido' + AND aprovacao_hash=encode(sha256(convert_to(relatorio_markdown,'UTF8')),'hex') + AND aprovado_por IS NOT NULL AND aprovado_em IS NOT NULL AND EXISTS (SELECT 1 FROM projeto p WHERE p.id=analise_repositorio.projeto_id AND p.status<>'arquivado') ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING id, projeto_id, run_id, repositorio_url, perfil, - relatorio_markdown, indexacao_lease_id, indexacao_tentativas, metadados`, + relatorio_markdown, indexacao_lease_id, indexacao_tentativas, metadados, aprovacao_hash, aprovado_por, aprovado_em`, [randomUUID()], ); return result.rows[0] ?? null; @@ -90,6 +95,7 @@ export class ReportIndexingRepository { AND indexacao_lease_id = $3 AND indexacao_status = 'processando' AND status = 'concluido' + AND aprovacao_hash=encode(sha256(convert_to(relatorio_markdown,'UTF8')),'hex') AND EXISTS (SELECT 1 FROM projeto p WHERE p.id=analise_repositorio.projeto_id AND p.status<>'arquivado') AND indexacao_bloqueado_ate > clock_timestamp() FOR UPDATE`, @@ -155,6 +161,19 @@ export class ReportIndexingRepository { ); } + async approveReport(analysisId: string, projectId: string, userId: string, expectedRevision: number): Promise { + await withTransaction(this.db, async client => { + await lockHierarchy(client, 'projeto', projectId); + await assertWritable(client, 'projeto', projectId); + const current = (await client.query('SELECT * FROM analise_repositorio WHERE id=$1 AND projeto_id=$2 FOR UPDATE', [analysisId, projectId])).rows[0]; + if (!current) return; + if (current.status !== 'concluido' || !current.relatorio_markdown?.trim()) throw new ConflictError('O relatório ainda não está disponível para revisão.'); + if (current.relatorio_revisao !== expectedRevision) throw new ConflictError('O relatório mudou. Atualize a página e revise a nova versão.'); + if (current.aprovacao_hash) return; + await client.query("UPDATE analise_repositorio SET aprovacao_hash=encode(sha256(convert_to(relatorio_markdown,'UTF8')),'hex'),aprovado_por=$3,aprovado_em=NOW() WHERE id=$1 AND projeto_id=$2", [analysisId, projectId, userId]); + }); + } + // Repetir uma solicitação ativa preserva sua reserva. async retryIndexing(analysisId: string, projectId: string): Promise { await withTransaction(this.db, async client => { @@ -162,6 +181,7 @@ export class ReportIndexingRepository { await assertWritable(client, 'projeto', projectId); const current = (await client.query('SELECT * FROM analise_repositorio WHERE id=$1 AND projeto_id=$2 FOR UPDATE', [analysisId, projectId])).rows[0]; if (!current) return; + if (!current.aprovacao_hash) throw new ConflictError('Revise e aprove o relatório antes de publicá-lo no acervo.'); if (current.indexacao_status === 'pendente' || current.indexacao_status === 'processando') return; if (current.status !== 'concluido' || !current.relatorio_markdown?.trim()) throw new ConflictError('A análise ainda não possui relatório concluído.'); await client.query( diff --git a/backend/src/modules/repo-analyses/repo-analyses.indexing.test.ts b/backend/src/modules/repo-analyses/repo-analyses.indexing.test.ts new file mode 100644 index 0000000..8fd6d56 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.indexing.test.ts @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import axios from 'axios'; +import { HttpDocumentIngestionClient } from '../documents/document-ingestion.js'; +import { ReportIndexingWorker } from './repo-analyses.indexing.worker.js'; +import { ReportIndexingRepository } from './repo-analyses.indexing.repository.js'; + +const source = { id: '10000000-0000-4000-8000-000000000001', projeto_id: '20000000-0000-4000-8000-000000000001', run_id: 'run-42', repositorio_url: 'https://github.com/test/repo', perfil: 'quick', relatorio_markdown: '# Relatorio\nArquivo src/app.ts', indexacao_lease_id: 'lease', indexacao_tentativas: 1, aprovado_por: 'reviewer-po', aprovado_em: '2026-10-21T12:00:00Z', metadados: { source_commit: 'a'.repeat(40), source_ref: 'main', analyzed_paths: ['src/app.ts', 'outro.ts'] } }; +const filename = `relatorio-${source.id}.md`; +function response() { + return { document_id: source.id, project_id: source.projeto_id, chunks: [{ chunk_index: 0, text: 'Arquivo src/app.ts', embedding: Array(1024).fill(0.1), metadata: { project_id: source.projeto_id, document_id: source.id, source_name: filename } }] }; +} +function setup() { + const saved: any[] = [], failed: string[] = []; + const repository = { claimPendingIndexing: async () => source, completeIndexing: async (_: unknown, chunks: unknown) => { saved.push(chunks); }, failIndexing: async (_: unknown, code: string) => { failed.push(code); } } as unknown as ReportIndexingRepository; + const client = new HttpDocumentIngestionClient('http://test', 'x'.repeat(32)); + return { saved, failed, worker: new ReportIndexingWorker(repository, 'http://test', client) }; +} + +test('S2-23: reutiliza o pipeline de documentos e preserva origem, versao e caminhos citados', async t => { + t.mock.method(axios, 'post', async (url: string, payload: any, options: any) => { + assert.equal(url, 'http://test/documents/process'); + assert.equal(Buffer.from(payload.content_base64, 'base64').toString(), source.relatorio_markdown); + assert.equal(options.headers['X-Document-Ingestion-Token'], 'x'.repeat(32)); + return { data: response() }; + }); + const { worker, saved, failed } = setup(); + await worker.tick(); + assert.equal(saved.length, 1); assert.deepEqual(failed, []); + const meta = saved[0][0].metadata; + assert.equal(meta.provenance, 'ai-accepted'); assert.equal(meta.reviewed_by, source.aprovado_por); assert.equal(meta.reviewed_at, source.aprovado_em); + assert.equal(meta.project_id, source.projeto_id); assert.equal(meta.run_id, source.run_id); + assert.equal(meta.source_commit, 'a'.repeat(40)); assert.equal(meta.source_ref, 'main'); + assert.equal(meta.repositorio_url, source.repositorio_url); + assert.deepEqual(meta.cited_paths, ['src/app.ts']); assert.match(meta.report_version, /^[a-f0-9]{64}$/); +}); + +for (const mismatch of ['project', 'document', 'chunk-project', 'chunk-document', 'vector', 'empty'] as const) { + test(`S2-23: rejeita resposta inconsistente (${mismatch})`, async t => { + const data = response(); + if (mismatch === 'project') data.project_id = 'outro'; + if (mismatch === 'document') data.document_id = 'outro'; + if (mismatch === 'chunk-project') data.chunks[0].metadata.project_id = 'outro'; + if (mismatch === 'chunk-document') data.chunks[0].metadata.document_id = 'outro'; + if (mismatch === 'vector') data.chunks[0].embedding = Array(1024).fill(0); + if (mismatch === 'empty') data.chunks = []; + t.mock.method(axios, 'post', async () => ({ data })); + const { worker, saved, failed } = setup(); await worker.tick(); + assert.equal(saved.length, 0); assert.equal(failed.length, 1); + }); +} + +test('S2-23: falha recuperavel nao persiste nem registra dados do provedor', async t => { + t.mock.method(axios, 'post', async () => { throw new Error('QA_PRIVATE_PROMPT'); }); + const logs: unknown[][] = []; t.mock.method(console, 'warn', (...args: unknown[]) => logs.push(args)); + const { worker, failed } = setup(); await worker.tick(); + assert.equal(failed.length, 1); assert.ok(!JSON.stringify({ failed, logs }).includes('QA_PRIVATE_PROMPT')); +}); + +test('S2-23: fila vazia nao chama o processamento', async t => { + const post = t.mock.method(axios, 'post', async () => { throw new Error('nao chamar'); }); + await new ReportIndexingWorker({ claimPendingIndexing: async () => null } as unknown as ReportIndexingRepository).tick(); + assert.equal(post.mock.callCount(), 0); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.indexing.worker.ts b/backend/src/modules/repo-analyses/repo-analyses.indexing.worker.ts index 0f8e089..e74ba88 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.indexing.worker.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.indexing.worker.ts @@ -20,6 +20,9 @@ function reportMetadata(analysis: PendingReportIndexing, text: string, index: nu cited_paths: cited, source_locator: `${analysis.run_id}#${index}`, report_version: createHash('sha256').update(analysis.relatorio_markdown).digest('hex'), + provenance: 'ai-accepted', + reviewed_by: analysis.aprovado_por, + reviewed_at: analysis.aprovado_em, chunk_index: index, }; } diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts new file mode 100644 index 0000000..7a165b9 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts @@ -0,0 +1,185 @@ +import test, { after, before } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { AddressInfo } from 'node:net'; +import { Server } from 'node:http'; +import { errorHandler } from '../../middleware/errorHandler'; +import { AppError, ValidationError } from '../../shared/errors'; +import { createRepoAnalysesRouter } from './repo-analyses.routes'; +import { RepoAnalysesService } from './repo-analyses.service'; +import type { RepoAnalysisRecord } from './repo-analyses.types'; + +const ACTIVE = 'a0000000-0000-4000-8000-000000000001'; +const ARCHIVED = 'a0000000-0000-4000-8000-000000000002'; +const OTHER = 'a0000000-0000-4000-8000-000000000003'; +const ANALYSIS = 'e0000000-0000-4000-8000-000000000001'; + +const record = { id: ANALYSIS, projeto_id: ACTIVE, status: 'iniciado', run_id: 'r1' } as RepoAnalysisRecord; + +class FakeService extends RepoAnalysesService { + public started: Array<{ projectId: string; userId: string; url: string; profile: string }> = []; + public lookups: Array<{ projectId: string; id: string }> = []; + public controls: Array<{ projectId: string; id: string; action: string }> = []; + public engineDown = false; + public reindexed: string[] = []; + + async retryIndexing(projectId: string, id: string): Promise { + if (projectId !== ACTIVE || id !== ANALYSIS) return null; + this.reindexed.push(id); + return { ...record, indexacao_status: 'pendente' }; + } + + async approveReport(projectId: string, id: string, userId: string, expectedRevision: number): Promise { + assert.ok(userId); assert.equal(expectedRevision,1); + return projectId === ACTIVE && id === ANALYSIS ? {...record,indexacao_status:'pendente'} : null; + } + constructor() { + super({} as never, 'http://localhost:0'); + } + + async startAnalysis(projectId: string, userId: string, url: string, profile = 'quick'): Promise { + if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/.test(url)) throw new ValidationError('URL do repositório GitHub inválida.'); + if (this.engineDown) throw new AppError('Falha ao iniciar análise no motor de IA: serviço indisponível', 503, 'ANALYZER_UNAVAILABLE'); + this.started.push({ projectId, userId, url, profile }); + return { ...record, projeto_id: projectId }; + } + + async controlAnalysis(projectId: string, id: string, action: 'pause' | 'resume' | 'cancel'): Promise { + this.controls.push({ projectId, id, action }); + return projectId === ACTIVE && id === ANALYSIS ? record : null; + } + + async listByProject(projectId: string): Promise { + return projectId === ACTIVE ? [record] : []; + } + + async getById(projectId: string, id: string): Promise { + this.lookups.push({ projectId, id }); + return projectId === ACTIVE && id === ANALYSIS ? record : null; + } +} + +const service = new FakeService(); +let server: Server; +let baseUrl: string; + +before(() => { + const app = express(); + app.use(express.json()); + const projects = { + async findById(id: string) { + if (id === ACTIVE || id === OTHER) return { id, status: 'ativo' }; + if (id === ARCHIVED) return { id, status: 'arquivado' }; + return null; + }, + }; + app.use('/api/v1/projects/:projectId/repo-analyses', createRepoAnalysesRouter(service, projects, (req, res, next) => { + if (req.headers['x-test-user'] !== 'ana') { + res.status(401).json({ error: 'Autenticação necessária.', code: 'UNAUTHORIZED' }); + return; + } + req.auth = { id: 'b0000000-0000-4000-8000-000000000001', nome: 'Ana', email: 'a@example.com', role: req.headers['x-test-role'] === 'dev' ? 'dev' : req.headers['x-test-role'] === 'admin' ? 'admin' : 'po' }; + next(); + })); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/projects`; +}); + +after(() => { + server.close(); +}); + +const call = (path: string, init: { method?: string; json?: unknown; auth?: boolean } = {}) => + fetch(`${baseUrl}${path}`, { + method: init.method ?? 'GET', + headers: { 'Content-Type': 'application/json', ...(init.auth === false ? {} : { 'x-test-user': 'ana' }) }, + body: init.json === undefined ? undefined : JSON.stringify(init.json), + }); + +test('exige autenticação', async () => { + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { auth: false })).status, 401); +}); + +test('id de projeto malformado retorna 400 e projeto inexistente retorna 404 antes de qualquer trabalho', async () => { + assert.equal((await call('/nao-uuid/repo-analyses')).status, 400); + assert.equal((await call('/a0000000-0000-4000-8000-0000000000ff/repo-analyses')).status, 404); + assert.equal((await call('/nao-uuid/repo-analyses', { method: 'POST', json: { repositorio_url: 'https://github.com/a/b' } })).status, 400); + assert.equal(service.started.length, 0); +}); + +test('inicia análise no projeto ativo com o usuário autenticado', async () => { + const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); + assert.equal(response.status, 201); + assert.deepEqual(service.started.at(-1), { projectId: ACTIVE, userId: 'b0000000-0000-4000-8000-000000000001', url: 'https://github.com/acme/api', profile: 'quick' }); +}); + +test('aceita perfil configurável e rejeita perfil desconhecido', async () => { + const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api', perfil: 'quick' } }); + assert.equal(response.status, 201); + assert.equal(service.started.at(-1)?.profile, 'quick'); + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api', perfil: 'turbo' } })).status, 400); +}); + +test('URL inválida retorna 400 e falha do motor retorna 503 com mensagem legível', async () => { + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://gitlab.com/a/b' } })).status, 400); + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: {} })).status, 400); + service.engineDown = true; + const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); + service.engineDown = false; + assert.equal(response.status, 503); + assert.match(((await response.json()) as { error: string }).error, /motor de IA/); +}); + +test('projeto arquivado é somente leitura: consulta funciona e nova análise retorna 409', async () => { + const before = service.started.length; + const post = await call(`/${ARCHIVED}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); + assert.equal(post.status, 409); + assert.equal(service.started.length, before); + assert.equal((await call(`/${ARCHIVED}/repo-analyses`)).status, 200); +}); + +test('consulta por id respeita o projeto da rota e valida o formato', async () => { + assert.equal((await call(`/${ACTIVE}/repo-analyses/${ANALYSIS}`)).status, 200); + assert.equal((await call(`/${OTHER}/repo-analyses/${ANALYSIS}`)).status, 404); + assert.deepEqual(service.lookups.at(-1), { projectId: OTHER, id: ANALYSIS }); + assert.equal((await call(`/${ACTIVE}/repo-analyses/abc`)).status, 400); + assert.deepEqual(await (await call(`/${OTHER}/repo-analyses`)).json(), []); +}); + +test('pausa, retoma e cancela uma análise autenticada e vinculada ao projeto', async () => { + for (const action of ['pause', 'resume', 'cancel'] as const) { + assert.equal((await call(`/${ACTIVE}/repo-analyses/${ANALYSIS}/${action}`, { method: 'POST', json: {} })).status, 200); + } + assert.deepEqual(service.controls.slice(-3), [ + { projectId: ACTIVE, id: ANALYSIS, action: 'pause' }, + { projectId: ACTIVE, id: ANALYSIS, action: 'resume' }, + { projectId: ACTIVE, id: ANALYSIS, action: 'cancel' }, + ]); + assert.equal((await call(`/${OTHER}/repo-analyses/${ANALYSIS}/cancel`, { method: 'POST', json: {} })).status, 404); + assert.equal((await call(`/${ARCHIVED}/repo-analyses/${ANALYSIS}/resume`, { method: 'POST', json: {} })).status, 409); +}); + +test('S2-23: reprocessamento exige autenticacao, escrita, projeto ativo e origem correta', async () => { + const path = `/${ACTIVE}/repo-analyses/${ANALYSIS}/indexing/retry`; + assert.equal((await call(path, { method: 'POST', json: {}, auth: false })).status, 401); + assert.equal((await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'x-test-user': 'ana', 'x-test-role': 'dev' } })).status, 403); + assert.equal((await call(`/${ARCHIVED}/repo-analyses/${ANALYSIS}/indexing/retry`, { method: 'POST', json: {} })).status, 409); + assert.equal((await call(`/${OTHER}/repo-analyses/${ANALYSIS}/indexing/retry`, { method: 'POST', json: {} })).status, 404); + const response = await call(path, { method: 'POST', json: {} }); + assert.equal(response.status, 200); + assert.equal((await response.json() as RepoAnalysisRecord).indexacao_status, 'pendente'); + assert.equal(service.reindexed.at(-1), ANALYSIS); +}); + +test('S2-23: aprovacao explicita exige PO/admin, revisao e projeto ativo', async()=>{ + const path=`/${ACTIVE}/repo-analyses/${ANALYSIS}/report/approve`; + assert.equal((await call(path,{method:'POST',auth:false})).status,401); + for(const role of ['po','admin','dev']){ + const response=await fetch(baseUrl+path,{method:'POST',headers:{'Content-Type':'application/json','x-test-user':'ana','x-test-role':role},body:JSON.stringify({relatorio_revisao:1})}); + assert.equal(response.status,role==='dev'?403:200); + } + assert.equal((await call(path,{method:'POST',json:{}})).status,400); + assert.equal((await call(`/${ARCHIVED}/repo-analyses/${ANALYSIS}/report/approve`,{method:'POST',json:{relatorio_revisao:1}})).status,409); + assert.equal((await call(`/${OTHER}/repo-analyses/${ANALYSIS}/report/approve`,{method:'POST',json:{relatorio_revisao:1}})).status,404); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.ts index 1831637..9dc17c7 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.ts @@ -89,6 +89,19 @@ export function createRepoAnalysesRouter( router.post('/:id/pause', control('pause')); router.post('/:id/resume', control('resume')); router.post('/:id/cancel', control('cancel')); + router.post('/:id/report/approve', async (req: Request, res: Response, next: NextFunction) => { + try { + const id = String(req.params.id); + validateUuid(id, 'ID da análise'); + if (!['admin', 'po'].includes(req.auth?.role ?? '')) throw new AppError('Sem permissão para aprovar relatórios.', 403, 'FORBIDDEN'); + if (res.locals.projectStatus === 'arquivado') throw new ArchiveConflict('Projeto arquivado não publica relatórios.'); + const revision = req.body?.relatorio_revisao; + if (!Number.isSafeInteger(revision) || revision < 0) throw new ValidationError('Informe a versão revisada do relatório.'); + const analysis = await service.approveReport(String(req.params.projectId), id, req.auth!.id, revision); + if (!analysis) throw new NotFoundError('Análise não encontrada.'); + res.json(analysis); + } catch (error) { next(error); } + }); router.post('/:id/indexing/retry', async (req: Request, res: Response, next: NextFunction) => { try { const id = String(req.params.id); diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.ts b/backend/src/modules/repo-analyses/repo-analyses.service.ts index 171d8f2..be8d371 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.ts @@ -117,6 +117,11 @@ export class RepoAnalysesService { return this.repository.findById(id, projectId); } + async approveReport(projectId: string, id: string, userId: string, expectedRevision: number): Promise { + await this.reportIndexing.approveReport(id, projectId, userId, expectedRevision); + return this.repository.findById(id, projectId); + } + async controlAnalysis(projetoId: string, id: string, action: 'pause' | 'resume' | 'cancel'): Promise { const analysis = await this.repository.findById(id, projetoId); if (!analysis) return null; diff --git a/backend/src/modules/repo-analyses/repo-analyses.types.ts b/backend/src/modules/repo-analyses/repo-analyses.types.ts index 2196a85..4658ad4 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.types.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.types.ts @@ -28,7 +28,7 @@ export interface RepoAnalysisRecord { resume_requested?: boolean; perfil?: 'quick' | 'balanced' | 'complete'; /** S2-23: Estado de indexação do relatório no acervo vetorial. */ - indexacao_status?: 'nao_aplicavel' | 'pendente' | 'processando' | 'indexado' | 'falha'; + indexacao_status?: 'nao_aplicavel' | 'aguardando_aprovacao' | 'pendente' | 'processando' | 'indexado' | 'falha'; indexacao_tentativas?: number; indexacao_erro?: string; } diff --git a/backend/src/modules/search/search.repository.ts b/backend/src/modules/search/search.repository.ts index c2c969f..b308029 100644 --- a/backend/src/modules/search/search.repository.ts +++ b/backend/src/modules/search/search.repository.ts @@ -23,6 +23,10 @@ export class SearchRepository { FROM chunk c JOIN projeto p ON p.id = c.projeto_id WHERE c.projeto_id = $1 + AND (c.entidade_tipo <> 'analise_repositorio' OR EXISTS (SELECT 1 FROM analise_repositorio reviewed + WHERE reviewed.id=c.entidade_id AND reviewed.projeto_id=c.projeto_id + AND reviewed.aprovacao_hash=encode(sha256(convert_to(reviewed.relatorio_markdown,'UTF8')),'hex') + AND reviewed.status='concluido' AND reviewed.aprovado_por IS NOT NULL AND reviewed.aprovado_em IS NOT NULL)) AND ($4::uuid IS NULL OR EXISTS ( SELECT 1 FROM entidade_tecnologia et diff --git a/backend/src/modules/suggestions/suggestions.db.test.ts b/backend/src/modules/suggestions/suggestions.db.test.ts new file mode 100644 index 0000000..1c10aec --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.db.test.ts @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { lockHierarchy } from "../projects/hierarchy-archive.js"; +import { EpicsRepository } from "../epics/epics.repository.js"; +import { PbisRepository } from "../pbis/pbis.repository.js"; +import { SuggestionsRepository } from "./suggestions.repository.js"; +import { SuggestionsService } from "./suggestions.service.js"; + +test("S2-13: ciclo humano de sugestões, proveniência por campo e fallback manual no PostgreSQL", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async (t) => { + const pool = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const service = new SuggestionsService(new SuggestionsRepository(pool)); + const [user, project, epic, feature, pbi, archivedPbi, concludedPbi] = Array.from({ length: 7 }, () => randomUUID()); + const configId = "00000000-0000-4000-8000-000000000001"; + let originalConfiguration: unknown; + + try { + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Ana PO',$2,'h','po')", [user, `${user}@sugestoes.test`]); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'T','ativo')", [project]); + await pool.query("INSERT INTO epico (id,projeto_id,titulo) VALUES ($1,$2,'Épico')", [epic, project]); + await pool.query("INSERT INTO feature (id,epico_id,titulo) VALUES ($1,$2,'Feature')", [feature, epic]); + await pool.query(`INSERT INTO pbi (id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES + ($1,$2,'PBI-S.1','PBI','PO','x','y'),($3,$2,'PBI-S.2','PBI arquivado','PO','x','y'),($4,$2,'PBI-S.3','PBI concluído','PO','x','y')`, + [pbi, feature, archivedPbi, concludedPbi]); + + await t.test("propõe, aceita e grava proveniência por campo no item real", async () => { + const created = await service.create("pbi", pbi, user, { campo: "titulo", valor_sugerido: "Validar login com e-mail" }); + assert.equal(created.status, "pendente"); + const accepted = await service.accept("pbi", pbi, created.id, user, {}); + assert.equal(accepted.status, "aceita"); + assert.equal(accepted.valor_resolvido, "Validar login com e-mail"); + + const row = (await pool.query("SELECT titulo, provenance_json FROM pbi WHERE id=$1", [pbi])).rows[0]; + assert.equal(row.titulo, "Validar login com e-mail"); + assert.equal(row.provenance_json.titulo, "ai-accepted"); + + const versao = (await pool.query("SELECT versao, snapshot_json FROM pbi_versao WHERE pbi_id=$1 ORDER BY versao DESC LIMIT 1", [pbi])).rows[0]; + assert.equal(versao.versao, 1); + assert.equal(versao.snapshot_json.titulo, "Validar login com e-mail"); + + const audit = (await pool.query("SELECT acao, dados_json FROM auditoria WHERE entidade_id=$1 AND acao='ACEITAR_SUGESTAO_IA'", [pbi])).rows[0]; + assert.equal(audit.dados_json.campo, "titulo"); + }); + + await t.test("repetir a aceitação é idempotente (200) e editar depois conflita (409)", async () => { + const created = await service.create("epico", epic, user, { campo: "objetivo", valor_sugerido: "Organizar backlog" }); + const accepted = await service.accept("epico", epic, created.id, user, {}); + const repeated = await service.accept("epico", epic, created.id, user, {}); + assert.deepEqual(repeated, accepted); + await assert.rejects(service.edit("epico", epic, created.id, user, { valor: "Outro objetivo" }), /já foi aceita/); + + const row = (await pool.query("SELECT provenance_json FROM epico WHERE id=$1", [epic])).rows[0]; + assert.equal(row.provenance_json.objetivo, "ai-accepted"); + }); + + await t.test("editar aplica o novo valor com proveniência ai-edited; nova proposta pendente não duplica (idempotência de criação)", async () => { + const first = await service.create("feature", feature, user, { campo: "titulo", valor_sugerido: "Sessão do usuário" }); + const second = await service.create("feature", feature, user, { campo: "titulo", valor_sugerido: "Sessão e login do usuário" }); + assert.equal(first.id, second.id, "a proposta pendente anterior é substituída, não duplicada"); + + const edited = await service.edit("feature", feature, second.id, user, { valor: "Sessão, login e logout" }); + assert.equal(edited.status, "editada"); + assert.equal(edited.valor_sugerido, "Sessão e login do usuário", "preserva o texto originalmente sugerido"); + + const row = (await pool.query("SELECT titulo, provenance_json FROM feature WHERE id=$1", [feature])).rows[0]; + assert.equal(row.titulo, "Sessão, login e logout"); + assert.equal(row.provenance_json.titulo, "ai-edited"); + }); + + await t.test("descartar nunca grava na tabela de negócio e é idempotente", async () => { + const created = await service.create("pbi", pbi, user, { campo: "historia_como_um", valor_sugerido: "usuário autenticado" }); + const discarded = await service.discard("pbi", pbi, created.id, user); + assert.equal(discarded.status, "descartada"); + assert.equal(discarded.valor_resolvido, null); + assert.deepEqual(await service.discard("pbi", pbi, created.id, user), discarded); + + const row = (await pool.query("SELECT historia_como_um, provenance_json FROM pbi WHERE id=$1", [pbi])).rows[0]; + assert.equal(row.historia_como_um, "PO"); + assert.equal(row.provenance_json.historia_como_um, undefined); + }); + + await t.test("item arquivado recusa aceitar/editar com 409, corrida com o arquivamento resulta em conflito, mas descartar continua permitido", async () => { + const created = await service.create("pbi", archivedPbi, user, { campo: "titulo", valor_sugerido: "Novo título" }); + + const archiver = await pool.connect(); + try { + await archiver.query("BEGIN"); + await lockHierarchy(archiver, "pbi", archivedPbi); + await archiver.query("UPDATE pbi SET status='arquivado' WHERE id=$1", [archivedPbi]); + const attempt = service.accept("pbi", archivedPbi, created.id, user, {}); + await new Promise((resolve) => setTimeout(resolve, 150)); + await archiver.query("COMMIT"); + await assert.rejects(attempt, ArchiveConflict); + } finally { + archiver.release(); + } + + await assert.rejects(service.accept("pbi", archivedPbi, created.id, user, {}), ArchiveConflict); + await assert.rejects(service.edit("pbi", archivedPbi, created.id, user, { valor: "x" }), ArchiveConflict); + const discarded = await service.discard("pbi", archivedPbi, created.id, user); + assert.equal(discarded.status, "descartada"); + }); + + await t.test("item concluído exige justificativa para aceitar ou editar, mas não para descartar", async () => { + const savedConfiguration = await pool.query<{ configuration: unknown }>( + "SELECT configuration FROM quality_configuration WHERE id = $1 FOR UPDATE", + [configId], + ); + assert.ok(savedConfiguration.rows[0], "o banco de teste deve estar migrado"); + originalConfiguration = savedConfiguration.rows[0].configuration; + await pool.query( + `UPDATE quality_configuration + SET configuration = jsonb_set(configuration, '{exigir_justificativa_item_concluido}', 'true'::jsonb, true) + WHERE id = $1`, + [configId], + ); + await pool.query("UPDATE pbi SET status='concluido' WHERE id=$1", [concludedPbi]); + + const created = await service.create("pbi", concludedPbi, user, { campo: "titulo", valor_sugerido: "PBI revisado" }); + await assert.rejects(service.accept("pbi", concludedPbi, created.id, user, {}), /justificativa é obrigatória/); + const accepted = await service.accept("pbi", concludedPbi, created.id, user, { justificativa: "Correção aprovada em revisão" }); + assert.equal(accepted.status, "aceita"); + + const other = await service.create("pbi", concludedPbi, user, { campo: "historia_eu_quero", valor_sugerido: "ver meu histórico" }); + const discarded = await service.discard("pbi", concludedPbi, other.id, user); + assert.equal(discarded.status, "descartada"); + }); + + await t.test("isolamento: sugestão de outro item ou outro tipo não é encontrada", async () => { + const created = await service.create("pbi", pbi, user, { campo: "titulo", valor_sugerido: "x" }); + await assert.rejects(service.accept("epico", epic, created.id, user, {}), /não encontrada/); + await assert.rejects(service.accept("pbi", feature, created.id, user, {}), /não encontrada/); + }); + + await t.test("fallback manual: operar epics/features/pbis sem IA grava proveniência humana e não perde dados", async () => { + const epics = new EpicsRepository(pool); + const pbis = new PbisRepository(pool); + + await epics.update(epic, { titulo: "Épico revisado manualmente", objetivo: "Organizar backlog" }); + const epicRow = (await pool.query("SELECT titulo, objetivo, provenance_json FROM epico WHERE id=$1", [epic])).rows[0]; + assert.equal(epicRow.titulo, "Épico revisado manualmente"); + assert.equal(epicRow.provenance_json.titulo, "human-authored"); + assert.equal(epicRow.provenance_json.objetivo, "human-authored", "edição manual sobrescreve proveniência ai-accepted anterior"); + + await pbis.update(pbi, { historia_para_que: "ter acesso rápido ao sistema" }); + const pbiRow = (await pool.query("SELECT historia_para_que, provenance_json FROM pbi WHERE id=$1", [pbi])).rows[0]; + assert.equal(pbiRow.historia_para_que, "ter acesso rápido ao sistema"); + assert.equal(pbiRow.provenance_json.historia_para_que, "human-authored"); + }); + } finally { + const entityIds = [project, epic, feature, pbi, archivedPbi, concludedPbi]; + await pool.query("DELETE FROM sugestao_ia WHERE entidade_id = ANY($1::uuid[])", [entityIds]); + await pool.query("DELETE FROM pbi_versao WHERE pbi_id = ANY($1::uuid[])", [[pbi, archivedPbi, concludedPbi]]); + await pool.query("DELETE FROM auditoria WHERE usuario_id=$1", [user]); + await pool.query("DELETE FROM projeto WHERE id=$1", [project]); + await pool.query("DELETE FROM usuario WHERE id=$1", [user]); + if (originalConfiguration !== undefined) { + await pool.query("UPDATE quality_configuration SET configuration = $2 WHERE id = $1", [configId, originalConfiguration]); + } + await pool.end(); + } +}); diff --git a/database/migrations/020_analysis_report_indexing.sql b/database/migrations/020_analysis_report_indexing.sql index c170e79..e017016 100644 --- a/database/migrations/020_analysis_report_indexing.sql +++ b/database/migrations/020_analysis_report_indexing.sql @@ -9,11 +9,7 @@ ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS indexacao_lease_id UUID, ADD COLUMN IF NOT EXISTS indexacao_bloqueado_ate TIMESTAMPTZ; --- Análises já concluídas com relatório ficam pendentes de indexação. -UPDATE analise_repositorio - SET indexacao_status = 'pendente' - WHERE status = 'concluido' AND relatorio_markdown IS NOT NULL - AND indexacao_status = 'nao_aplicavel'; +-- O backfill exige revisão humana, definida na migration 021. -- Índice para o worker buscar análises pendentes de indexação eficientemente. CREATE INDEX IF NOT EXISTS idx_analysis_indexing_pending diff --git a/database/migrations/022_analysis_report_review.sql b/database/migrations/022_analysis_report_review.sql new file mode 100644 index 0000000..a11641f --- /dev/null +++ b/database/migrations/022_analysis_report_review.sql @@ -0,0 +1,51 @@ +ALTER TABLE analise_repositorio ALTER COLUMN indexacao_status TYPE VARCHAR(30); + +ALTER TABLE analise_repositorio + ADD COLUMN IF NOT EXISTS relatorio_revisao INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS aprovacao_hash TEXT, + ADD COLUMN IF NOT EXISTS aprovado_por UUID REFERENCES usuario(id), + ADD COLUMN IF NOT EXISTS aprovado_em TIMESTAMPTZ; + +CREATE OR REPLACE FUNCTION queue_analysis_report() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE changed BOOLEAN; +BEGIN + changed := TG_OP='INSERT' OR NEW.relatorio_markdown IS DISTINCT FROM OLD.relatorio_markdown + OR NEW.metadados IS DISTINCT FROM OLD.metadados OR NEW.status IS DISTINCT FROM OLD.status + OR NEW.projeto_id IS DISTINCT FROM OLD.projeto_id OR NEW.run_id IS DISTINCT FROM OLD.run_id + OR NEW.repositorio_url IS DISTINCT FROM OLD.repositorio_url; + IF changed THEN + NEW.relatorio_revisao := COALESCE(OLD.relatorio_revisao,0)+1; + NEW.aprovacao_hash := NULL; NEW.aprovado_por := NULL; NEW.aprovado_em := NULL; + NEW.indexacao_status := CASE WHEN NEW.status='concluido' AND NULLIF(btrim(NEW.relatorio_markdown),'') IS NOT NULL + THEN 'aguardando_aprovacao' ELSE 'nao_aplicavel' END; + NEW.indexacao_tentativas := 0; NEW.indexacao_erro := NULL; + NEW.indexacao_lease_id := NULL; NEW.indexacao_bloqueado_ate := NULL; + DELETE FROM chunk WHERE entidade_tipo='analise_repositorio' AND entidade_id=NEW.id; + ELSIF NEW.aprovacao_hash IS DISTINCT FROM OLD.aprovacao_hash THEN + IF NEW.aprovacao_hash IS NULL AND NEW.aprovado_por IS NULL AND NEW.aprovado_em IS NULL THEN + NEW.indexacao_status := CASE WHEN NEW.status='concluido' AND NULLIF(btrim(NEW.relatorio_markdown),'') IS NOT NULL + THEN 'aguardando_aprovacao' ELSE 'nao_aplicavel' END; + NEW.indexacao_tentativas := 0; NEW.indexacao_erro := NULL; + NEW.indexacao_lease_id := NULL; NEW.indexacao_bloqueado_ate := NULL; + DELETE FROM chunk WHERE entidade_tipo='analise_repositorio' AND entidade_id=NEW.id; + ELSIF NEW.aprovacao_hash IS NOT NULL AND NEW.aprovado_por IS NOT NULL AND NEW.aprovado_em IS NOT NULL + AND NEW.status='concluido' + AND NEW.aprovacao_hash=encode(sha256(convert_to(NEW.relatorio_markdown,'UTF8')),'hex') THEN + NEW.indexacao_status := 'pendente'; + NEW.indexacao_tentativas := 0; NEW.indexacao_erro := NULL; + NEW.indexacao_lease_id := NULL; NEW.indexacao_bloqueado_ate := NULL; + ELSE + RAISE EXCEPTION 'A aprovação deve identificar o revisor e a versão atual do relatório'; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- Nunca transformar relatórios legados em evidências revisadas implicitamente. +DELETE FROM chunk WHERE entidade_tipo='analise_repositorio'; +UPDATE analise_repositorio SET indexacao_status=CASE + WHEN status='concluido' AND NULLIF(btrim(relatorio_markdown),'') IS NOT NULL + AND EXISTS(SELECT 1 FROM projeto p WHERE p.id=projeto_id AND p.status<>'arquivado') + THEN 'aguardando_aprovacao' ELSE 'nao_aplicavel' END, + aprovacao_hash=NULL,aprovado_por=NULL,aprovado_em=NULL,indexacao_lease_id=NULL,indexacao_bloqueado_ate=NULL; diff --git a/docs/QA-S2-23-2026-10-22.md b/docs/QA-S2-23-2026-10-22.md new file mode 100644 index 0000000..79588f5 --- /dev/null +++ b/docs/QA-S2-23-2026-10-22.md @@ -0,0 +1,62 @@ +# QA complementar — S2-23 e integração com o acervo + +Validação de 22/10/2026 sobre a branch `fix/s2-23-aprovacao-relatorios`, integrada à main `d00c801`. A PR #54 foi mesclada durante a revisão; as correções seguem em branch complementar. A migration de revisão é a **022**, preservando a 021 de relações entre PBIs que entrou na main. + +## Correções demonstradas + +1. `ProjectDetail` usa permissão de PO/admin e estado do projeto. Desenvolvedores leem relatórios, mas não veem aprovação/reprocessamento. Testes cobrem PO, admin, dev, carregamento, erro HTTP e preservação do relatório. +2. Relatórios concluídos ficam aguardando aprovação. O aceite identifica revisor, data e SHA-256 e compara a revisão exibida com a revisão atual sob bloqueio transacional. Alterações de conteúdo/origem invalidam o aceite e removem os trechos anteriores. Busca e chat recusam relatórios não revisados, inclusive quando há um chunk inserido diretamente na fixture. +3. A migration 022 remove somente trechos de relatórios legados e exige revisão humana; preserva outros tipos de conteúdo. Reprocessamento não duplica chunks. +4. O mock do Ollama foi movido para contêiner/rede Docker próprios. A porta publicada é somente loopback, com token por execução, corpo de até 256 KiB, limite de histórico e erros controlados. O teste de segurança verifica 401/400/413 e recuperação da próxima requisição válida. +5. O teste transacional atualiza um relatório indexado, observa remoção dos chunks/revogação do aceite, aprova a nova versão e observa a fila pendente **antes do rollback**. Após rollback, conteúdo, hash de aprovação, estado indexado e chunks anteriores permanecem intactos. +6. Dois workers concorrentes recebem análises distintas; uma terceira reivindicação não obtém os trabalhos reservados. Reservas vencidas e respostas antigas não sobrescrevem a revisão atual. +7. A integração da S2-05 revelou uma lacuna adicional: o expurgo cancelava documentos, mas não reservas de relatórios. O expurgo agora revoga a aprovação e invalida suas reservas na mesma transação. Uma conclusão atrasada não repovoa o índice. +8. A revisão adicional encontrou uma corrida com aceite em trânsito: após o expurgo, uma requisição antiga poderia reaproveitar a mesma revisão. O expurgo também incrementa a revisão; o aceite enviado por uma tela antiga retorna 409, antes de qualquer nova publicação. + +## Resultados da versão integrada + +| Verificação | Resultado | +| --- | --- | +| Build/backend e frontend | Aprovados | +| Backend unitário/contratos pertinentes | 24 aprovados; 16 condicionais ignorados | +| Interface do Analyzer | 20 aprovados | +| Indexação PostgreSQL S2-03/S2-04/S2-23 | 33 aprovados; 1 teste opcional de IA real ignorado | +| Sugestões e proveniência no PostgreSQL | 9 aprovados | +| Navegador autenticado S2-05/S2-08/S2-23/S2-13/S2-14 | 4 fluxos aprovados | +| Harness + FastAPI + PostgreSQL + busca/chat | Aprovado | +| Regressão de copiloto: questionador, padronização, demora/cancelamento, indisponibilidade sem perder edição, mobile e acessibilidade | Aprovada | +| Scripts Windows usados pelo CI | Aprovados | +| Sintaxe Python usada pelo CI | Aprovada | + +A main removeu grande parte das suítes antigas. Esta branch mantém somente os testes e o suporte pertinentes a esta revisão; os números acima são da versão **já integrada**, não os totais históricos de antes da remoção. O workflow `QA de relatorios e acervo` executa esses checks em novas PRs. + +## Reprodução + +Com Docker disponível e dependências de `backend`, `frontend` e `e2e` instaladas, na raiz: + +```powershell +npm run build --prefix backend +npm test --prefix backend +npm run build --prefix frontend +npm test --prefix frontend +npm run test:integration:indexing --prefix backend +npm run test:integration:review --prefix backend +docker build -t sinapse-s209-ai-test ai-service +npm run test:integration:copilot --prefix backend +``` + +Os comandos criam bancos/arquivos temporários e os removem ao terminar. Não usam nem limpam o banco de desenvolvimento. O teste de navegador usa Edge no Windows ou `E2E_CHROME_PATH`; no CI usa Chrome. + +## Cobertura complementar e limites + +- **S2-05/S2-08:** confirmação do nome do projeto, autorização administrativa, expurgo de relatórios/documentos, preservação do outro projeto, busca vazia após exclusão, snippet, identificação da fonte e navegação ao trecho/execução foram exercitados no navegador autenticado. +- **S2-03/S2-04:** os testes PostgreSQL cobrem idempotência, revisão da origem, reabertura, arquivamento, mudança de projeto, reservas concorrentes e recuperação de falha. O teste de estados executa oito sequências determinísticas de 80 operações. +- **S2-13/S2-14:** aceite, edição e descarte explícitos foram exercitados pela UI, com verificação de autor, origem e `ai-accepted`/`ai-edited` persistidos. Dev recebe 403 e não vê os controles; o serviço de IA indisponível não impede resolver sugestões já existentes nem preservar edição manual. +- **S2-06/S2-17:** não declaradas concluídas. Permanecem pendentes a decisão do PO/time sobre GRF-01/08, critérios de relevância e aceite da avaliação semântica. As fixtures e o relatório anterior em `QA_SEARCH_V2_2026-10-02.md` são referência, não novo aceite. +- Embeddings/respostas do modelo são determinísticos/simulados nos testes integrados. Os testes demonstram contratos e persistência; não certificam a qualidade semântica do Ollama local. + +## Correção do check da PR #57 + +O run 39862496111 passou builds, contratos, interface, PostgreSQL e navegador, mas encerrou a inicialização do harness com código 13. A espera pelo mock usava somente `AbortSignal.timeout`, cujo timer não mantém o processo Node ativo enquanto há uma promessa pendente sem outros recursos ativos. + +A comunicação de inicialização agora usa um prazo referenciado, cancela a requisição ao expirar e libera o timer após sucesso ou erro. Dois testes executam processos Node independentes: uma operação sem sockets deve terminar com timeout controlado, e uma operação concluída deve sair sem aguardar o prazo restante. Ambos passaram em Linux/Node 20, junto do teste de segurança do mock. A integração completa FastAPI/PostgreSQL/busca/chat/copiloto também passou localmente após o ajuste. diff --git a/docs/S2-23-RELATORIOS.md b/docs/S2-23-RELATORIOS.md index 5c16d34..d8d1384 100644 --- a/docs/S2-23-RELATORIOS.md +++ b/docs/S2-23-RELATORIOS.md @@ -1,6 +1,12 @@ # S2-23 — Relatórios do RepoAnalyzer no acervo -Ao salvar um relatório concluído, a migration `020_analysis_report_indexing.sql` agenda sua indexação na mesma transação. O worker envia apenas o Markdown ao endpoint `/documents/process`, usado pelo pipeline de documentos; não ingere os arquivos brutos do repositório. +Relatórios concluídos são rascunhos de IA e aguardam aprovação humana. A migration `022_analysis_report_review.sql` exige que PO/admin revise o Markdown e acione **Aprovar relatório e publicar no acervo**. O endpoint `POST /api/v1/projects/{projectId}/repo-analyses/{id}/report/approve` recebe `relatorio_revisao`; uma versão diferente da exibida retorna 409. A aprovação grava revisor, data e SHA-256 e enfileira a indexação na mesma transação. O worker envia apenas o Markdown ao endpoint `/documents/process`, usado pelo pipeline de documentos; não ingere os arquivos brutos do repositório. + +Cada trecho inclui proveniência `ai-accepted`, revisor e data. Alterar o relatório ou sua origem invalida o aceite e exige nova aprovação. A busca e o chat verificam a aprovação vigente e identificam a fonte como relatório de IA revisado. + +O backfill nunca aprova automaticamente: a migration 022 retira trechos de relatórios legados e deixa seus relatórios aguardando revisão. Não altera os outros tipos de conteúdo do acervo. Desenvolvedores podem ler os relatórios, mas não veem ações de aprovação/reprocessamento. Projetos arquivados e relatórios sem aprovação recusam a publicação. + +O expurgo administrativo S2-05 revoga o aceite e cancela reservas dos relatórios na mesma transação que remove os trechos. Respostas atrasadas do worker não podem repovoar o acervo. Os relatórios preservados como fonte exigem nova revisão para serem publicados novamente. Cada trecho preserva projeto, análise, execução, URL, commit/ref quando disponíveis, caminhos citados no próprio trecho e SHA-256 do relatório. O Analyzer registra a revisão Git do clone e preserva esses dados nos checkpoints. Relatórios antigos sem revisão conhecida mantêm valores nulos; a implementação não inventa essa informação. @@ -18,3 +24,7 @@ A busca aceita o nível `analise_repositorio` e abre `/projects/{projectId}#repo - `python -m unittest discover -s tests`, em `ai-service/`: Analyzer, checkpoints e processamento de documentos. A qualidade semântica do relatório e dos embeddings do modelo instalado deve ser conferida com o Ollama local; respostas simuladas validam o fluxo e os contratos. + +O mock do Ollama roda em uma rede Docker privada por execução. A porta de controle é publicada apenas em `127.0.0.1`, protegida por token aleatório. O corpo é limitado a 256 KiB, o histórico é limitado a 200 requisições e entradas inválidas retornam 400/401/413. O bind em todas as interfaces existe somente dentro do contêiner temporário; o host não publica a porta para a rede externa. + +`npm run test:integration:review`, em `backend/`, cria PostgreSQL e arquivos temporários, sobe a API e o build da interface e executa os testes autenticados de busca, aprovação, expurgo e sugestões. Os embeddings são determinísticos; os demais endpoints de IA ficam indisponíveis para demonstrar o fallback manual. Nenhum dado do ambiente de desenvolvimento é removido. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 798f424..a9e1478 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -218,6 +218,9 @@ paths: documentos_cancelados: type: integer description: Documentos tirados da fila e leases invalidados pelo expurgo + relatorios_cancelados: + type: integer + description: Relatórios com aprovação revogada e leases invalidados; exigem nova revisão humana eventos_removidos: type: integer '400': @@ -2710,6 +2713,31 @@ paths: '404': {description: Projeto ou análise não encontrados} '409': {description: Estado da análise não permite cancelamento} + /api/v1/projects/{projectId}/repo-analyses/{id}/report/approve: + post: + summary: Aprovar a versão revisada do relatório de IA e publicar no acervo (PO/admin) + operationId: approveRepoReport + security: [{cookieAuth: []}] + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [relatorio_revisao] + properties: + relatorio_revisao: {type: integer, minimum: 0} + responses: + '200': {description: Versão aprovada com autor/data e indexação enfileirada} + '400': {description: Versão ausente ou inválida} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {description: Somente PO e administrador podem aprovar} + '404': {description: Projeto ou análise não encontrados} + '409': {description: Projeto arquivado, relatório indisponível ou versão alterada} + /api/v1/projects/{projectId}/repo-analyses/{id}/indexing/retry: post: summary: Reprocessar o relatório concluído no acervo (PO ou administrador) @@ -2723,7 +2751,7 @@ paths: '401': {$ref: '#/components/responses/Unauthorized'} '403': {description: Sem permissão de escrita} '404': {description: Projeto ou análise não encontrados} - '409': {description: Projeto arquivado ou relatório ainda não concluído} + '409': {description: Projeto arquivado, relatório ainda não concluído ou sem aprovação humana} /api/v1/search: get: diff --git a/docs/evidence/S2-09-harness.png b/docs/evidence/S2-09-harness.png index 7f0e5f7..a47b6bc 100644 Binary files a/docs/evidence/S2-09-harness.png and b/docs/evidence/S2-09-harness.png differ diff --git a/docs/evidence/S2-10-demora.png b/docs/evidence/S2-10-demora.png index 35c0fbe..678b9e1 100644 Binary files a/docs/evidence/S2-10-demora.png and b/docs/evidence/S2-10-demora.png differ diff --git a/docs/evidence/S2-10-epico.png b/docs/evidence/S2-10-epico.png index 02349ed..79b0251 100644 Binary files a/docs/evidence/S2-10-epico.png and b/docs/evidence/S2-10-epico.png differ diff --git a/docs/evidence/S2-10-feature.png b/docs/evidence/S2-10-feature.png index 382db47..696368f 100644 Binary files a/docs/evidence/S2-10-feature.png and b/docs/evidence/S2-10-feature.png differ diff --git a/docs/evidence/S2-10-indisponibilidade.png b/docs/evidence/S2-10-indisponibilidade.png index 2796b98..f4766e9 100644 Binary files a/docs/evidence/S2-10-indisponibilidade.png and b/docs/evidence/S2-10-indisponibilidade.png differ diff --git a/docs/evidence/S2-10-mobile.png b/docs/evidence/S2-10-mobile.png index 6d63058..7f0b411 100644 Binary files a/docs/evidence/S2-10-mobile.png and b/docs/evidence/S2-10-mobile.png differ diff --git a/docs/evidence/S2-10-padronizador.png b/docs/evidence/S2-10-padronizador.png index d836c91..b500727 100644 Binary files a/docs/evidence/S2-10-padronizador.png and b/docs/evidence/S2-10-padronizador.png differ diff --git a/docs/evidence/S2-10-questionador.png b/docs/evidence/S2-10-questionador.png index a38c3b3..21b3c97 100644 Binary files a/docs/evidence/S2-10-questionador.png and b/docs/evidence/S2-10-questionador.png differ diff --git a/e2e/database.mjs b/e2e/database.mjs new file mode 100644 index 0000000..72ff49e --- /dev/null +++ b/e2e/database.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +const require = createRequire(new URL('../backend/package.json', import.meta.url)); +const { Pool } = require('pg'); + +export async function testDatabase(work) { + const connectionString = process.env.E2E_DATABASE_URL; + if (!connectionString || !/^sinapse_e2e_test$/.test(new URL(connectionString).pathname.slice(1))) { + throw new Error('E2E_DATABASE_URL deve apontar para um banco dedicado chamado sinapse_e2e_test.'); + } + const db = new Pool({ connectionString, max: 1 }); + try { return await work(db); } finally { await db.end(); } +} + +export async function allocateDeveloper(account, project) { + await testDatabase(async db => { + const developer = await db.query('INSERT INTO desenvolvedor(usuario_id) VALUES($1) RETURNING id', [account.user.id]); + await db.query('INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES($1,$2)', [developer.rows[0].id, project.id]); + }); +} diff --git a/e2e/embedding-service.mjs b/e2e/embedding-service.mjs new file mode 100644 index 0000000..2a6b80f --- /dev/null +++ b/e2e/embedding-service.mjs @@ -0,0 +1,20 @@ +import { createServer } from 'node:http'; + +// Embeddings determinísticos para exercitar SQL/HTTP reais; os demais modos de IA ficam indisponíveis. +export async function startEmbeddingService() { + const server = createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method !== 'POST' || req.url !== '/embeddings') { + res.writeHead(503); res.end('{"error":"IA indisponível no teste"}'); return; + } + let bytes = 0; + for await (const part of req) { + bytes += part.length; + if (bytes > 64 * 1024) { res.writeHead(413); res.end('{}'); return; } + } + res.end(JSON.stringify({ embedding: Array(1024).fill(0.1) })); + }); + server.requestTimeout = 5000; + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return { server, url: 'http://127.0.0.1:' + server.address().port }; +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..57f0ae3 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "sinapse-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sinapse-e2e", + "version": "0.1.0", + "dependencies": { + "axe-core": "^4.13.0" + }, + "devDependencies": { + "playwright-core": "^1.55.0" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..b4dd7e4 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "sinapse-e2e", + "version": "0.1.0", + "private": true, + "description": "Testes E2E de navegador (API real + PostgreSQL + frontend)", + "type": "module", + "scripts": { + "test": "node run.mjs" + }, + "devDependencies": { + "playwright-core": "^1.55.0" + }, + "dependencies": { + "axe-core": "^4.13.0" + } +} diff --git a/e2e/run.mjs b/e2e/run.mjs new file mode 100644 index 0000000..2011ffb --- /dev/null +++ b/e2e/run.mjs @@ -0,0 +1,11 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { setup } from './setup.mjs'; + +await setup(); + +const files = readdirSync("tests").filter((file) => file.endsWith(".e2e.mjs")).sort().map((file) => join("tests", file)); +if (files.length === 0) throw new Error("Nenhum teste E2E encontrado."); +const result = spawnSync(process.execPath, ["--test", "--test-concurrency=1", ...files], { stdio: "inherit", env: process.env }); +process.exit(result.status ?? 1); diff --git a/e2e/server.mts b/e2e/server.mts new file mode 100644 index 0000000..60f4dec --- /dev/null +++ b/e2e/server.mts @@ -0,0 +1,8 @@ +import { createRequire } from 'node:module'; +import { startEmbeddingService } from './embedding-service.mjs'; +const embeddings = await startEmbeddingService(); +process.env.AI_SERVICE_URL = embeddings.url; +const require = createRequire(import.meta.url); +const { default: app } = require('../backend/src/app.ts'); +const port = Number(process.env.PORT ?? 3001); +app.listen(port, '127.0.0.1', () => console.log(`E2E API: http://127.0.0.1:${port}`)); diff --git a/e2e/setup.mjs b/e2e/setup.mjs new file mode 100644 index 0000000..2bde97f --- /dev/null +++ b/e2e/setup.mjs @@ -0,0 +1,12 @@ +import { randomBytes, scryptSync } from 'node:crypto'; +import { testDatabase } from './database.mjs'; + +// Test-only bootstrap: no production endpoint or change to registration authorization. +export async function setup() { + const salt = randomBytes(16).toString('hex'); + const hash = ['scrypt', 'v1', salt, scryptSync('E2E-admin-only-123', salt, 64).toString('hex')].join('$'); + await testDatabase(async db => { + await db.query(`INSERT INTO usuario(nome,email,senha_hash,role) VALUES('Admin E2E','admin@e2e.test',$1,'admin') + ON CONFLICT(email) DO UPDATE SET senha_hash=$1, role='admin', tentativas_login=0, bloqueado_ate=NULL`, [hash]); + }); +} diff --git a/e2e/support.mjs b/e2e/support.mjs new file mode 100644 index 0000000..5b0c72e --- /dev/null +++ b/e2e/support.mjs @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto"; +import { chromium } from "playwright-core"; + +export const API = process.env.E2E_API_URL ?? "http://localhost:3001/api/v1"; +export const APP = process.env.E2E_APP_URL ?? "http://localhost:5173"; +export const PASSWORD = "Senha-forte-123"; + +export async function api(path, { token, method = "GET", body, headers = {} } = {}) { + const raw = body instanceof Uint8Array; + const form = body instanceof FormData; + const response = await fetch(`${API}${path}`, { + method, + headers: { + // FormData: o fetch gera o Content-Type com boundary sozinho; forçar + // aqui quebraria o parsing multipart do multer no backend. + ...(body && !raw && !form ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: raw || form ? body : body ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + let json; + try { json = text ? JSON.parse(text) : undefined; } catch { json = text; } + return { status: response.status, json }; +} + +export async function createUser(role) { + let token; + if (role !== 'dev') { + const admin = await api('/auth/login', { method: 'POST', body: { email: 'admin@e2e.test', password: 'E2E-admin-only-123' } }); + if (admin.status !== 200) throw new Error('Execute o setup do banco E2E isolado antes dos testes.'); + token = admin.json.token; + } + const email = `${role}-${randomUUID().slice(0, 8)}@e2e.test`; + const registered = await api("/auth/register", { token, method: "POST", body: { nome: `E2E ${role}`, email, password: PASSWORD, role } }); + if (registered.status !== 201 && registered.status !== 200) throw new Error(`register ${role}: ${registered.status} ${JSON.stringify(registered.json)}`); + const login = await api("/auth/login", { method: "POST", body: { email, password: PASSWORD } }); + if (login.status !== 200) throw new Error(`login ${role}: ${login.status}`); + return { email, password: PASSWORD, token: login.json.token, user: login.json.user }; +} + +export async function createProject(account, label = "Projeto") { + const response = await api("/projects", { + method: "POST", + token: account.token, + body: { nome: `${label} ${randomUUID().slice(0, 8)}`, cliente: "Cliente E2E", descricao: "Projeto criado pelo E2E." }, + }); + if (response.status !== 201) throw new Error(`projeto: ${response.status} ${JSON.stringify(response.json)}`); + return response.json; +} + +export async function archiveProject(account, project) { + const impact = await api(`/projects/${project.id}/archive-impact`, { token: account.token }); + const archived = await api(`/projects/${project.id}/archive`, { method: "PATCH", token: account.token, body: { confirmado: true, impacto: impact.json } }); + if (archived.status !== 200) throw new Error(`arquivar: ${archived.status} ${JSON.stringify(archived.json)}`); +} + +export function uploadRaw(account, projectId, name, content) { + // Backend usa multer com upload.single("file"): exige multipart/form-data, + // campo "file" — mesmo contrato que o frontend usa (ver api_documents.ts). + const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content; + const form = new FormData(); + form.append("file", new Blob([bytes]), name); + return api(`/projects/${projectId}/documents`, { + method: "POST", + token: account.token, + body: form, + }); +} + +export const pdfContent = () => `%PDF-1.7\n${" ".repeat(200)}`; + +let browser; + +export async function getBrowser() { + browser ??= await chromium.launch({ + headless: true, + ...(process.env.E2E_CHROME_PATH ? { executablePath: process.env.E2E_CHROME_PATH } : { channel: "chrome" }), + }); + return browser; +} + +export async function closeBrowser() { + await browser?.close(); + browser = undefined; +} + +export async function openPage(account, { width = 1440, height = 900 } = {}) { + const context = await (await getBrowser()).newContext({ viewport: { width, height } }); + const page = await context.newPage(); + await page.addInitScript((token) => localStorage.setItem("app_auth_token", token), account.token); + return { context, page }; +} + +export async function assertNoHorizontalOverflow(page, label) { + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + if (overflow > 1) throw new Error(`${label}: overflow horizontal de ${overflow}px`); +} + +export async function createHierarchy(account, project, labels = {}) { + const epic = await api("/epics", { method: "POST", token: account.token, body: { projeto_id: project.id, titulo: labels.epic ?? "Épico de autenticação", descricao: "Objetivo de acesso seguro à plataforma", objetivo: "Garantir acesso seguro", escopo_macro: "Autenticação e sessão", resultado_esperado: "Acessos autenticados" } }); + if (epic.status !== 201) throw new Error(`épico: ${epic.status} ${JSON.stringify(epic.json)}`); + const feature = await api("/features", { method: "POST", token: account.token, body: { epico_id: epic.json.id, titulo: labels.feature ?? "Sessão do usuário", descricao: "Controle de sessão e expiração", objetivo: "Gerenciar sessão", criterios_sucesso: "Sessão válida", comportamento_esperado: "Expirar acesso inválido" } }); + if (feature.status !== 201) throw new Error(`feature: ${feature.status} ${JSON.stringify(feature.json)}`); + const pbi = await api("/pbis", { + method: "POST", + token: account.token, + body: { + feature_id: feature.json.id, + titulo: labels.pbi ?? "Validar credenciais no login", + historia_como_um: "usuário cadastrado", + historia_eu_quero: "entrar com e-mail e senha", + historia_para_que: "acessar meus projetos com segurança", + }, + }); + if (pbi.status !== 201) throw new Error(`pbi: ${pbi.status} ${JSON.stringify(pbi.json)}`); + return { epic: epic.json, feature: feature.json, pbi: pbi.json }; +} + +export async function addScenario(account, pbiId) { + return api("/criteria", { + method: "POST", + token: account.token, + body: { entidade_tipo: "pbi", entidade_id: pbiId, nome: "Login válido", dado: "um usuário cadastrado", quando: "informar credenciais corretas", entao: "o sistema abre a área interna" }, + }); +} + +export async function runAxe(page, label, { include } = {}) { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + await page.addScriptTag({ path: require.resolve("axe-core/axe.min.js") }); + const result = await page.evaluate(async (scope) => { + // eslint-disable-next-line no-undef + return axe.run(scope ? { include: [scope] } : document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] } }); + }, include ?? null); + const blocking = result.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious"); + return { label, blocking, all: result.violations }; +} + +export function formatViolations(report) { + return report.blocking + .map((violation) => `${report.label}: [${violation.impact}] ${violation.id} - ${violation.help} (${violation.nodes.length} elemento(s): ${violation.nodes.slice(0, 2).map((node) => node.target.join(" ")).join(" | ")})`) + .join("\n"); +} diff --git a/e2e/tests/expect.mjs b/e2e/tests/expect.mjs new file mode 100644 index 0000000..bf40fd3 --- /dev/null +++ b/e2e/tests/expect.mjs @@ -0,0 +1,21 @@ +const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS ?? 10000); + +export function expect(target) { + const isLocator = typeof target.waitFor === "function"; + return { + async toBeVisible() { + if (!isLocator) throw new Error("toBeVisible exige um locator"); + await target.first().waitFor({ state: "visible", timeout: TIMEOUT }); + }, + async toHaveAttribute(name, value) { + const deadline = Date.now() + TIMEOUT; + let current = null; + while (Date.now() < deadline) { + current = await target.first().getAttribute(name); + if (current === value) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`atributo ${name}: esperado "${value}", recebido "${current}"`); + }, + }; +} diff --git a/e2e/tests/knowledge.e2e.mjs b/e2e/tests/knowledge.e2e.mjs new file mode 100644 index 0000000..56989cc --- /dev/null +++ b/e2e/tests/knowledge.e2e.mjs @@ -0,0 +1,47 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { testDatabase } from '../database.mjs'; +import { expect } from './expect.mjs'; +import { APP, api, closeBrowser, createProject, createUser, openPage, uploadRaw } from '../support.mjs'; +after(() => closeBrowser()); +test('S2-08: trecho e fonte, isolamento e exclusão do documento na UI autenticada', async () => { + const po = await createUser('po'); + const project = await createProject(po, 'Busca isolada'); + const other = await createProject(po, 'Acervo sem documento'); + const uploaded = await uploadRaw(po, project.id, 'Especificacao QA.md', '# Integração\n\nConfirmar o pagamento PIX.'); + assert.equal(uploaded.status, 201); + const chunkId = randomUUID(); + await testDatabase(async (db) => { + await db.query("UPDATE documento SET status_processamento='processado' WHERE id=$1", [uploaded.json.id]); + await db.query(`INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto,metadados_json) + VALUES($1,$2,'documento',$3,'Confirmar o pagamento PIX no projeto de origem.',$4::jsonb)`, [chunkId, project.id, uploaded.json.id, JSON.stringify({ chunk_index: 0, source_name: 'Especificacao QA.md', source_locator: 'DOC-23' })]); + }); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/knowledge`); + await page.getByRole('option', { name: project.nome, exact: true }).waitFor({ state: 'attached' }); + await page.getByLabel('Escopo do projeto').selectOption(other.id); + await page.getByLabel('Pesquisar no acervo').fill('DOC-23'); + await page.getByRole('button', { name: 'Pesquisar', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Nenhum resultado encontrado' })).toBeVisible(); + await page.getByLabel('Escopo do projeto').selectOption(project.id); + await page.getByRole('button', { name: 'Pesquisar', exact: true }).click(); + await expect(page.locator('.knowledge-excerpt').filter({ hasText: 'Confirmar o pagamento PIX' })).toBeVisible(); + await expect(page.getByText('Projeto: ' + project.nome, { exact: true })).toBeVisible(); + await expect(page.getByText('Documento de origem: Especificacao QA.md', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Abrir documento no trecho' }).click(); + await expect(page.getByRole('heading', { name: 'Trecho 1 · Resultado selecionado' })).toBeVisible(); + assert.equal(await page.locator('#source-chunk-' + chunkId).evaluate(node => node === document.activeElement), true); + await page.getByRole('button', { name: 'Fechar documento' }).click(); + assert.equal((await api(`/projects/${project.id}/documents/${uploaded.json.id}`, { method: 'DELETE', token: po.token })).status, 204); + await page.getByRole('button', { name: 'Abrir documento no trecho' }).click(); + await expect(page.getByText('O documento não está disponível. Ele pode ter sido removido ou reprocessado.')).toBeVisible(); + await page.getByRole('button', { name: 'Fechar documento' }).click(); + await page.getByRole('button', { name: 'Pesquisar', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Nenhum resultado encontrado' })).toBeVisible(); + } + finally { + await context.close(); + } +}); diff --git a/e2e/tests/purge.e2e.mjs b/e2e/tests/purge.e2e.mjs new file mode 100644 index 0000000..d57ff8a --- /dev/null +++ b/e2e/tests/purge.e2e.mjs @@ -0,0 +1,61 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { testDatabase } from '../database.mjs'; +import { expect } from './expect.mjs'; +import { APP, api, closeBrowser, createProject, createUser, openPage, uploadRaw } from '../support.mjs'; + +after(() => closeBrowser()); +test('S2-05: expurgo autenticado exige confirmação, cancela relatórios e preserva outro projeto', async () => { + const admin = await createUser('admin'); + const po = await createUser('po'); + const project = await createProject(admin, 'Expurgo QA'); + const other = await createProject(admin, 'Contexto preservado'); + const documents = []; + for (const item of [project, other]) { + const uploaded = await uploadRaw(admin, item.id, 'Contexto.md', '# Contexto\n\nPagamento PIX.'); + assert.equal(uploaded.status, 201); + documents.push(uploaded.json.id); + await testDatabase(async db => { + await db.query("UPDATE documento SET status_processamento='processado' WHERE id=$1", [uploaded.json.id]); + await db.query("INSERT INTO chunk(projeto_id,entidade_tipo,entidade_id,texto,metadados_json) VALUES($1,'documento',$2,'Pagamento PIX',$3::jsonb)", [item.id, uploaded.json.id, JSON.stringify({ source_locator: 'DOC-23' })]); + }); + } + const analysis = randomUUID(); + await testDatabase(db => db.query("INSERT INTO analise_repositorio(id,projeto_id,usuario_id,repositorio_url,run_id,status,relatorio_markdown) VALUES($1,$2,$3,'https://github.com/test/purge',$4,'concluido','# Relatório pendente')", [analysis, project.id, admin.user.id, 'run-' + analysis])); + assert.equal((await api(`/projects/${project.id}/repo-analyses/${analysis}/report/approve`, { method: 'POST', token: admin.token, body: { relatorio_revisao: 1 } })).status, 200); + assert.equal((await api(`/admin/projects/${project.id}/purge`, { method: 'POST', token: po.token, body: { confirmacao_nome: project.nome } })).status, 403); + const { page, context } = await openPage(admin); + try { + await page.goto(`${APP}/admin`); + await page.getByRole('option', { name: project.nome, exact: true }).first().waitFor({ state: 'attached' }); + await page.getByLabel('Projeto para expurgo').selectOption(project.id); + const button = page.getByRole('button', { name: 'Expurgar contexto do projeto', exact: true }); + assert.equal(await button.isDisabled(), true); + await page.locator('#purge-confirm-name').fill('Nome errado'); + assert.equal(await button.isDisabled(), true); + await page.locator('#purge-confirm-name').fill(project.nome); + const response = page.waitForResponse(item => item.url().endsWith(`/admin/projects/${project.id}/purge`) && item.request().method() === 'POST'); + await button.click(); + assert.equal((await response).status(), 200); + await expect(page.getByText(/Contexto indexado do projeto expurgado com sucesso/)).toBeVisible(); + assert.equal((await api(`/projects/${project.id}/repo-analyses/${analysis}/report/approve`, { method: 'POST', token: admin.token, body: { relatorio_revisao: 1 } })).status, 409); + await testDatabase(async db => { + assert.equal((await db.query('SELECT count(*)::int AS n FROM chunk WHERE projeto_id=$1', [project.id])).rows[0].n, 0); + assert.equal((await db.query('SELECT count(*)::int AS n FROM chunk WHERE projeto_id=$1', [other.id])).rows[0].n, 1); + const report = (await db.query('SELECT aprovacao_hash,indexacao_status,indexacao_lease_id FROM analise_repositorio WHERE id=$1', [analysis])).rows[0]; + assert.deepEqual(report, { aprovacao_hash: null, indexacao_status: 'aguardando_aprovacao', indexacao_lease_id: null }); + assert.equal((await db.query('SELECT status_processamento FROM documento WHERE id=$1', [documents[0]])).rows[0].status_processamento, 'falha'); + assert.equal((await db.query("SELECT count(*)::int AS n FROM auditoria WHERE entidade_id=$1 AND acao='EXPURGAR_CONTEXTO'", [project.id])).rows[0].n, 1); + }); + await page.goto(`${APP}/knowledge`); + await page.getByRole('option', { name: project.nome, exact: true }).waitFor({ state: 'attached' }); + await page.getByLabel('Escopo do projeto').selectOption(project.id); + await page.getByLabel('Pesquisar no acervo').fill('DOC-23'); + await page.getByRole('button', { name: 'Pesquisar', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Nenhum resultado encontrado' })).toBeVisible(); + await page.getByLabel('Escopo do projeto').selectOption(other.id); + await page.getByRole('button', { name: 'Pesquisar', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Abrir documento no trecho' })).toBeVisible(); + } finally { await context.close(); } +}); diff --git a/e2e/tests/report-review.e2e.mjs b/e2e/tests/report-review.e2e.mjs new file mode 100644 index 0000000..ac8596f --- /dev/null +++ b/e2e/tests/report-review.e2e.mjs @@ -0,0 +1,68 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { testDatabase, allocateDeveloper } from '../database.mjs'; +import { expect } from './expect.mjs'; +import { APP, api, closeBrowser, createProject, createUser, openPage } from '../support.mjs'; +after(() => closeBrowser()); +test('S2-23: aprovação humana real, proveniência, busca, navegação e autorização na UI', async () => { + const po = await createUser('po'); + const admin = await createUser('admin'); + const dev = await createUser('dev'); + const project = await createProject(po, 'Relatório revisado'); + const other = await createProject(po, 'Outro acervo'); + await allocateDeveloper(dev, project); + const id = randomUUID(); + const run = 'QA-223-' + id; + await testDatabase(db => db.query(`INSERT INTO analise_repositorio(id,projeto_id,usuario_id,repositorio_url,run_id,status,relatorio_markdown) + VALUES($1,$2,$3,'https://github.com/test/review',$4,'concluido','# Relatório QA\n\nArquitetura PostgreSQL revisada')`, [id, project.id, po.user.id, run])); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}#repo-analyzer=${id}`); + await expect(page.getByText(/Gerado por IA e ainda não revisado/)).toBeVisible(); + const approvalResponse = page.waitForResponse(response => response.url().endsWith('/report/approve') && response.request().method() === 'POST'); + await page.getByRole('button', { name: 'Aprovar relatório e publicar no acervo' }).click(); + assert.equal((await approvalResponse).status(), 200); + await testDatabase(async (db) => { + const approved = (await db.query('SELECT * FROM analise_repositorio WHERE id=$1', [id])).rows[0]; + assert.equal(approved.aprovado_por, po.user.id); + assert.ok(approved.aprovado_em); + assert.equal(approved.indexacao_status, 'pendente'); + // Fixture do resultado do worker; o harness exercita o processamento real em serviço separado. + await db.query(`INSERT INTO chunk(projeto_id,entidade_tipo,entidade_id,texto,metadados_json) + VALUES($1,'analise_repositorio',$2,'Arquitetura PostgreSQL revisada',$3::jsonb)`, [project.id, id, JSON.stringify({ run_id: run, source_name: 'https://github.com/test/review', provenance: 'ai-accepted' })]); + await db.query("UPDATE analise_repositorio SET indexacao_status='indexado' WHERE id=$1", [id]); + }); + await page.goto(`${APP}/knowledge`); + await page.getByRole('option', { name: project.nome, exact: true }).waitFor({ state: 'attached' }); + await page.getByLabel('Escopo do projeto').selectOption(project.id); + await page.getByLabel('Pesquisar no acervo').fill('Arquitetura PostgreSQL'); + await page.getByRole('button', { name: 'Pesquisar', exact: true }).click(); + await expect(page.getByText('Relatório de IA revisado · Execução: ' + run)).toBeVisible(); + await expect(page.locator('.knowledge-excerpt').filter({ hasText: 'Arquitetura PostgreSQL revisada' })).toBeVisible(); + await page.getByRole('link', { name: 'Abrir origem' }).click(); + await expect(page.getByRole('heading', { name: 'Relatório QA', exact: true })).toBeVisible(); + assert.ok(page.url().endsWith('#repo-analyzer=' + id)); + const isolated = await api(`/search?q=Arquitetura&projeto_id=${other.id}`, { token: po.token }); + assert.equal(isolated.status, 200); + assert.equal(isolated.json.items.some(hit => hit.entity_id === id), false); + } + finally { + await context.close(); + } + for (const account of [admin, dev]) { + const browser = await openPage(account); + try { + await browser.page.goto(`${APP}/projects/${project.id}#repo-analyzer=${id}`); + await expect(browser.page.getByRole('heading', { name: 'Relatório QA', exact: true })).toBeVisible(); + assert.equal(await browser.page.getByRole('button', { name: 'Reprocessar relatório' }).count(), account === admin ? 1 : 0); + } + finally { + await browser.context.close(); + } + } + assert.equal((await api(`/projects/${project.id}/repo-analyses/${id}/indexing/retry`, { token: dev.token, method: 'POST', body: {} })).status, 403); + await testDatabase(db => db.query("UPDATE analise_repositorio SET relatorio_markdown='Nova versão sem revisão' WHERE id=$1", [id])); + const revoked = await api(`/search?q=Arquitetura&projeto_id=${project.id}`, { token: po.token }); + assert.equal(revoked.json.items.some(hit => hit.entity_id === id), false); +}); diff --git a/e2e/tests/suggestions.e2e.mjs b/e2e/tests/suggestions.e2e.mjs new file mode 100644 index 0000000..bc68dbb --- /dev/null +++ b/e2e/tests/suggestions.e2e.mjs @@ -0,0 +1,66 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { allocateDeveloper, testDatabase } from '../database.mjs'; +import { expect } from './expect.mjs'; +import { APP, api, closeBrowser, createHierarchy, createProject, createUser, openPage } from '../support.mjs'; + +after(() => closeBrowser()); +test('S2-13/S2-14: aceite, edição e descarte explícitos, papel autorizado e proveniência sem IA', async () => { + const po = await createUser('po'); + const dev = await createUser('dev'); + const project = await createProject(po, 'Governança IA'); + await allocateDeveloper(dev, project); + const { epic, feature, pbi } = await createHierarchy(po, project); + const root = `/pbis/${pbi.id}/suggestions`; + const suggestions = []; + for (const [campo, valor_sugerido] of [['titulo', 'Consultar saldo validado'], ['historia_eu_quero', 'consultar o saldo'], ['historia_para_que', 'texto a descartar']]) { + const result = await api(root, { method: 'POST', token: po.token, body: { campo, valor_sugerido, origem: 'qa-harness' } }); + assert.equal(result.status, 201); + suggestions.push(result.json); + } + const path = `${APP}/projects/${project.id}/epics/${epic.id}/features/${feature.id}/pbis/${pbi.id}`; + const developer = await openPage(dev); + try { + await developer.page.goto(path); + const panel = developer.page.getByRole('region', { name: 'Sugestões da IA', exact: true }); + await expect(panel.getByText('Consultar saldo validado', { exact: true })).toBeVisible(); + assert.equal(await panel.getByRole('button', { name: 'Aceitar', exact: true }).count(), 0); + for (const action of ['accept', 'edit', 'discard']) { + assert.equal((await api(`${root}/${suggestions[0].id}/${action}`, { method: 'POST', token: dev.token, body: { valor: 'negado' } })).status, 403); + } + } finally { await developer.context.close(); } + const { page, context } = await openPage(po); + try { + await page.goto(path); + const panel = page.getByRole('region', { name: 'Sugestões da IA', exact: true }); + const cards = panel.getByRole('region', { name: 'Sugestão da IA', exact: true }); + const accept = page.waitForResponse(response => response.url().endsWith('/accept') && response.request().method() === 'POST'); + await cards.filter({ hasText: 'Consultar saldo validado' }).getByRole('button', { name: 'Aceitar', exact: true }).click(); + assert.equal((await accept).status(), 200); + await cards.filter({ hasText: 'consultar o saldo' }).getByRole('button', { name: 'Editar antes de aceitar' }).click(); + await panel.getByLabel('Valor editado', { exact: true }).fill('consultar o saldo atualizado'); + const edit = page.waitForResponse(response => response.url().endsWith('/edit') && response.request().method() === 'POST'); + await panel.getByRole('button', { name: 'Confirmar edição' }).click(); + assert.equal((await edit).status(), 200); + const discard = page.waitForResponse(response => response.url().endsWith('/discard') && response.request().method() === 'POST'); + await cards.filter({ hasText: 'texto a descartar' }).getByRole('button', { name: 'Descartar', exact: true }).click(); + assert.equal((await discard).status(), 200); + await testDatabase(async db => { + const row = (await db.query('SELECT * FROM pbi WHERE id=$1', [pbi.id])).rows[0]; + assert.equal(row.titulo, 'Consultar saldo validado'); + assert.equal(row.historia_eu_quero, 'consultar o saldo atualizado'); + assert.equal(row.historia_para_que, pbi.historia_para_que); + assert.equal(row.provenance_json.titulo, 'ai-accepted'); + assert.equal(row.provenance_json.historia_eu_quero, 'ai-edited'); + const saved = (await db.query('SELECT status,resolvido_por,origem FROM sugestao_ia WHERE entidade_id=$1 ORDER BY campo', [pbi.id])).rows; + assert.equal(saved.length, 3); + assert.ok(saved.every(item => item.resolvido_por === po.user.id && item.origem === 'qa-harness')); + assert.deepEqual(new Set(saved.map(item => item.status)), new Set(['aceita', 'editada', 'descartada'])); + }); + const unavailable = await api(`/projects/${project.id}/ai/requests`, { method: 'POST', token: po.token, + body: { mode: 'levantamento', operation: 'questionar', level: 'pbi', fields: { titulo: 'Consultar saldo' } } }); + assert.equal(unavailable.status, 503); + await page.reload(); + await expect(page.getByRole('heading', { name: /Consultar saldo validado/ })).toBeVisible(); + } finally { await context.close(); } +}); diff --git a/frontend/package.json b/frontend/package.json index 6dc643a..529f3eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "lucide-react": "^0.475.0", @@ -18,6 +19,11 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.3", - "vite": "^6.4.5" + "vite": "^6.4.5", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "jsdom": "^26.1.0", + "vitest": "^4.1.11" } } diff --git a/frontend/src/api/api_repo_analyzer.ts b/frontend/src/api/api_repo_analyzer.ts index a4d7f23..802b53c 100644 --- a/frontend/src/api/api_repo_analyzer.ts +++ b/frontend/src/api/api_repo_analyzer.ts @@ -2,7 +2,7 @@ import { apiRequest } from "./api_auth"; export type RepoAnalysisStatus = "iniciado" | "em_execucao" | "pausando" | "pausada" | "cancelando" | "cancelada" | "concluido" | "falha"; export type RepoAnalysisProfile = "quick" | "balanced" | "complete"; -export type RepoAnalysisAction = "pause" | "resume" | "cancel" | "indexing/retry"; +export type RepoAnalysisAction = "pause" | "resume" | "cancel" | "indexing/retry" | "report/approve"; export interface RepoAnalysis { id: string; @@ -15,6 +15,7 @@ export interface RepoAnalysis { progresso: number | null; mensagem?: string | null; erro?: string | null; + relatorio_revisao?: number; relatorio_markdown?: string | null; metadados?: Record | null; created_at: string; @@ -22,7 +23,7 @@ export interface RepoAnalysis { concluido_em?: string | null; autor_nome?: string | null; autor_email?: string | null; - indexacao_status?: 'nao_aplicavel' | 'pendente' | 'processando' | 'indexado' | 'falha'; + indexacao_status?: 'nao_aplicavel' | 'aguardando_aprovacao' | 'pendente' | 'processando' | 'indexado' | 'falha'; indexacao_erro?: string | null; } @@ -36,10 +37,10 @@ export async function startRepoAnalysis(projectId: string, repositoryUrl: string return await response.json(); } -export async function controlRepoAnalysis(projectId: string, analysisId: string, action: RepoAnalysisAction, signal?: AbortSignal): Promise { +export async function controlRepoAnalysis(projectId: string, analysisId: string, action: RepoAnalysisAction, signal?: AbortSignal, reportRevision?: number): Promise { const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/repo-analyses/${encodeURIComponent(analysisId)}/${action}`, { method: "POST", - body: JSON.stringify({}), + body: JSON.stringify(action === "report/approve" ? { relatorio_revisao: reportRevision } : {}), signal, }); return await response.json(); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..5c1a635 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1,19 @@ +import "@testing-library/jest-dom/vitest"; + +// Node 26 no Windows não expõe o localStorage do jsdom como global em todas +// as execuções do Vitest. A aplicação usa somente a interface Storage, então +// este fallback mantém o contrato dos testes sem depender do localStorage nativo. +if (typeof globalThis.localStorage === "undefined") { + const values = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(String(key), String(value)), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + key: (index: number) => [...values.keys()][index] ?? null, + get length() { return values.size; }, + } satisfies Storage, + }); +} diff --git a/frontend/src/views/knowledge/KnowledgeView.tsx b/frontend/src/views/knowledge/KnowledgeView.tsx index 2d73662..f129e07 100644 --- a/frontend/src/views/knowledge/KnowledgeView.tsx +++ b/frontend/src/views/knowledge/KnowledgeView.tsx @@ -101,7 +101,7 @@ export function KnowledgeView() {
{SEARCH_LEVELS[item.entity_type]}Projeto: {item.project_name}

{item.title?.trim() || SEARCH_LEVELS[item.entity_type] + " de origem"}

{searchExcerpt(item.text, query)}

- {item.entity_type === 'analise_repositorio' && typeof item.metadata.run_id === 'string' &&

Execução: {item.metadata.run_id}

} + {item.entity_type === 'analise_repositorio' && typeof item.metadata.run_id === 'string' &&

Relatório de IA revisado · Execução: {item.metadata.run_id}

} {typeof sourceName === "string" &&

Documento de origem: {sourceName}

} {item.entity_type === "documento" ? : href ? Abrir origem :

Origem sem rota disponível.

} diff --git a/frontend/src/views/projects/ProjectDetail.tsx b/frontend/src/views/projects/ProjectDetail.tsx index 00943a2..53b758a 100644 --- a/frontend/src/views/projects/ProjectDetail.tsx +++ b/frontend/src/views/projects/ProjectDetail.tsx @@ -277,7 +277,7 @@ export function ProjectDetail({ )} diff --git a/frontend/src/views/projects/RepoAnalyzerView.test.tsx b/frontend/src/views/projects/RepoAnalyzerView.test.tsx new file mode 100644 index 0000000..0522fb4 --- /dev/null +++ b/frontend/src/views/projects/RepoAnalyzerView.test.tsx @@ -0,0 +1,298 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { ProjectDetail } from "./ProjectDetail"; +import { RepoAnalyzerView } from "./RepoAnalyzerView"; + +const analysis = (overrides: Record = {}) => ({ + id: "a-1", projeto_id: "p-1", repositorio_url: "https://github.com/acme/api", run_id: "r-1", status: "em_execucao", + etapa: "scan", etapa_label: "Inventariando arquivos", progresso: 30, mensagem: null, erro: null, relatorio_markdown: null, + metadados: null, created_at: "2026-09-20T13:00:00Z", updated_at: "2026-09-20T13:00:00Z", concluido_em: null, + autor_nome: "Ana PO", autor_email: null, + ...overrides, +}); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.useRealTimers(); window.history.replaceState(null, '', window.location.pathname); }); + +it("mostra histórico, etapas e progresso sem inventar autor", async () => { + vi.stubGlobal("fetch", vi.fn(() => json([analysis({ autor_nome: null, metadados: { files_total: 40, files_processed: 12, language_counts: { ts: 20, py: 5 } } })]))); + render(); + + expect(await screen.findByRole("progressbar", { name: "Progresso da análise" })).toHaveAttribute("aria-valuenow", "30"); + const steps = within(screen.getByRole("list", { name: "Etapas da análise" })).getAllByRole("listitem"); + expect(steps).toHaveLength(5); + expect(steps[2]).toHaveAttribute("aria-current", "step"); + expect(steps[0]).toHaveTextContent("concluída"); + expect(screen.getByText(/12 de 40 arquivos analisados/)).toBeInTheDocument(); + expect(screen.getByRole("list", { name: "Linguagens encontradas" })).toHaveTextContent("ts"); + expect(screen.queryByText(/po@sinapse\.local/)).toBeNull(); + expect(screen.queryByText(/Ana PO/)).toBeNull(); +}); + +it("avança o progresso e conclui com relatório renderizado enquanto consulta em segundo plano", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const request = vi.fn() + .mockImplementationOnce(() => json([analysis()])) + .mockImplementationOnce(() => json([analysis({ progresso: 70, etapa: "synthesis", etapa_label: "Gerando síntese" })])) + .mockImplementation(() => json([analysis({ status: "concluido", etapa: "done", progresso: 100, relatorio_markdown: "# Relatório final\n\n- item **um**", concluido_em: "2026-09-20T13:02:00Z" })])); + vi.stubGlobal("fetch", request); + render(); + + await screen.findByText("Inventariando arquivos"); + await vi.advanceTimersByTimeAsync(4100); + expect(await screen.findByText("Gerando síntese")).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(4100); + expect(await screen.findByRole("heading", { name: "Relatório final" })).toBeInTheDocument(); + expect(screen.getByText("um").tagName).toBe("STRONG"); + expect(screen.getByRole("button", { name: "Baixar .md" })).toBeInTheDocument(); + expect(screen.getByText(/duração 2 min/)).toBeInTheDocument(); + + const calls = request.mock.calls.length; + await vi.advanceTimersByTimeAsync(9000); + expect(request.mock.calls.length).toBe(calls); +}); + +it("alterna o relatório entre leitura e Markdown bruto", async () => { + vi.stubGlobal("fetch", vi.fn(() => json([analysis({ status: "concluido", etapa: "done", relatorio_markdown: "# Título\n\nTexto" })]))); + render(); + + await screen.findByRole("heading", { name: "Título" }); + fireEvent.click(screen.getByRole("button", { name: "Markdown" })); + await waitFor( + () => expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"), + { timeout: 3000 }, + ); + expect(screen.queryByRole("heading", { name: "Título" })).toBeNull(); + expect(screen.getByText(/# Título/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); +}); + +it("mantém o formato ao atualizar e volta à leitura ao selecionar outra análise", async () => { + vi.stubGlobal("fetch", vi.fn(() => json([ + analysis({ status: "concluido", relatorio_markdown: "# Primeiro" }), + analysis({ id: "a-2", repositorio_url: "https://github.com/acme/outro", status: "concluido", relatorio_markdown: "# Segundo" }), + ]))); + render(); + + await screen.findByRole("heading", { name: "Primeiro" }); + fireEvent.click(screen.getByRole("button", { name: "Markdown" })); + expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Atualizar" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Atualizar" })).toHaveAttribute("aria-busy", "false")); + expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); + + const history = screen.getByRole("complementary", { name: "Histórico de análises" }); + fireEvent.click(within(history).getByText("acme/outro")); + expect(await screen.findByRole("heading", { name: "Segundo" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Leitura" })).toHaveAttribute("aria-pressed", "true"); +}); + +it("erro ao carregar oferece nova tentativa", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ error: "x" }, 500)) + .mockImplementationOnce(() => json([])); + vi.stubGlobal("fetch", request); + render(); + + expect((await screen.findByRole("alert")).textContent).toBe("Não foi possível carregar as análises. Tente novamente."); + fireEvent.click(screen.getByText("Tentar novamente")); + expect(await screen.findByText("Nenhuma análise realizada neste projeto.")).toBeInTheDocument(); + expect(screen.getByText("Nenhuma análise selecionada")).toBeInTheDocument(); +}); + +it("traduz projeto inexistente e acesso negado", async () => { + vi.stubGlobal("fetch", vi.fn(() => json({ error: "x" }, 404))); + render(); + expect((await screen.findByRole("alert")).textContent).toBe("Projeto não encontrado."); + cleanup(); + vi.stubGlobal("fetch", vi.fn(() => json({ error: "x" }, 403))); + render(); + expect((await screen.findByRole("alert")).textContent).toMatch(/não tem permissão/); +}); + +it("valida a URL no cliente antes de chamar o backend", async () => { + const request = vi.fn(() => json([])); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText("Nenhuma análise realizada neste projeto."); + + fireEvent.click(screen.getByRole("button", { name: "Iniciar análise" })); + expect(screen.getByText("Informe a URL do repositório.")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/URL do repositório/), { target: { value: "https://gitlab.com/acme/api" } }); + fireEvent.click(screen.getByRole("button", { name: "Iniciar análise" })); + expect(screen.getByText("Use o formato https://github.com/usuario/repositorio.")).toBeInTheDocument(); + expect(request).toHaveBeenCalledTimes(1); +}); + +it("inicia a análise, seleciona a nova e exibe a mensagem do servidor quando recusa", async () => { + const created = analysis({ id: "a-2", repositorio_url: "https://github.com/acme/novo", status: "iniciado", progresso: 0, etapa: "queued", etapa_label: "Na fila" }); + const request = vi.fn() + .mockImplementationOnce(() => json([])) + .mockImplementationOnce(() => json({ error: "Falha ao iniciar análise no motor de IA: indisponível" }, 400)) + .mockImplementationOnce(() => json(created, 201)) + .mockImplementation(() => json([created])); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText("Nenhuma análise realizada neste projeto."); + + fireEvent.change(screen.getByLabelText(/URL do repositório/), { target: { value: "https://github.com/acme/novo" } }); + fireEvent.click(screen.getByRole("button", { name: "Iniciar análise" })); + expect(await screen.findByText(/Falha ao iniciar análise no motor de IA/)).toBeInTheDocument(); + expect(screen.getByLabelText(/URL do repositório/)).toHaveValue("https://github.com/acme/novo"); + + fireEvent.click(screen.getByRole("button", { name: "Iniciar análise" })); + expect(await screen.findByText("Na fila", { selector: ".analysis-progress-line span" })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText(/URL do repositório/)).toHaveValue("")); + const [url, init] = request.mock.calls[2] as [string, RequestInit]; + expect(url).toBe("/api/v1/projects/p-1/repo-analyses"); + expect(JSON.parse(String(init.body))).toEqual({ repositorio_url: "https://github.com/acme/novo", perfil: "quick" }); +}); + +it("análise com falha mostra o motivo, permite repetir e alterna pelo histórico", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json([ + analysis({ id: "a-1", status: "falha", erro: "Repositório inacessível", progresso: 10, etapa: "clone" }), + analysis({ id: "a-2", repositorio_url: "https://github.com/acme/outro", status: "concluido", etapa: "done", progresso: 100, relatorio_markdown: "# Outro" }), + ])) + .mockImplementationOnce(() => json(analysis({ id: "a-3", status: "iniciado", etapa: "queued", etapa_label: "Na fila", progresso: 0 }), 201)) + .mockImplementation(() => json([analysis({ id: "a-3", status: "iniciado", etapa: "queued", etapa_label: "Na fila", progresso: 0 })])); + vi.stubGlobal("fetch", request); + render(); + + expect(await screen.findByText("Repositório inacessível")).toBeInTheDocument(); + const history = screen.getByRole("complementary", { name: "Histórico de análises" }); + fireEvent.click(within(history).getByText("acme/outro")); + expect(await screen.findByRole("heading", { name: "Outro" })).toBeInTheDocument(); + expect(within(history).getByText("acme/outro").closest("button")).toHaveAttribute("aria-current", "true"); + + fireEvent.click(within(history).getByText("acme/api")); + fireEvent.click(await screen.findByRole("button", { name: "Analisar novamente" })); + await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + const [, init] = request.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ repositorio_url: "https://github.com/acme/api", perfil: "quick" }); +}); + +it("perfil sem permissão apenas consulta", async () => { + vi.stubGlobal("fetch", vi.fn(() => json([]))); + render(); + expect(await screen.findByText(/não pode iniciar novas/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Iniciar análise" })).toBeNull(); +}); + +it("envia perfil rápido ao iniciar uma análise", async () => { + const created = analysis({ status: "iniciado", etapa: "queued", etapa_label: "Na fila", progresso: 0 }); + const request = vi.fn() + .mockImplementationOnce(() => json([])) + .mockImplementationOnce(() => json(created, 201)) + .mockImplementation(() => json([created])); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText("Nenhuma análise realizada neste projeto."); + + fireEvent.change(screen.getByDisplayValue("Rápida · até 8 arquivos prioritários"), { target: { value: "balanced" } }); + fireEvent.change(screen.getByLabelText(/URL do repositório/), { target: { value: "https://github.com/acme/api" } }); + fireEvent.click(screen.getByRole("button", { name: "Iniciar análise" })); + await waitFor(() => expect(request).toHaveBeenCalledTimes(3)); + const [, options] = request.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(String(options.body))).toEqual({ repositorio_url: "https://github.com/acme/api", perfil: "balanced" }); +}); + +it("pausa uma análise e oferece retomada pelo checkpoint", async () => { + const paused = analysis({ status: "pausada", etapa: "files", etapa_label: "Analisando arquivos", progresso: 42, mensagem: "Progresso salvo." }); + const request = vi.fn() + .mockImplementationOnce(() => json([analysis()])) + .mockImplementationOnce(() => json(paused)) + .mockImplementationOnce(() => json([paused])); + vi.stubGlobal("fetch", request); + render(); + await screen.findByRole("button", { name: "Pausar" }); + fireEvent.click(screen.getByRole("button", { name: "Pausar" })); + + expect(await screen.findByRole("button", { name: "Retomar análise" })).toBeInTheDocument(); + expect(screen.getAllByText("Pausada").length).toBeGreaterThan(0); + expect(request.mock.calls[1][0]).toBe("/api/v1/projects/p-1/repo-analyses/a-1/pause"); +}); + +it("permite continuar uma análise que falhou depois de salvar progresso", async () => { + const failed = analysis({ status: "falha", etapa: "error", erro: "Limite temporário de saída", metadados: { files_total: 8, files_processed: 5, can_resume: true } }); + const resumed = analysis({ status: "iniciado", etapa: "queued", mensagem: "Retomando do último ponto salvo…" }); + const request = vi.fn() + .mockImplementationOnce(() => json([failed])) + .mockImplementationOnce(() => json(resumed)) + .mockImplementationOnce(() => json([resumed])); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Continuar com progresso salvo" })); + expect(request.mock.calls[1][0]).toBe("/api/v1/projects/p-1/repo-analyses/a-1/resume"); + expect(await screen.findByText("Retomando do último ponto salvo…")).toBeInTheDocument(); +}); + +it("pede confirmação antes de cancelar e registra o estado cancelado", async () => { + const cancelled = analysis({ status: "cancelada", etapa: "files", etapa_label: "Analisando arquivos", progresso: 30, mensagem: "Análise cancelada pelo usuário." }); + const request = vi.fn() + .mockImplementationOnce(() => json([analysis()])) + .mockImplementationOnce(() => json(cancelled)) + .mockImplementationOnce(() => json([cancelled])); + vi.stubGlobal("fetch", request); + vi.spyOn(window, "confirm").mockReturnValue(true); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Cancelar análise" })); + + expect(window.confirm).toHaveBeenCalled(); + expect((await screen.findAllByText("Cancelada")).length).toBeGreaterThan(0); + expect(request.mock.calls[1][0]).toBe("/api/v1/projects/p-1/repo-analyses/a-1/cancel"); +}); + +it('seleciona o relatorio indicado pela busca e permite reprocessar sem nova analise', async () => { + window.history.replaceState(null, '', '#repo-analyzer=a-2'); + let retry = false; + const older = () => analysis({ id: 'a-2', run_id: 'run-42', status: 'concluido', indexacao_status: retry ? 'pendente' : 'falha', relatorio_markdown: '# Relatorio selecionado' }); + const request = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === 'POST') { retry = true; return json(older()); } + return json([analysis({ status: 'concluido' }), older()]); + }); + vi.stubGlobal('fetch', request); + try { + render(); + expect(await screen.findByRole('heading', { name: 'Relatorio selecionado' })).toBeInTheDocument(); + expect(screen.getByText('Execução: run-42')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Reprocessar relatório' })); + await waitFor(() => expect(request.mock.calls.some(([url, init]) => url.endsWith('/a-2/indexing/retry') && init?.method === 'POST')).toBe(true)); + expect(await screen.findByText('Indexação no acervo: pendente')).toBeInTheDocument(); + } finally { window.history.replaceState(null, '', window.location.pathname); } +}); + +for (const role of ['po', 'admin', 'dev']) { + it(`S2-23: ações do relatório respeitam o papel ${role} no detalhe do projeto`, async()=>{ + vi.stubGlobal('fetch',vi.fn(()=>json([analysis({status:'concluido',relatorio_markdown:'# Revisado',indexacao_status:'falha'})]))); + render(); + await screen.findByRole('heading',{name:'Revisado'}); + expect(Boolean(screen.queryByRole('button',{name:'Reprocessar relatório'}))).toBe(role!=='dev'); + }); +} +it('S2-23: aprovação envia a versão exibida, bloqueia cliques durante loading e apresenta falha sem perder relatório',async()=>{ + let finish!: (response:Response)=>void; + const report=analysis({status:'concluido',relatorio_markdown:'# Rascunho revisável',relatorio_revisao:7,indexacao_status:'aguardando_aprovacao'}); + const request=vi.fn((_:unknown,init?:RequestInit)=>init?.method==='POST'?new Promise(resolve=>{finish=resolve;}):json([report])); + vi.stubGlobal('fetch',request); + render(); + const approve=await screen.findByRole('button',{name:'Aprovar relatório e publicar no acervo'}); + fireEvent.click(approve); + await waitFor(()=>expect(approve).toBeDisabled()); + expect(screen.getByRole('button',{name:'Publicando…'})).toBeDisabled(); + const post=request.mock.calls.find(([,init])=>init?.method==='POST')!; + expect(JSON.parse(post[1]!.body as string)).toEqual({relatorio_revisao:7}); + finish(new Response(JSON.stringify({error:'O relatório mudou. Atualize a página.'}),{status:409,headers:{'Content-Type':'application/json'}})); + expect(await screen.findByRole('alert')).toHaveTextContent('O relatório mudou'); + expect(screen.getByRole('heading',{name:'Rascunho revisável'})).toBeInTheDocument(); + expect(screen.getByRole('button',{name:'Aprovar relatório e publicar no acervo'})).toBeEnabled(); +}); +it('S2-23: desenvolvedor pode ler rascunho e não pode aprová-lo',async()=>{ + vi.stubGlobal('fetch',vi.fn(()=>json([analysis({status:'concluido',relatorio_markdown:'# Rascunho',indexacao_status:'aguardando_aprovacao'})]))); + render(); + await screen.findByRole('heading',{name:'Rascunho'}); + expect(screen.getByText(/Gerado por IA e ainda não revisado/)).toBeInTheDocument(); + expect(screen.queryByRole('button',{name:'Aprovar relatório e publicar no acervo'})).toBeNull(); +}); diff --git a/frontend/src/views/projects/RepoAnalyzerView.tsx b/frontend/src/views/projects/RepoAnalyzerView.tsx index d00651b..e53fcab 100644 --- a/frontend/src/views/projects/RepoAnalyzerView.tsx +++ b/frontend/src/views/projects/RepoAnalyzerView.tsx @@ -149,17 +149,18 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st setControlBusy(true); setControlError(""); try { - const updated = await controlRepoAnalysis(projectId, analysisId, action); + const updated = await controlRepoAnalysis(projectId, analysisId, action, undefined, analyses.find(item => item.id === analysisId)?.relatorio_revisao); if (mounted.current) { setAnalyses((previous) => previous.map((item) => item.id === updated.id ? updated : item)); void refresh(true); } } catch (error) { - if (mounted.current) setControlError(error instanceof Error ? error.message : "Não foi possível controlar a análise. Tente novamente."); + const message = serverMessage(error) ?? "Não foi possível controlar a análise. Tente novamente."; + if (mounted.current) setControlError(message); } finally { if (mounted.current) setControlBusy(false); } - }, [controlBusy, projectId, refresh]); + }, [controlBusy, projectId, refresh, analyses]); const submit = (event: React.FormEvent) => { event.preventDefault(); @@ -408,10 +409,12 @@ function AnalysisDetails({ analysis, now, canRetry, canControl, controlBusy, con {analysis.status === "concluido" && report && (

Execução: {analysis.run_id ?? 'Não informada'}

+ {analysis.indexacao_status === 'aguardando_aprovacao' && Gerado por IA e ainda não revisado. Este relatório não está disponível na busca nem no chat.} + {canControl && analysis.indexacao_status === 'aguardando_aprovacao' && } {analysis.indexacao_status &&

Indexação no acervo: {analysis.indexacao_status}

} {analysis.indexacao_status === 'falha' && Não foi possível indexar o relatório. {analysis.indexacao_erro}} - {canControl && ['falha', 'indexado', 'nao_aplicavel'].includes(analysis.indexacao_status ?? '') && ( - + {canControl && ['falha', 'indexado'].includes(analysis.indexacao_status ?? '') && ( + )}

Relatório técnico

diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 63a0026..4eec3d4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,4 +1,5 @@ -import { defineConfig } from "vite"; +/// +import { defineConfig } from "vitest/config"; import { loadEnv } from "vite"; import react from "@vitejs/plugin-react"; @@ -6,8 +7,13 @@ import react from "@vitejs/plugin-react"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, ".", "VITE_"); return { - plugins: [react()], - server: { + plugins: [react()], + test: { + environment: "jsdom", + environmentOptions: { jsdom: { url: "http://localhost:5173/" } }, + setupFiles: "./src/test-setup.ts", + }, + server: { port: 5173, proxy: { "/api": {