diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fd4e77..0c1ff03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: - run: npm run test:seed working-directory: backend - name: Test archive cascade on PostgreSQL - run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts + run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts src/modules/backlog-search/backlog-search.db.test.ts src/modules/decisions/decisions.db.test.ts src/database/migration-013.db.test.ts working-directory: backend env: ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..38216e6 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,63 @@ +name: E2E (navegador) + +# Execução manual: o serviço de IA não é necessário; os cenários verificam o +# comportamento com ele indisponível. Não bloqueia PRs. +on: + workflow_dispatch: + +jobs: + e2e: + name: E2E com API real, PostgreSQL e Chrome + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: e2e + POSTGRES_PASSWORD: e2e_only + POSTGRES_DB: sinapse_e2e_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U e2e -d sinapse_e2e_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: e2e + POSTGRES_PASSWORD: e2e_only + POSTGRES_DB: sinapse_e2e_test + E2E_API_URL: http://localhost:3001/api/v1 + E2E_APP_URL: http://localhost:5173 + E2E_CHROME_PATH: /usr/bin/google-chrome + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Instalar dependências + run: | + npm ci --prefix backend + npm ci --prefix frontend + npm ci --prefix e2e + - name: Migrar o banco + run: npm run migrate + working-directory: backend + - name: Subir backend e frontend + run: | + export DOCUMENT_STORAGE_DIR="$RUNNER_TEMP/documents" + mkdir -p "$DOCUMENT_STORAGE_DIR" + (cd backend && nohup npx tsx src/index.ts > "$RUNNER_TEMP/backend.log" 2>&1 &) + (cd frontend && nohup npx vite --port 5173 --strictPort > "$RUNNER_TEMP/frontend.log" 2>&1 &) + for i in $(seq 1 30); do + curl -fs http://localhost:3001/health > /dev/null && curl -fs http://localhost:5173 > /dev/null && exit 0 + sleep 2 + done + cat "$RUNNER_TEMP/backend.log" "$RUNNER_TEMP/frontend.log" + exit 1 + - name: Executar E2E + run: npm test + working-directory: e2e + - name: Logs dos serviços + if: failure() + run: cat "$RUNNER_TEMP/backend.log" "$RUNNER_TEMP/frontend.log" diff --git a/backend/package-lock.json b/backend/package-lock.json index c337217..aad4283 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,7 +26,8 @@ "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "tsx": "^4.19.3", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "yaml": "^2.9.1" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -2226,6 +2227,15 @@ "node": ">=20.0.0" } }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/swagger-ui-dist": { "version": "5.33.0", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.0.tgz", @@ -2364,12 +2374,19 @@ } }, "node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/zod": { diff --git a/backend/package.json b/backend/package.json index 0b7683a..1b8c193 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,8 @@ "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "tsx": "^4.19.3", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "yaml": "^2.9.1" }, "overrides": { "qs": "^6.16.0" diff --git a/backend/src/database/migration-012.db.test.ts b/backend/src/database/migration-012.db.test.ts index ba7deec..9cf262e 100644 --- a/backend/src/database/migration-012.db.test.ts +++ b/backend/src/database/migration-012.db.test.ts @@ -1,63 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { readFile, readdir } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { Client } from "pg"; import { validateTarget } from "./seed-lib.js"; +import { applyUntil, loadMigration, migrationFiles, withDatabase } from "./migration-test-utils.js"; -const migrationsDir = resolve(process.cwd(), "../database/migrations"); const DOCUMENT_MIGRATION = "012_document_upload.sql"; -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(); -} - -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, ""); -} - -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]); - } -} - -async function withDatabase(adminUrl: string, run: (client: Client, vectorAvailable: boolean) => Promise): Promise { - const admin = new Client({ connectionString: adminUrl }); - await admin.connect(); - const database = `migration012_${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(); - } -} - test("migrations: a 012 sucede a 011 e o runner usa nomes completos sem colisão", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { const files = await migrationFiles(); assert.equal(new Set(files).size, files.length); diff --git a/backend/src/database/migration-013.db.test.ts b/backend/src/database/migration-013.db.test.ts new file mode 100644 index 0000000..b827975 --- /dev/null +++ b/backend/src/database/migration-013.db.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { validateTarget } from "./seed-lib.js"; +import { applyUntil, loadMigration, migrationFiles, withDatabase } from "./migration-test-utils.js"; + +const DECISION_MIGRATION = "013_decision_records.sql"; + +test("migration 013 vem depois da 012 e é a única com esse número", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + const files = await migrationFiles(); + assert.ok(files.indexOf(DECISION_MIGRATION) > files.indexOf("012_document_upload.sql")); + assert.equal(files.filter((file) => file.startsWith("013_")).length, 1); +}); + +test("migration 013 preserva decisões antigas, preenche o texto da decisão e é idempotente", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + await withDatabase(validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test"), async (client, vectorAvailable) => { + await applyUntil(client, vectorAvailable, (file) => file === DECISION_MIGRATION); + const legacy = randomUUID(); + const legacyEntity = randomUUID(); + await client.query("INSERT INTO decisao (id,entidade_tipo,entidade_id,titulo,contexto,justificativa,alternativas) VALUES ($1,'pbi',$2,'Título legado','Contexto legado','Motivo legado','Alt legada')", [legacy, legacyEntity]); + await client.query("INSERT INTO decisao (entidade_tipo,entidade_id,titulo,contexto,justificativa) VALUES ('documento',$1,'Tipo antigo','c','j')", [randomUUID()]); + + await applyUntil(client, vectorAvailable, () => false); + const migrated = (await client.query("SELECT titulo, decisao, contexto, justificativa, alternativas FROM decisao WHERE id=$1", [legacy])).rows[0]; + assert.deepEqual(migrated, { titulo: "Título legado", decisao: "Título legado", contexto: "Contexto legado", justificativa: "Motivo legado", alternativas: "Alt legada" }); + assert.equal((await client.query("SELECT count(*)::int AS n FROM decisao")).rows[0].n, 2, "linhas antigas com tipo desconhecido não são removidas"); + + await assert.rejects(client.query("INSERT INTO decisao (entidade_tipo,entidade_id,titulo,contexto,decisao,justificativa) VALUES ('documento',$1,'x','c','d','j')", [randomUUID()]), /ck_decisao_entidade_tipo/); + await assert.rejects(client.query("INSERT INTO decisao (entidade_tipo,entidade_id,titulo,contexto,justificativa) VALUES ('pbi',$1,'x','c','j')", [randomUUID()]), /null value/); + + const migration = await loadMigration(DECISION_MIGRATION, vectorAvailable); + await client.query(migration); + await client.query(migration); + assert.equal((await client.query("SELECT count(*)::int AS n FROM decisao")).rows[0].n, 2); + const indexes = (await client.query("SELECT indexname FROM pg_indexes WHERE tablename='decisao'")).rows.map((row) => row.indexname); + assert.ok(indexes.includes("idx_decisao_entidade_data")); + }); +}); 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/index.ts b/backend/src/index.ts index b1fb973..2bcfaa1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,8 @@ import { criteriaRouter } from "./modules/criteria/criteria.routes.js"; import qualityRouter from "./modules/quality/quality.routes.js"; import { epicsCompatRouter } from "./modules/epics/epics.compat.routes.js"; import { repoAnalysesRouter } from './modules/repo-analyses/repo-analyses.routes'; +import { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js"; +import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.routes.js"; import { documentsRouter } from "./modules/documents/documents.routes.js"; import { documentsService, startDocumentsBackgroundWorker } from "./modules/documents/documents.service.js"; import { authRouter } from "./modules/auth/auth.routes.js"; @@ -80,6 +82,15 @@ app.use("/api/v1/admin", adminRouter); // Repo analyzer app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); +// Decisões em qualquer nível da hierarquia (S1-18) +app.use("/api/v1/projects/:entityId/decisions", projectDecisionsRouter); +app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter); +app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter); +app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter); + +// Busca textual no backlog (S1-17) +app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); + // Documentos do projeto (S1-19/S1-20/S1-22) app.use("/api/v1/projects/:projectId/documents", requireAuth, documentsRouter); diff --git a/backend/src/modules/backlog-search/backlog-search.db.test.ts b/backend/src/modules/backlog-search/backlog-search.db.test.ts new file mode 100644 index 0000000..8b1deb5 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.db.test.ts @@ -0,0 +1,77 @@ +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 { ProjectsRepository } from "../projects/projects.repository.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { BacklogSearchService } from "./backlog-search.service.js"; + +test("S1-17: busca relacional isolada por projeto, com filtros combinados no PostgreSQL", { skip: !process.env.BACKLOG_TREE_TEST_DATABASE_URL }, async () => { + const pool = new Pool({ connectionString: validateTarget(process.env.BACKLOG_TREE_TEST_DATABASE_URL, "test") }); + const service = new BacklogSearchService(new BacklogSearchRepository(pool), new ProjectsRepository(pool)); + const [projectA, projectB, epicA, featureA, pbiTitle, pbiDesc, pbiDone, epicB, featureB, pbiB, techJava, techNode] = + Array.from({ length: 12 }, () => randomUUID()); + const tag = randomUUID().slice(0, 8); + try { + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'T','ativo'),($2::uuid,$2::text,'T','ativo')", [projectA, projectB]); + await pool.query("INSERT INTO epico (id,projeto_id,titulo,descricao,status) VALUES ($1,$2,$3,'Objetivo de navegação e busca','rascunho'),($4,$5,$6,'Descrição da autenticação do outro projeto','rascunho')", + [epicA, projectA, `Épico Autenticação ${tag}`, epicB, projectB, `Épico Autenticação ${tag}`]); + await pool.query("INSERT INTO feature (id,epico_id,titulo,descricao,status) VALUES ($1,$2,$3,'Sessão segura com expiração','rascunho'),($4,$5,$6,'igual','rascunho')", + [featureA, epicA, `Feature Sessão ${tag}`, featureB, epicB, `Feature Sessão ${tag}`]); + await pool.query(`INSERT INTO pbi (id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,regras_observacoes,status) VALUES + ($1,$2,'PBI-T.1',$3,'PO','entrar','acessar','','rascunho'), + ($4,$2,'PBI-T.2',$5,'PO','recuperar a senha por e-mail','continuar','Regra: 100% dos links expiram','arquivado'), + ($6,$2,'PBI-T.3',$7,'PO','sair','encerrar','','concluido'), + ($8,$9,'PBI-T.9',$3,'PO','entrar','acessar','','rascunho')`, + [pbiTitle, featureA, `Validar Autenticação ${tag}`, pbiDesc, `Enviar link ${tag}`, pbiDone, `Encerrar sessão ${tag}`, pbiB, featureB]); + await pool.query("INSERT INTO tecnologia (id,nome) VALUES ($1,$2),($3,$4)", [techJava, `Java-${tag}`, techNode, `Node-${tag}`]); + await pool.query("INSERT INTO entidade_tecnologia (id,entidade_tipo,entidade_id,tecnologia_id) VALUES ($1,'pbi',$2,$3),($4,'pbi',$5,$6),($7,'epico',$8,$3)", + [randomUUID(), pbiTitle, techJava, randomUUID(), pbiDone, techNode, randomUUID(), epicA]); + + const byTitle = await service.search({ projetoId: projectA, q: `autenticacao ${tag}` }); + assert.equal(byTitle.total, 2, "título sem acento encontra épico e PBI do projeto A"); + assert.ok(byTitle.items.every((item) => item.trecho.destaques.length > 0)); + assert.ok(!byTitle.items.some((item) => item.id === pbiB || item.id === epicB), "nenhum item do projeto B"); + const pbiHit = byTitle.items.find((item) => item.id === pbiTitle); + assert.equal(pbiHit?.campo, "titulo"); + assert.deepEqual(pbiHit?.caminho.map((node) => node.titulo), [`Épico Autenticação ${tag}`, `Feature Sessão ${tag}`, `Validar Autenticação ${tag}`]); + assert.equal(pbiHit?.caminho[2].codigo, "PBI-T.1"); + + const byDescription = await service.search({ projetoId: projectA, q: "recuperar senha" }); + assert.deepEqual(byDescription.items.map((item) => item.id), [pbiDesc]); + assert.equal(byDescription.items[0].campo, "descricao"); + const [from, to] = byDescription.items[0].trecho.destaques[0]; + assert.equal(byDescription.items[0].trecho.texto.slice(from, to).toLowerCase(), "recuperar"); + + const byEpicDescription = await service.search({ projetoId: projectA, q: "NAVEGAÇÃO" }); + assert.deepEqual(byEpicDescription.items.map((item) => item.id), [epicA]); + assert.deepEqual(byEpicDescription.items[0].caminho.map((node) => node.tipo), ["epico"]); + + const byCode = await service.search({ projetoId: projectA, q: "pbi-t.3" }); + assert.deepEqual(byCode.items.map((item) => item.id), [pbiDone]); + + const combined = await service.search({ projetoId: projectA, q: `sessão ${tag}`, status: "concluido", tecnologiaId: techNode }); + assert.deepEqual(combined.items.map((item) => item.id), [pbiDone]); + assert.deepEqual((await service.search({ projetoId: projectA, q: `sessão ${tag}`, status: "concluido", tecnologiaId: techJava })).items, []); + assert.deepEqual((await service.search({ projetoId: projectA, q: `autenticação ${tag}`, tecnologiaId: techJava })).items.map((item) => item.id).sort(), [epicA, pbiTitle].sort()); + assert.deepEqual((await service.search({ projetoId: projectA, q: `link ${tag}`, status: "arquivado" })).items.map((item) => item.id), [pbiDesc]); + assert.deepEqual((await service.search({ projetoId: projectA, q: `link ${tag}`, status: "rascunho" })).items, []); + + assert.equal((await service.search({ projetoId: projectA, q: "100%" })).items.length, 1, "% é literal"); + assert.equal((await service.search({ projetoId: projectA, q: "e_mail" })).items.length, 0, "_ é literal"); + assert.equal((await service.search({ projetoId: projectA, q: "termo-que-nao-existe-xyz" })).total, 0); + + const limited = await service.search({ projetoId: projectA, q: tag, limit: "2" }); + assert.equal(limited.items.length, 2); + assert.ok(limited.total > 2); + + const other = await service.search({ projetoId: projectB, q: `autenticação ${tag}` }); + assert.deepEqual(other.items.map((item) => item.id).sort(), [epicB, pbiB].sort()); + } finally { + await pool.query("DELETE FROM entidade_tecnologia WHERE tecnologia_id = ANY($1::uuid[]) OR entidade_id = ANY($2::uuid[])", [[techJava, techNode], [epicA]]); + await pool.query("DELETE FROM tecnologia WHERE id = ANY($1::uuid[])", [[techJava, techNode]]); + await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[projectA, projectB]]); + await pool.end(); + } +}); diff --git a/backend/src/modules/backlog-search/backlog-search.repository.ts b/backend/src/modules/backlog-search/backlog-search.repository.ts new file mode 100644 index 0000000..392af12 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.repository.ts @@ -0,0 +1,59 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import type { BacklogSearchFilters, BacklogSearchRow } from "./backlog-search.types.js"; + +const ACCENTED = "ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇÑáàâãäéèêëíìîïóòôõöúùûüçñ"; +const PLAIN = "AAAAAEEEEIIIIOOOOOUUUUCNaaaaaeeeeiiiiooooouuuucn"; + +const norm = (expression: string) => `lower(translate(${expression}, '${ACCENTED}', '${PLAIN}'))`; + +export class BacklogSearchRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async search(projetoId: string, patterns: string[], filters: BacklogSearchFilters, limit: number): Promise { + const result = await this.pool.query( + `WITH items AS ( + SELECT 'epico'::text AS tipo, e.id, e.titulo, + concat_ws(E'\\n', e.descricao, e.objetivo, e.escopo_macro, e.resultado_esperado) AS descricao, + e.status, NULL::text AS codigo, e.id AS epico_id, e.titulo AS epico_titulo, + NULL::uuid AS feature_id, NULL::text AS feature_titulo, e.created_at, 1 AS nivel + FROM epico e WHERE e.projeto_id = $1 + UNION ALL + SELECT 'feature', f.id, f.titulo, concat_ws(E'\\n', f.descricao, f.objetivo), + f.status, NULL, e.id, e.titulo, f.id, f.titulo, f.created_at, 2 + FROM feature f JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 + UNION ALL + SELECT 'pbi', p.id, p.titulo, + concat_ws(E'\\n', p.historia_como_um, p.historia_eu_quero, p.historia_para_que, p.regras_observacoes), + p.status, p.codigo, e.id, e.titulo, f.id, f.titulo, p.created_at, 3 + FROM pbi p JOIN feature f ON f.id = p.feature_id JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 + ), matched AS ( + SELECT i.*, + ${norm("concat_ws(' ', i.titulo, i.codigo)")} LIKE ALL ($2::text[]) AS titulo_match + FROM items i + WHERE ${norm("concat_ws(' ', i.titulo, i.codigo, i.descricao)")} LIKE ALL ($2::text[]) + AND ($3::text IS NULL OR i.status = $3) + AND ($4::uuid IS NULL OR EXISTS ( + SELECT 1 FROM entidade_tecnologia et + WHERE et.entidade_id = i.id AND et.entidade_tipo = i.tipo AND et.tecnologia_id = $4)) + ) + SELECT m.tipo, m.id, m.titulo, m.descricao, m.status, m.codigo, m.epico_id, m.epico_titulo, + m.feature_id, m.feature_titulo, m.titulo_match, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', t.id, 'nome', t.nome) ORDER BY t.nome) + FROM entidade_tecnologia et JOIN tecnologia t ON t.id = et.tecnologia_id + WHERE et.entidade_id = m.id AND et.entidade_tipo = m.tipo + ), '[]'::jsonb) AS tecnologias, + COUNT(*) OVER () AS total + FROM matched m + ORDER BY m.titulo_match DESC, m.nivel, m.created_at, m.id + LIMIT $5`, + [projetoId, patterns, filters.status ?? null, filters.tecnologiaId ?? null, limit], + ); + return result.rows; + } +} diff --git a/backend/src/modules/backlog-search/backlog-search.routes.test.ts b/backend/src/modules/backlog-search/backlog-search.routes.test.ts new file mode 100644 index 0000000..58ea4c9 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.routes.test.ts @@ -0,0 +1,65 @@ +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.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { createBacklogSearchRouter } from "./backlog-search.routes.js"; +import { BacklogSearchService } from "./backlog-search.service.js"; + +const PROJECT = "a0000000-0000-4000-8000-000000000001"; + +class FakeRepository extends BacklogSearchRepository { + public last: { projetoId: string; patterns: string[] } | null = null; + constructor() { super(); } + async search(projetoId: string, patterns: string[]) { + this.last = { projetoId, patterns }; + return []; + } +} + +const repository = new FakeRepository(); +let server: Server; +let baseUrl: string; + +before(() => { + const app = express(); + const service = new BacklogSearchService(repository, { async findById(id: string) { return id === PROJECT ? { id, status: "ativo" } : null; } }); + app.use("/api/v1/projects/:projectId/backlog-search", createBacklogSearchRouter(service, (req, res, next) => { + if (req.headers["x-test-user"] !== "ana") { + res.status(401).json({ error: "Autenticação necessária.", code: "UNAUTHORIZED" }); + return; + } + 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 get = (path: string, auth = true) => fetch(`${baseUrl}${path}`, { headers: auth ? { "x-test-user": "ana" } : {} }); + +test("exige autenticação", async () => { + assert.equal((await get(`/${PROJECT}/backlog-search?q=login`, false)).status, 401); +}); + +test("busca no projeto da rota e devolve o contrato", async () => { + const response = await get(`/${PROJECT}/backlog-search?q=Autentica%C3%A7%C3%A3o`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { projeto_id: PROJECT, termo: "Autenticação", total: 0, limite: 50, items: [] }); + assert.deepEqual(repository.last, { projetoId: PROJECT, patterns: ["%autenticacao%"] }); +}); + +test("valida consulta, filtros e projeto", async () => { + assert.equal((await get(`/${PROJECT}/backlog-search`)).status, 400); + assert.equal((await get(`/${PROJECT}/backlog-search?q=a`)).status, 400); + assert.equal((await get(`/${PROJECT}/backlog-search?q=ab&status=xx`)).status, 400); + assert.equal((await get(`/${PROJECT}/backlog-search?q=ab&tecnologia=xx`)).status, 400); + assert.equal((await get(`/nao-uuid/backlog-search?q=ab`)).status, 400); + assert.equal((await get(`/a0000000-0000-4000-8000-0000000000ff/backlog-search?q=ab`)).status, 404); +}); diff --git a/backend/src/modules/backlog-search/backlog-search.routes.ts b/backend/src/modules/backlog-search/backlog-search.routes.ts new file mode 100644 index 0000000..9bfd878 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.routes.ts @@ -0,0 +1,49 @@ +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; +import { requireAuth } from "../../middleware/requireAuth.js"; +import { BacklogSearchService, backlogSearchService } from "./backlog-search.service.js"; + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function createBacklogSearchRouter( + service: BacklogSearchService = backlogSearchService, + authentication: RequestHandler = requireAuth, +): Router { + const router = Router({ mergeParams: true }); + router.use(authentication); + + /** + * @swagger + * /api/v1/projects/{projectId}/backlog-search: + * get: + * summary: Busca textual relacional no backlog do projeto (S1-17) + * tags: [Backlog] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Itens do projeto com trecho, destaques e caminho hierárquico + * 400: + * description: Consulta, status, tecnologia ou limite inválidos + * 404: + * description: Projeto não encontrado + */ + router.get("/", async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.search({ + projetoId: String(req.params.projectId), + q: text(req.query.q), + status: text(req.query.status), + tecnologiaId: text(req.query.tecnologia), + limit: text(req.query.limit), + })); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const backlogSearchRouter = createBacklogSearchRouter(); diff --git a/backend/src/modules/backlog-search/backlog-search.service.test.ts b/backend/src/modules/backlog-search/backlog-search.service.test.ts new file mode 100644 index 0000000..042aba2 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.service.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NotFoundError, ValidationError } from "../../shared/errors.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { BacklogSearchService } from "./backlog-search.service.js"; +import type { BacklogSearchFilters, BacklogSearchRow } from "./backlog-search.types.js"; + +const PROJECT = "a0000000-0000-4000-8000-000000000001"; +const TECH = "f0000000-0000-4000-8000-000000000001"; + +class FakeRepository extends BacklogSearchRepository { + public calls: Array<{ projetoId: string; patterns: string[]; filters: BacklogSearchFilters; limit: number }> = []; + public rows: BacklogSearchRow[] = []; + + constructor() { + super(); + } + + async search(projetoId: string, patterns: string[], filters: BacklogSearchFilters, limit: number): Promise { + this.calls.push({ projetoId, patterns, filters, limit }); + return this.rows; + } +} + +const projects = { async findById(id: string) { return id === PROJECT ? { id, status: "ativo" } : null; } }; + +function row(overrides: Partial): BacklogSearchRow { + return { + tipo: "pbi", id: "p1", titulo: "Filtrar backlog por status", descricao: "Como um PO\nEu quero filtrar itens\nPara localizar rápido", + status: "rascunho", codigo: "PBI-01.4.1", epico_id: "e1", epico_titulo: "Navegação", feature_id: "f1", feature_titulo: "Filtros", + tecnologias: [], titulo_match: true, total: "1", ...overrides, + }; +} + +function setup() { + const repository = new FakeRepository(); + return { repository, service: new BacklogSearchService(repository, projects) }; +} + +test("monta tipo, trecho, destaques e caminho Projeto → Épico → Feature → PBI", async () => { + const { service, repository } = setup(); + repository.rows = [row({})]; + const result = await service.search({ projetoId: PROJECT, q: " FILTRAR Backlog " }); + + assert.equal(result.termo, "FILTRAR Backlog"); + assert.equal(result.total, 1); + const [item] = result.items; + assert.equal(item.tipo, "pbi"); + assert.equal(item.campo, "titulo"); + assert.equal(item.trecho.texto, "Filtrar backlog por status"); + assert.deepEqual(item.trecho.destaques, [[0, 7], [8, 15]]); + assert.deepEqual(item.caminho.map((node) => [node.tipo, node.titulo]), [["epico", "Navegação"], ["feature", "Filtros"], ["pbi", "Filtrar backlog por status"]]); + assert.equal(item.caminho[2].codigo, "PBI-01.4.1"); + assert.deepEqual(repository.calls[0].patterns, ["%filtrar%", "%backlog%"]); +}); + +test("quando só a descrição casa, o trecho vem da descrição e o campo é descricao", async () => { + const { service, repository } = setup(); + repository.rows = [row({ titulo_match: false })]; + const [item] = (await service.search({ projetoId: PROJECT, q: "localizar" })).items; + assert.equal(item.campo, "descricao"); + assert.match(item.trecho.texto, /localizar rápido/); + assert.equal(item.trecho.texto.slice(item.trecho.destaques[0][0], item.trecho.destaques[0][1]), "localizar"); +}); + +test("épico e feature têm caminho com o nível aplicável", async () => { + const { service, repository } = setup(); + repository.rows = [ + row({ tipo: "epico", id: "e1", titulo: "Navegação", codigo: null, feature_id: null, feature_titulo: null, total: "2" }), + row({ tipo: "feature", id: "f1", titulo: "Filtros", codigo: null, total: "2" }), + ]; + const result = await service.search({ projetoId: PROJECT, q: "na" }); + assert.deepEqual(result.items[0].caminho.map((node) => node.tipo), ["epico"]); + assert.deepEqual(result.items[1].caminho.map((node) => node.tipo), ["epico", "feature"]); + assert.equal(result.total, 2); +}); + +test("repassa status, tecnologia e limite validados ao repositório", async () => { + const { service, repository } = setup(); + await service.search({ projetoId: PROJECT, q: "login", status: "concluido", tecnologiaId: TECH, limit: "10" }); + assert.deepEqual(repository.calls[0].filters, { status: "concluido", tecnologiaId: TECH }); + assert.equal(repository.calls[0].limit, 10); + assert.equal(repository.calls[0].projetoId, PROJECT); +}); + +test("rejeita consulta curta/longa, status, tecnologia e limite inválidos", async () => { + const { service, repository } = setup(); + await assert.rejects(service.search({ projetoId: PROJECT, q: "a" }), /ao menos 2/); + await assert.rejects(service.search({ projetoId: PROJECT }), ValidationError); + await assert.rejects(service.search({ projetoId: PROJECT, q: "x".repeat(101) }), /no máximo 100/); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", status: "inventado" }), /Status inválido/); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", tecnologiaId: "nao-uuid" }), ValidationError); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", limit: "0" }), ValidationError); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", limit: "101" }), ValidationError); + await assert.rejects(service.search({ projetoId: "nao-uuid", q: "ab" }), ValidationError); + assert.equal(repository.calls.length, 0); +}); + +test("projeto inexistente retorna 404 antes de consultar o banco de itens", async () => { + const { service, repository } = setup(); + await assert.rejects(service.search({ projetoId: "a0000000-0000-4000-8000-0000000000ff", q: "login" }), NotFoundError); + assert.equal(repository.calls.length, 0); +}); + +test("sem resultados devolve total zero e lista vazia", async () => { + const { service } = setup(); + assert.deepEqual(await service.search({ projetoId: PROJECT, q: "inexistente" }), { projeto_id: PROJECT, termo: "inexistente", total: 0, limite: 50, items: [] }); +}); diff --git a/backend/src/modules/backlog-search/backlog-search.service.ts b/backend/src/modules/backlog-search/backlog-search.service.ts new file mode 100644 index 0000000..3efb428 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.service.ts @@ -0,0 +1,96 @@ +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { ProjectsRepository } from "../projects/projects.repository.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { likePatterns, makeSnippet, parseTerms } from "./backlog-search.text.js"; +import { + BACKLOG_SEARCH_STATUSES, + DEFAULT_LIMIT, + MAX_LIMIT, + MAX_QUERY_LENGTH, + MIN_QUERY_LENGTH, + type BacklogSearchItem, + type BacklogSearchResponse, + type BacklogSearchRow, + type SearchPathNode, +} from "./backlog-search.types.js"; + +export interface ProjectLookup { + findById(id: string): Promise<{ id: string; status: string } | null>; +} + +export interface BacklogSearchInput { + projetoId: string; + q?: string; + status?: string; + tecnologiaId?: string; + limit?: string; +} + +function buildPath(row: BacklogSearchRow): SearchPathNode[] { + const path: SearchPathNode[] = [{ tipo: "epico", id: row.epico_id, titulo: row.epico_titulo, codigo: null }]; + if (row.feature_id && row.feature_titulo !== null) { + path.push({ tipo: "feature", id: row.feature_id, titulo: row.feature_titulo, codigo: null }); + } + if (row.tipo === "pbi") path.push({ tipo: "pbi", id: row.id, titulo: row.titulo, codigo: row.codigo }); + return path; +} + +export class BacklogSearchService { + constructor( + private readonly repository: BacklogSearchRepository = new BacklogSearchRepository(), + private readonly projects: ProjectLookup = new ProjectsRepository(), + ) {} + + async search(input: BacklogSearchInput): Promise { + validateUuid(input.projetoId, "ID do projeto"); + const query = (input.q ?? "").replace(/\s+/g, " ").trim(); + if (query.length < MIN_QUERY_LENGTH) throw new ValidationError(`Digite ao menos ${MIN_QUERY_LENGTH} caracteres para buscar.`); + if (query.length > MAX_QUERY_LENGTH) throw new ValidationError(`A busca pode ter no máximo ${MAX_QUERY_LENGTH} caracteres.`); + const terms = parseTerms(query); + if (terms.length === 0) throw new ValidationError(`Digite ao menos ${MIN_QUERY_LENGTH} caracteres para buscar.`); + + if (input.status && !(BACKLOG_SEARCH_STATUSES as readonly string[]).includes(input.status)) { + throw new ValidationError("Status inválido para o filtro da busca."); + } + if (input.tecnologiaId) validateUuid(input.tecnologiaId, "ID da tecnologia"); + const limit = input.limit === undefined ? DEFAULT_LIMIT : Number(input.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new ValidationError(`O limite deve ser um inteiro entre 1 e ${MAX_LIMIT}.`); + } + + if (!(await this.projects.findById(input.projetoId))) throw new NotFoundError("Projeto não encontrado."); + + const rows = await this.repository.search( + input.projetoId, + likePatterns(terms), + { status: input.status || undefined, tecnologiaId: input.tecnologiaId || undefined }, + limit, + ); + + const items: BacklogSearchItem[] = rows.map((row) => { + const campo = row.titulo_match ? "titulo" : "descricao"; + const source = row.titulo_match ? row.titulo : row.descricao ?? ""; + return { + tipo: row.tipo, + id: row.id, + titulo: row.titulo, + codigo: row.codigo, + status: row.status, + campo, + trecho: makeSnippet(source, terms, campo === "titulo"), + caminho: buildPath(row), + tecnologias: row.tecnologias, + }; + }); + + return { + projeto_id: input.projetoId, + termo: query, + total: rows.length > 0 ? Number(rows[0].total) : 0, + limite: limit, + items, + }; + } +} + +export const backlogSearchService = new BacklogSearchService(); diff --git a/backend/src/modules/backlog-search/backlog-search.text.test.ts b/backend/src/modules/backlog-search/backlog-search.text.test.ts new file mode 100644 index 0000000..711d057 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.text.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { escapeLike, fold, likePatterns, makeSnippet, parseTerms } from "./backlog-search.text.js"; + +test("fold ignora acentos e maiúsculas sem alterar o tamanho do texto", () => { + const text = "AÇÃO Rápida do Usuário Ñandú"; + assert.equal(fold(text), "acao rapida do usuario nandu"); + assert.equal(fold(text).length, text.length); +}); + +test("parseTerms separa palavras, remove duplicadas e termos de 1 caractere, limitando a 6", () => { + assert.deepEqual(parseTerms(" Autenticação LOGIN login "), ["autenticacao", "login"]); + assert.deepEqual(parseTerms("a b c"), []); + assert.deepEqual(parseTerms("x login"), ["login"]); + assert.equal(parseTerms("aa bb cc dd ee ff gg hh").length, 6); +}); + +test("curingas do usuário são tratados como texto literal", () => { + assert.equal(escapeLike("100%_ok\\"), "100\\%\\_ok\\\\"); + assert.deepEqual(likePatterns(["50%"]), ["%50\\%%"]); +}); + +test("trecho de título devolve o texto inteiro com todos os destaques", () => { + const snippet = makeSnippet("Cadastrar usuário no sistema", ["usuario", "sist"], true); + assert.equal(snippet.texto, "Cadastrar usuário no sistema"); + assert.deepEqual(snippet.destaques, [[10, 17], [21, 25]]); + assert.equal(snippet.texto.slice(10, 17), "usuário"); +}); + +test("trecho de descrição curta não recebe reticências e junta quebras de linha", () => { + const snippet = makeSnippet("Como um PO\nEu quero filtrar\nPara localizar", ["filtrar"], false); + assert.equal(snippet.texto, "Como um PO Eu quero filtrar Para localizar"); + assert.equal(snippet.texto.slice(snippet.destaques[0][0], snippet.destaques[0][1]), "filtrar"); +}); + +test("trecho de descrição longa centraliza o termo, corta em palavras e marca reticências", () => { + const long = `${"palavra ".repeat(40)}o termo-alvo aparece aqui ${"depois ".repeat(40)}`; + const snippet = makeSnippet(long, ["termo-alvo"], false); + assert.ok(snippet.texto.startsWith("…")); + assert.ok(snippet.texto.endsWith("…")); + assert.ok(snippet.texto.length < 200); + const [from, to] = snippet.destaques[0]; + assert.equal(snippet.texto.slice(from, to), "termo-alvo"); +}); + +test("destaques sobrepostos são unidos e ocorrências repetidas são todas marcadas", () => { + const snippet = makeSnippet("login logins", ["login", "logi"], true); + assert.deepEqual(snippet.destaques, [[0, 5], [6, 11]]); +}); diff --git a/backend/src/modules/backlog-search/backlog-search.text.ts b/backend/src/modules/backlog-search/backlog-search.text.ts new file mode 100644 index 0000000..18a917e --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.text.ts @@ -0,0 +1,92 @@ +import type { SearchSnippet } from "./backlog-search.types.js"; + +const MARKS = /[̀-ͯ]/g; +const SNIPPET_RADIUS = 70; + +export function foldChar(character: string): string { + const folded = character.normalize("NFD").replace(MARKS, "").toLowerCase(); + return folded.length === 1 ? folded : character.toLowerCase().slice(0, 1) || character; +} + +export function fold(text: string): string { + let result = ""; + for (let index = 0; index < text.length; index += 1) result += foldChar(text[index]); + return result; +} + +export function parseTerms(query: string): string[] { + const words = fold(query.normalize("NFC")) + .split(/\s+/) + .map((word) => word.trim()) + .filter(Boolean); + const unique = [...new Set(words)]; + const meaningful = unique.filter((word) => word.length >= 2); + return (meaningful.length > 0 ? meaningful : []).slice(0, 6); +} + +export function escapeLike(term: string): string { + return term.replace(/[\\%_]/g, "\\$&"); +} + +export function likePatterns(terms: string[]): string[] { + return terms.map((term) => `%${escapeLike(term)}%`); +} + +function collapse(text: string): string { + let result = ""; + for (let index = 0; index < text.length; index += 1) result += /\s/.test(text[index]) ? " " : text[index]; + return result; +} + +function highlights(folded: string, terms: string[], offset: number, length: number): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const term of terms) { + let from = offset; + while (from < offset + length) { + const index = folded.indexOf(term, from); + if (index < 0 || index >= offset + length) break; + ranges.push([index - offset, Math.min(index + term.length, offset + length) - offset]); + from = index + Math.max(term.length, 1); + } + } + ranges.sort((left, right) => left[0] - right[0] || left[1] - right[1]); + const merged: Array<[number, number]> = []; + for (const range of ranges) { + const last = merged[merged.length - 1]; + if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]); + else merged.push([...range]); + } + return merged; +} + +export function makeSnippet(source: string, terms: string[], wholeText: boolean): SearchSnippet { + const text = collapse(source).trim(); + const folded = fold(text); + + if (wholeText || text.length <= SNIPPET_RADIUS * 2) { + return { texto: text, destaques: highlights(folded, terms, 0, text.length) }; + } + + const first = terms + .map((term) => folded.indexOf(term)) + .filter((index) => index >= 0) + .sort((left, right) => left - right)[0] ?? 0; + + let start = Math.max(0, first - SNIPPET_RADIUS); + let end = Math.min(text.length, first + SNIPPET_RADIUS); + if (start > 0) { + const space = text.indexOf(" ", start); + if (space >= 0 && space < first) start = space + 1; + } + if (end < text.length) { + const space = text.lastIndexOf(" ", end); + if (space > first) end = space; + } + + const prefix = start > 0 ? "…" : ""; + const suffix = end < text.length ? "…" : ""; + return { + texto: `${prefix}${text.slice(start, end)}${suffix}`, + destaques: highlights(folded, terms, start, end - start).map(([from, to]) => [from + prefix.length, to + prefix.length] as [number, number]), + }; +} diff --git a/backend/src/modules/backlog-search/backlog-search.types.ts b/backend/src/modules/backlog-search/backlog-search.types.ts new file mode 100644 index 0000000..a2caa90 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.types.ts @@ -0,0 +1,66 @@ +export type BacklogItemType = "epico" | "feature" | "pbi"; + +export const BACKLOG_SEARCH_STATUSES = ["rascunho", "ativo", "pronto", "concluido", "arquivado"] as const; + +export interface SearchPathNode { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; +} + +export interface SearchSnippet { + texto: string; + destaques: Array<[number, number]>; +} + +export interface SearchTechnology { + id: string; + nome: string; +} + +export interface BacklogSearchItem { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; + status: string; + campo: "titulo" | "descricao"; + trecho: SearchSnippet; + caminho: SearchPathNode[]; + tecnologias: SearchTechnology[]; +} + +export interface BacklogSearchResponse { + projeto_id: string; + termo: string; + total: number; + limite: number; + items: BacklogSearchItem[]; +} + +export interface BacklogSearchRow { + tipo: BacklogItemType; + id: string; + titulo: string; + descricao: string | null; + status: string; + codigo: string | null; + epico_id: string; + epico_titulo: string; + feature_id: string | null; + feature_titulo: string | null; + tecnologias: SearchTechnology[]; + titulo_match: boolean; + total: string; +} + +export interface BacklogSearchFilters { + status?: string; + tecnologiaId?: string; +} + +export const MIN_QUERY_LENGTH = 2; +export const MAX_QUERY_LENGTH = 100; +export const DEFAULT_LIMIT = 50; +export const MAX_LIMIT = 100; diff --git a/backend/src/modules/decisions/decisions.db.test.ts b/backend/src/modules/decisions/decisions.db.test.ts new file mode 100644 index 0000000..b9cfc0f --- /dev/null +++ b/backend/src/modules/decisions/decisions.db.test.ts @@ -0,0 +1,90 @@ +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 { DecisionsRepository } from "./decisions.repository.js"; +import { DecisionsService } from "./decisions.service.js"; + +test("S1-18: decisões em qualquer nível, herança, isolamento e arquivamento no PostgreSQL", { skip: !process.env.BACKLOG_TREE_TEST_DATABASE_URL }, async (t) => { + const pool = new Pool({ connectionString: validateTarget(process.env.BACKLOG_TREE_TEST_DATABASE_URL, "test") }); + const service = new DecisionsService(new DecisionsRepository(pool)); + const [user, projectA, projectB, epicA, featureA, pbiA, epicB, featureB, pbiB, archivedProject] = Array.from({ length: 10 }, () => randomUUID()); + const body = (titulo: string, extra: Record = {}) => ({ titulo, contexto: "Contexto", decisao: "Decisão", justificativa: "Motivo", ...extra }); + try { + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Ana PO',$2,'h','po')", [user, `${user}@decisoes.test`]); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'T','ativo'),($2::uuid,$2::text,'T','ativo'),($3::uuid,$3::text,'T','ativo')", [projectA, projectB, archivedProject]); + await pool.query("INSERT INTO epico (id,projeto_id,titulo) VALUES ($1,$2,'Épico A'),($3,$4,'Épico B')", [epicA, projectA, epicB, projectB]); + await pool.query("INSERT INTO feature (id,epico_id,titulo) VALUES ($1,$2,'Feature A'),($3,$4,'Feature B')", [featureA, epicA, featureB, epicB]); + await pool.query(`INSERT INTO pbi (id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES + ($1,$2,'PBI-D.1','PBI A','PO','x','y'),($3,$4,'PBI-D.2','PBI B','PO','x','y')`, [pbiA, featureA, pbiB, featureB]); + + await t.test("registra em todos os níveis com autor, data e auditoria", async () => { + const levels = [["projeto", projectA], ["epico", epicA], ["feature", featureA], ["pbi", pbiA]] as const; + for (const [tipo, id] of levels) { + const created = await service.create(tipo, id, user, body(`Decisão ${tipo}`, tipo === "pbi" ? { alternativas: "Alternativa descartada" } : {})); + assert.equal(created.autor?.nome, "Ana PO"); + assert.ok(Date.parse(created.created_at)); + assert.equal(created.origem.tipo, tipo); + } + const audits = await pool.query("SELECT entidade_tipo FROM auditoria WHERE acao='REGISTRAR_DECISAO' AND usuario_id=$1 ORDER BY entidade_tipo", [user]); + assert.deepEqual(audits.rows.map((row) => row.entidade_tipo), ["epico", "feature", "pbi", "projeto"]); + }); + + await t.test("o PBI enxerga as decisões dos ascendentes, na ordem cronológica, marcadas como herdadas", async () => { + const result = await service.list("pbi", pbiA); + assert.deepEqual(result.decisoes.map((item) => item.origem.tipo), ["projeto", "epico", "feature", "pbi"]); + assert.deepEqual(result.decisoes.map((item) => item.origem.herdada), [true, true, true, false]); + assert.equal(result.decisoes[3].alternativas, "Alternativa descartada"); + assert.deepEqual(result.ancestrais.map((node) => node.titulo), [projectA, "Épico A", "Feature A"]); + assert.equal(result.entidade.codigo, "PBI-D.1"); + }); + + await t.test("ascendentes não listam decisões de descendentes", async () => { + assert.deepEqual((await service.list("epico", epicA)).decisoes.map((item) => item.origem.tipo), ["projeto", "epico"]); + assert.deepEqual((await service.list("projeto", projectA)).decisoes.map((item) => item.origem.tipo), ["projeto"]); + }); + + await t.test("isolamento: outro projeto não recebe decisões do primeiro", async () => { + await service.create("pbi", pbiB, user, body("Decisão do PBI B")); + assert.deepEqual((await service.list("pbi", pbiB)).decisoes.map((item) => item.titulo), ["Decisão do PBI B"]); + assert.ok(!(await service.list("pbi", pbiA)).decisoes.some((item) => item.titulo === "Decisão do PBI B")); + }); + + await t.test("registrar decisão não exige nem altera o status do item", async () => { + const before = (await pool.query("SELECT status FROM pbi WHERE id=$1", [pbiA])).rows[0].status; + await service.create("pbi", pbiA, user, body("Mais uma")); + assert.equal((await pool.query("SELECT status FROM pbi WHERE id=$1", [pbiA])).rows[0].status, before); + }); + + await t.test("projeto arquivado recusa decisões e corrida com o arquivamento resulta em conflito", async () => { + const archiver = await pool.connect(); + try { + await archiver.query("BEGIN"); + await lockHierarchy(archiver); + await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); + const attempt = service.create("projeto", archivedProject, user, body("Corrida")); + await new Promise((resolve) => setTimeout(resolve, 150)); + await archiver.query("COMMIT"); + await assert.rejects(attempt, ArchiveConflict); + } finally { + archiver.release(); + } + await assert.rejects(service.create("projeto", archivedProject, user, body("Depois")), ArchiveConflict); + assert.equal((await pool.query("SELECT count(*)::int AS n FROM decisao WHERE entidade_id=$1", [archivedProject])).rows[0].n, 0); + assert.deepEqual((await service.list("projeto", archivedProject)).decisoes, []); + }); + + await t.test("restrição rejeita tipo de entidade desconhecido", async () => { + await assert.rejects(pool.query("INSERT INTO decisao (entidade_tipo,entidade_id,titulo,contexto,decisao,justificativa) VALUES ('documento',$1,'x','c','d','j')", [randomUUID()]), /ck_decisao_entidade_tipo/); + }); + } finally { + await pool.query("DELETE FROM decisao WHERE entidade_id = ANY($1::uuid[])", [[projectA, epicA, featureA, pbiA, pbiB, projectB, archivedProject]]); + await pool.query("DELETE FROM auditoria WHERE usuario_id=$1", [user]); + await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[projectA, projectB, archivedProject]]); + await pool.query("DELETE FROM usuario WHERE id=$1", [user]); + await pool.end(); + } +}); diff --git a/backend/src/modules/decisions/decisions.fakes.ts b/backend/src/modules/decisions/decisions.fakes.ts new file mode 100644 index 0000000..4d46363 --- /dev/null +++ b/backend/src/modules/decisions/decisions.fakes.ts @@ -0,0 +1,59 @@ +import { ArchiveConflict } from "../projects/archive.types.js"; +import { DecisionsRepository } from "./decisions.repository.js"; +import type { ChainNode, CreateDecisionInput, DecisionEntityType, DecisionRow } from "./decisions.types.js"; + +export const PROJECT = "a0000000-0000-4000-8000-000000000001"; +export const EPIC = "a0000000-0000-4000-8000-000000000002"; +export const FEATURE = "a0000000-0000-4000-8000-000000000003"; +export const PBI = "a0000000-0000-4000-8000-000000000004"; +export const OTHER_PBI = "a0000000-0000-4000-8000-000000000009"; +export const USER = "b0000000-0000-4000-8000-000000000001"; + +const CHAIN: ChainNode[] = [ + { tipo: "projeto", id: PROJECT, titulo: "Sinapse", codigo: null }, + { tipo: "epico", id: EPIC, titulo: "Especificar backlog", codigo: null }, + { tipo: "feature", id: FEATURE, titulo: "Decisões", codigo: null }, + { tipo: "pbi", id: PBI, titulo: "Registrar decisão", codigo: "PBI-01.5.1" }, +]; + +export class FakeDecisionsRepository extends DecisionsRepository { + public rows: DecisionRow[] = []; + public archived = new Set(); + private sequence = 0; + + constructor() { + super(); + } + + async resolveChain(tipo: DecisionEntityType, id: string): Promise { + const index = CHAIN.findIndex((node) => node.tipo === tipo && node.id === id); + return index < 0 ? null : CHAIN.slice(0, index + 1); + } + + async listForChain(chain: ChainNode[]): Promise { + const keys = new Set(chain.map((node) => `${node.tipo}:${node.id}`)); + return this.rows + .filter((row) => keys.has(`${row.entidade_tipo}:${row.entidade_id}`)) + .sort((left, right) => left.created_at.localeCompare(right.created_at)); + } + + async create(input: CreateDecisionInput): Promise { + if (this.archived.has(input.entidadeId)) throw new ArchiveConflict("Item ou ancestral arquivado está disponível apenas para leitura."); + this.sequence += 1; + const row: DecisionRow = { + id: `d0000000-0000-4000-8000-${String(this.sequence).padStart(12, "0")}`, + entidade_tipo: input.entidadeTipo, + entidade_id: input.entidadeId, + titulo: input.titulo, + contexto: input.contexto, + decisao: input.decisao, + justificativa: input.justificativa, + alternativas: input.alternativas, + autor_id: input.usuarioId, + autor_nome: "Ana PO", + created_at: new Date(Date.UTC(2026, 8, 25, 10, this.sequence)).toISOString(), + }; + this.rows.push(row); + return row; + } +} diff --git a/backend/src/modules/decisions/decisions.repository.ts b/backend/src/modules/decisions/decisions.repository.ts new file mode 100644 index 0000000..140507d --- /dev/null +++ b/backend/src/modules/decisions/decisions.repository.ts @@ -0,0 +1,99 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; +import type { ChainNode, CreateDecisionInput, DecisionEntityType, DecisionRow } from "./decisions.types.js"; + +const CHAIN_QUERIES: Record = { + projeto: `SELECT p.id AS projeto_id, p.nome AS projeto_titulo, NULL::uuid AS epico_id, NULL::text AS epico_titulo, + NULL::uuid AS feature_id, NULL::text AS feature_titulo, NULL::uuid AS pbi_id, NULL::text AS pbi_titulo, NULL::text AS pbi_codigo + FROM projeto p WHERE p.id = $1`, + epico: `SELECT p.id AS projeto_id, p.nome AS projeto_titulo, e.id AS epico_id, e.titulo AS epico_titulo, + NULL::uuid AS feature_id, NULL::text AS feature_titulo, NULL::uuid AS pbi_id, NULL::text AS pbi_titulo, NULL::text AS pbi_codigo + FROM epico e JOIN projeto p ON p.id = e.projeto_id WHERE e.id = $1`, + feature: `SELECT p.id AS projeto_id, p.nome AS projeto_titulo, e.id AS epico_id, e.titulo AS epico_titulo, + f.id AS feature_id, f.titulo AS feature_titulo, NULL::uuid AS pbi_id, NULL::text AS pbi_titulo, NULL::text AS pbi_codigo + FROM feature f JOIN epico e ON e.id = f.epico_id JOIN projeto p ON p.id = e.projeto_id WHERE f.id = $1`, + pbi: `SELECT p.id AS projeto_id, p.nome AS projeto_titulo, e.id AS epico_id, e.titulo AS epico_titulo, + f.id AS feature_id, f.titulo AS feature_titulo, b.id AS pbi_id, b.titulo AS pbi_titulo, b.codigo AS pbi_codigo + FROM pbi b JOIN feature f ON f.id = b.feature_id JOIN epico e ON e.id = f.epico_id JOIN projeto p ON p.id = e.projeto_id WHERE b.id = $1`, +}; + +interface ChainRow { + projeto_id: string; + projeto_titulo: string; + epico_id: string | null; + epico_titulo: string | null; + feature_id: string | null; + feature_titulo: string | null; + pbi_id: string | null; + pbi_titulo: string | null; + pbi_codigo: string | null; +} + +const DECISION_COLUMNS = `d.id, d.entidade_tipo, d.entidade_id, d.titulo, d.contexto, d.decisao, d.justificativa, d.alternativas, + d.autor_id, u.nome AS autor_nome, d.created_at::text AS created_at`; + +export class DecisionsRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async resolveChain(tipo: DecisionEntityType, id: string): Promise { + const result = await this.pool.query(CHAIN_QUERIES[tipo], [id]); + const row = result.rows[0]; + if (!row) return null; + const chain: ChainNode[] = [{ tipo: "projeto", id: row.projeto_id, titulo: row.projeto_titulo, codigo: null }]; + if (row.epico_id && row.epico_titulo !== null) chain.push({ tipo: "epico", id: row.epico_id, titulo: row.epico_titulo, codigo: null }); + if (row.feature_id && row.feature_titulo !== null) chain.push({ tipo: "feature", id: row.feature_id, titulo: row.feature_titulo, codigo: null }); + if (row.pbi_id && row.pbi_titulo !== null) chain.push({ tipo: "pbi", id: row.pbi_id, titulo: row.pbi_titulo, codigo: row.pbi_codigo }); + return chain; + } + + async listForChain(chain: ChainNode[]): Promise { + const result = await this.pool.query( + `SELECT ${DECISION_COLUMNS} + FROM decisao d + LEFT JOIN usuario u ON u.id = d.autor_id + WHERE (d.entidade_tipo, d.entidade_id) IN (SELECT t, i::uuid FROM unnest($1::text[], $2::text[]) AS pair(t, i)) + ORDER BY d.created_at ASC, d.id ASC`, + [chain.map((node) => node.tipo), chain.map((node) => node.id)], + ); + return result.rows; + } + + async create(input: CreateDecisionInput): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await lockHierarchy(client); + await assertWritable(client, input.entidadeTipo, input.entidadeId); + const inserted = await client.query<{ id: string }>( + `INSERT INTO decisao (entidade_tipo, entidade_id, titulo, contexto, decisao, justificativa, alternativas, autor_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + [input.entidadeTipo, input.entidadeId, input.titulo, input.contexto, input.decisao, input.justificativa, input.alternativas, input.usuarioId], + ); + const id = inserted.rows[0].id; + await auditService.record({ + usuario_id: input.usuarioId, + entidade_tipo: input.entidadeTipo, + entidade_id: input.entidadeId, + acao: "REGISTRAR_DECISAO", + dados_json: { decisao_id: id, titulo: input.titulo }, + }, client); + const created = await client.query( + `SELECT ${DECISION_COLUMNS} FROM decisao d LEFT JOIN usuario u ON u.id = d.autor_id WHERE d.id = $1`, + [id], + ); + await client.query("COMMIT"); + return created.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} diff --git a/backend/src/modules/decisions/decisions.routes.test.ts b/backend/src/modules/decisions/decisions.routes.test.ts new file mode 100644 index 0000000..9958fd7 --- /dev/null +++ b/backend/src/modules/decisions/decisions.routes.test.ts @@ -0,0 +1,88 @@ +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.js"; +import { requireRole } from "../../middleware/requireRole.js"; +import type { UserRole } from "../auth/auth.types.js"; +import { createDecisionsRouter } from "./decisions.routes.js"; +import { DecisionsService } from "./decisions.service.js"; +import { FEATURE, FakeDecisionsRepository, PBI, USER } from "./decisions.fakes.js"; + +const repository = new FakeDecisionsRepository(); +let server: Server; +let baseUrl: string; +let role: UserRole | null = "po"; + +const valid = { titulo: "Usar PostgreSQL", contexto: "Contexto", decisao: "Decisão", justificativa: "Motivo" }; + +before(() => { + const service = new DecisionsService(repository); + const app = express(); + app.use(express.json()); + const authentication = (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (!role) { + res.status(401).json({ error: "Autenticação necessária.", code: "UNAUTHORIZED" }); + return; + } + req.auth = { id: USER, nome: "Ana", email: "a@example.com", role }; + next(); + }; + app.use("/api/v1/pbis/:entityId/decisions", createDecisionsRouter("pbi", service, authentication, requireRole("admin", "po"))); + app.use("/api/v1/features/:entityId/decisions", createDecisionsRouter("feature", service, authentication, requireRole("admin", "po"))); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1`; +}); + +after(() => { + server.close(); +}); + +const call = (path: string, init: { method?: string; json?: unknown } = {}) => + fetch(`${baseUrl}${path}`, { + method: init.method ?? "GET", + headers: { "Content-Type": "application/json" }, + body: init.json === undefined ? undefined : JSON.stringify(init.json), + }); + +test("exige autenticação para listar e registrar", async () => { + role = null; + assert.equal((await call(`/pbis/${PBI}/decisions`)).status, 401); + assert.equal((await call(`/pbis/${PBI}/decisions`, { method: "POST", json: valid })).status, 401); + role = "po"; +}); + +test("PO registra (201) e todos os perfis consultam; dev não registra (403)", async () => { + const created = await call(`/pbis/${PBI}/decisions`, { method: "POST", json: { ...valid, alternativas: "MySQL" } }); + assert.equal(created.status, 201); + const body = await created.json() as { id: string; alternativas: string; autor: { nome: string } }; + assert.equal(body.alternativas, "MySQL"); + assert.equal(body.autor.nome, "Ana PO"); + + role = "dev"; + assert.equal((await call(`/pbis/${PBI}/decisions`, { method: "POST", json: valid })).status, 403); + const list = await call(`/pbis/${PBI}/decisions`); + assert.equal(list.status, 200); + assert.equal(((await list.json()) as { decisoes: unknown[] }).decisoes.length, 1); + role = "po"; +}); + +test("valida o corpo e o identificador", async () => { + assert.equal((await call(`/pbis/${PBI}/decisions`, { method: "POST", json: { titulo: "Somente título" } })).status, 400); + assert.equal((await call(`/pbis/nao-uuid/decisions`)).status, 400); + assert.equal((await call(`/pbis/a0000000-0000-4000-8000-0000000000ff/decisions`)).status, 404); +}); + +test("arquivado retorna 409 sem registrar e a herança aparece na consulta do PBI", async () => { + await call(`/features/${FEATURE}/decisions`, { method: "POST", json: { ...valid, titulo: "Decisão da feature" } }); + const inherited = await (await call(`/pbis/${PBI}/decisions`)).json() as { decisoes: Array<{ titulo: string; origem: { herdada: boolean } }> }; + assert.ok(inherited.decisoes.some((item) => item.titulo === "Decisão da feature" && item.origem.herdada)); + + const before = repository.rows.length; + repository.archived.add(PBI); + assert.equal((await call(`/pbis/${PBI}/decisions`, { method: "POST", json: valid })).status, 409); + assert.equal(repository.rows.length, before); + repository.archived.delete(PBI); +}); diff --git a/backend/src/modules/decisions/decisions.routes.ts b/backend/src/modules/decisions/decisions.routes.ts new file mode 100644 index 0000000..ad9a75c --- /dev/null +++ b/backend/src/modules/decisions/decisions.routes.ts @@ -0,0 +1,74 @@ +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; +import { requireAuth } from "../../middleware/requireAuth.js"; +import { requireRole } from "../../middleware/requireRole.js"; +import { ValidationError } from "../../shared/errors.js"; +import { DecisionsService, decisionsService } from "./decisions.service.js"; +import type { DecisionEntityType } from "./decisions.types.js"; + +export function createDecisionsRouter( + tipo: DecisionEntityType, + service: DecisionsService = decisionsService, + authentication: RequestHandler = requireAuth, + canWrite: RequestHandler = requireRole("admin", "po"), +): Router { + const router = Router({ mergeParams: true }); + router.use(authentication); + + /** + * @swagger + * /api/v1/{nivel}/{id}/decisions: + * get: + * summary: Lista as decisões do item e herdadas dos ascendentes (S1-18) + * tags: [Decisions] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Decisões em ordem cronológica, com autor, data e origem + * 404: + * description: Item não encontrado + */ + router.get("/", async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.list(tipo, String(req.params.entityId))); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/v1/{nivel}/{id}/decisions: + * post: + * summary: Registra uma decisão no item (S1-18) + * tags: [Decisions] + * security: + * - bearerAuth: [] + * responses: + * 201: + * description: Decisão registrada com autor e data + * 400: + * description: Campos obrigatórios ausentes ou inválidos + * 403: + * description: Perfil sem permissão de escrita + * 404: + * description: Item não encontrado + * 409: + * description: Item ou ancestral arquivado + */ + router.post("/", canWrite, async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.auth?.id) throw new ValidationError("Autenticação necessária."); + res.status(201).json(await service.create(tipo, String(req.params.entityId), req.auth.id, req.body)); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const projectDecisionsRouter = createDecisionsRouter("projeto"); +export const epicDecisionsRouter = createDecisionsRouter("epico"); +export const featureDecisionsRouter = createDecisionsRouter("feature"); +export const pbiDecisionsRouter = createDecisionsRouter("pbi"); diff --git a/backend/src/modules/decisions/decisions.service.test.ts b/backend/src/modules/decisions/decisions.service.test.ts new file mode 100644 index 0000000..585f25e --- /dev/null +++ b/backend/src/modules/decisions/decisions.service.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NotFoundError, ValidationError } from "../../shared/errors.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { DecisionsService } from "./decisions.service.js"; +import { EPIC, FEATURE, FakeDecisionsRepository, OTHER_PBI, PBI, PROJECT, USER } from "./decisions.fakes.js"; + +const valid = { + titulo: "Usar PostgreSQL", + contexto: "Precisamos de persistência relacional.", + decisao: "Adotar PostgreSQL 16.", + justificativa: "Suporta pgvector e transações.", +}; + +function setup() { + const repository = new FakeDecisionsRepository(); + return { repository, service: new DecisionsService(repository) }; +} + +test("registra decisão completa vinculada ao item, com autor e data", async () => { + const { service } = setup(); + const created = await service.create("pbi", PBI, USER, valid); + + assert.equal(created.titulo, "Usar PostgreSQL"); + assert.equal(created.decisao, "Adotar PostgreSQL 16."); + assert.deepEqual(created.autor, { id: USER, nome: "Ana PO" }); + assert.ok(Date.parse(created.created_at)); + assert.equal(created.origem.tipo, "pbi"); + assert.equal(created.origem.herdada, false); + assert.equal(created.alternativas, null); +}); + +test("preserva as alternativas descartadas e normaliza espaços", async () => { + const { service } = setup(); + const created = await service.create("feature", FEATURE, USER, { ...valid, titulo: " Título ", alternativas: " MySQL; MongoDB " }); + assert.equal(created.titulo, "Título"); + assert.equal(created.alternativas, "MySQL; MongoDB"); + const blank = await service.create("feature", FEATURE, USER, { ...valid, alternativas: " " }); + assert.equal(blank.alternativas, null); +}); + +test("recusa campos obrigatórios ausentes, curtos, longos ou desconhecidos com mensagem legível", async () => { + const { service, repository } = setup(); + for (const field of ["titulo", "contexto", "decisao", "justificativa"] as const) { + await assert.rejects(service.create("pbi", PBI, USER, { ...valid, [field]: " " }), ValidationError, field); + await assert.rejects(service.create("pbi", PBI, USER, { ...valid, [field]: undefined }), /obrigatório/, field); + } + await assert.rejects(service.create("pbi", PBI, USER, { ...valid, titulo: "ab" }), /ao menos 3/); + await assert.rejects(service.create("pbi", PBI, USER, { ...valid, contexto: "x".repeat(5001) }), /no máximo 5000/); + await assert.rejects(service.create("pbi", PBI, USER, { ...valid, autor_id: "x" }), ValidationError); + assert.equal(repository.rows.length, 0); +}); + +test("item inexistente ou identificador malformado", async () => { + const { service } = setup(); + await assert.rejects(service.create("pbi", OTHER_PBI, USER, valid), NotFoundError); + await assert.rejects(service.list("pbi", "nao-uuid"), ValidationError); + await assert.rejects(service.list("epico", OTHER_PBI), /Épico não encontrado/); +}); + +test("item ou ancestral arquivado não recebe decisões (409)", async () => { + const { service, repository } = setup(); + repository.archived.add(PBI); + await assert.rejects(service.create("pbi", PBI, USER, valid), ArchiveConflict); + assert.equal(repository.rows.length, 0); +}); + +test("lista em ordem cronológica, com herança de feature, épico e projeto marcada", async () => { + const { service } = setup(); + await service.create("epico", EPIC, USER, { ...valid, titulo: "Decisão do épico" }); + await service.create("pbi", PBI, USER, { ...valid, titulo: "Decisão do PBI" }); + await service.create("projeto", PROJECT, USER, { ...valid, titulo: "Decisão do projeto" }); + await service.create("feature", FEATURE, USER, { ...valid, titulo: "Decisão da feature" }); + + const result = await service.list("pbi", PBI); + assert.deepEqual(result.decisoes.map((item) => item.titulo), ["Decisão do épico", "Decisão do PBI", "Decisão do projeto", "Decisão da feature"]); + assert.deepEqual(result.decisoes.map((item) => item.origem.herdada), [true, false, true, true]); + assert.deepEqual(result.decisoes.map((item) => item.origem.tipo), ["epico", "pbi", "projeto", "feature"]); + assert.deepEqual(result.ancestrais.map((node) => node.tipo), ["projeto", "epico", "feature"]); + assert.equal(result.entidade.codigo, "PBI-01.5.1"); +}); + +test("a lista de um ascendente não inclui decisões dos descendentes", async () => { + const { service } = setup(); + await service.create("pbi", PBI, USER, { ...valid, titulo: "Somente do PBI" }); + await service.create("epico", EPIC, USER, { ...valid, titulo: "Do épico" }); + + assert.deepEqual((await service.list("epico", EPIC)).decisoes.map((item) => item.titulo), ["Do épico"]); + assert.deepEqual((await service.list("projeto", PROJECT)).decisoes, []); +}); diff --git a/backend/src/modules/decisions/decisions.service.ts b/backend/src/modules/decisions/decisions.service.ts new file mode 100644 index 0000000..533f049 --- /dev/null +++ b/backend/src/modules/decisions/decisions.service.ts @@ -0,0 +1,75 @@ +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { DecisionsRepository } from "./decisions.repository.js"; +import { + createDecisionSchema, + type ChainNode, + type DecisionEntityType, + type DecisionListResponse, + type DecisionRecord, + type DecisionRow, +} from "./decisions.types.js"; + +const LABELS: Record = { + projeto: "projeto", + epico: "épico", + feature: "feature", + pbi: "PBI", +}; + +export class DecisionsService { + constructor(private readonly repository: DecisionsRepository = new DecisionsRepository()) {} + + private async requireChain(tipo: DecisionEntityType, id: string): Promise { + validateUuid(id, `ID do ${LABELS[tipo]}`); + const chain = await this.repository.resolveChain(tipo, id); + if (!chain) throw new NotFoundError(`${LABELS[tipo][0].toUpperCase()}${LABELS[tipo].slice(1)} não encontrado.`); + return chain; + } + + private toRecord(row: DecisionRow, chain: ChainNode[], ownId: string): DecisionRecord { + const origin = chain.find((node) => node.tipo === row.entidade_tipo && node.id === row.entidade_id) as ChainNode; + return { + id: row.id, + titulo: row.titulo, + contexto: row.contexto, + decisao: row.decisao, + justificativa: row.justificativa, + alternativas: row.alternativas, + autor: row.autor_id && row.autor_nome ? { id: row.autor_id, nome: row.autor_nome } : null, + created_at: row.created_at, + origem: { ...origin, herdada: origin.id !== ownId }, + }; + } + + async list(tipo: DecisionEntityType, id: string): Promise { + const chain = await this.requireChain(tipo, id); + const rows = await this.repository.listForChain(chain); + const known = new Set(chain.map((node) => `${node.tipo}:${node.id}`)); + const decisoes = rows + .filter((row) => known.has(`${row.entidade_tipo}:${row.entidade_id}`)) + .map((row) => this.toRecord(row, chain, id)); + return { entidade: chain[chain.length - 1], ancestrais: chain.slice(0, -1), decisoes }; + } + + async create(tipo: DecisionEntityType, id: string, usuarioId: string, body: unknown): Promise { + const parsed = createDecisionSchema.safeParse(body); + if (!parsed.success) { + const first = parsed.error.issues[0]; + throw new ValidationError(first?.message ?? "Dados da decisão inválidos.", parsed.error.flatten().fieldErrors); + } + const chain = await this.requireChain(tipo, id); + const row = await this.repository.create({ + entidadeTipo: tipo, + entidadeId: id, + usuarioId, + titulo: parsed.data.titulo, + contexto: parsed.data.contexto, + decisao: parsed.data.decisao, + justificativa: parsed.data.justificativa, + alternativas: parsed.data.alternativas, + }); + return this.toRecord(row, chain, id); + } +} + +export const decisionsService = new DecisionsService(); diff --git a/backend/src/modules/decisions/decisions.types.ts b/backend/src/modules/decisions/decisions.types.ts new file mode 100644 index 0000000..abdcb58 --- /dev/null +++ b/backend/src/modules/decisions/decisions.types.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +export type DecisionEntityType = "projeto" | "epico" | "feature" | "pbi"; + +export interface ChainNode { + tipo: DecisionEntityType; + id: string; + titulo: string; + codigo: string | null; +} + +export interface DecisionRow { + id: string; + entidade_tipo: DecisionEntityType; + entidade_id: string; + titulo: string; + contexto: string; + decisao: string; + justificativa: string; + alternativas: string | null; + autor_id: string | null; + autor_nome: string | null; + created_at: string; +} + +export interface DecisionRecord { + id: string; + titulo: string; + contexto: string; + decisao: string; + justificativa: string; + alternativas: string | null; + autor: { id: string; nome: string } | null; + created_at: string; + origem: ChainNode & { herdada: boolean }; +} + +export interface DecisionListResponse { + entidade: ChainNode; + ancestrais: ChainNode[]; + decisoes: DecisionRecord[]; +} + +export interface CreateDecisionInput { + entidadeTipo: DecisionEntityType; + entidadeId: string; + usuarioId: string; + titulo: string; + contexto: string; + decisao: string; + justificativa: string; + alternativas: string | null; +} + +const required = (label: string, max: number) => + z + .string({ required_error: `${label} é obrigatório.`, invalid_type_error: `${label} deve ser texto.` }) + .trim() + .min(1, `${label} é obrigatório.`) + .max(max, `${label} pode ter no máximo ${max} caracteres.`); + +export const createDecisionSchema = z + .object({ + titulo: required("O título", 255).pipe(z.string().min(3, "O título deve ter ao menos 3 caracteres.")), + contexto: required("O contexto", 5000), + decisao: required("A decisão", 5000), + justificativa: required("A justificativa", 5000), + alternativas: z + .string({ invalid_type_error: "As alternativas devem ser texto." }) + .trim() + .max(5000, "As alternativas podem ter no máximo 5000 caracteres.") + .nullish() + .transform((value) => (value ? value : null)), + }) + .strict(); + +export type CreateDecisionBody = z.infer; diff --git a/backend/src/modules/search/search.routes.ts b/backend/src/modules/search/search.routes.ts index 4d2345d..c08008a 100644 --- a/backend/src/modules/search/search.routes.ts +++ b/backend/src/modules/search/search.routes.ts @@ -1,4 +1,5 @@ import { Router, Request, Response, NextFunction } from "express"; +import { ValidationError, validateUuid } from "../../shared/errors.js"; import { pool } from "../../database/db.js"; import { requireAuth } from "../../middleware/requireAuth.js"; @@ -12,6 +13,9 @@ searchRouter.get("/", async (req: Request, res: Response, next: NextFunction) => const q = ((req.query.q as string) || "").trim(); const projectId = (req.query.projeto_id as string) || (req.query.projectId as string); + if (projectId) validateUuid(projectId, "ID do projeto"); + if (q.length > 200) throw new ValidationError("A busca pode ter no máximo 200 caracteres."); + if (!q) { // Se a query estiver vazia, retorna os chunks mais recentes let sql = ` @@ -37,7 +41,7 @@ searchRouter.get("/", async (req: Request, res: Response, next: NextFunction) => JOIN projeto p ON c.projeto_id = p.id WHERE c.texto ILIKE $1 `; - const params: unknown[] = [`%${q}%`]; + const params: unknown[] = [`%${q.replace(/[\\%_]/g, "\\$&")}%`]; if (projectId) { sql += " AND c.projeto_id = $2"; diff --git a/backend/src/openapi.contract.test.ts b/backend/src/openapi.contract.test.ts new file mode 100644 index 0000000..0d60eb5 --- /dev/null +++ b/backend/src/openapi.contract.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { AddressInfo } from "node:net"; +import { parse } from "yaml"; +import app from "./index.js"; +import { listRoutes, normalizeRoute } from "./shared/route-inventory.js"; + +type PathItem = Record; +type Document = { paths: Record }; + +const METHODS = ["get", "post", "put", "patch", "delete"] as const; +const DOCS = resolve(process.cwd(), "../docs/api"); +const EXTERNAL_PREFIXES = ["/ingest", "/embeddings", "/rag", "/webhook", "/api/analyze", "/api/runs"]; + +const document = parse(readFileSync(resolve(DOCS, "openapi.yaml"), "utf8")) as Document; +const compat = parse(readFileSync(resolve(DOCS, "epics-compat.yaml"), "utf8")) as Record; + +function expand(item: PathItem): PathItem { + const reference = item.$ref; + if (typeof reference !== "string") return item; + const key = reference.split("#/")[1]; + return compat[key] ?? {}; +} + +function canonical(route: string): string { + return normalizeRoute(route.replace(/^([A-Z]+) \/api\/(?!v1\b)/, "$1 /api/v1/")); +} + +const documented = new Map(); +for (const [path, rawItem] of Object.entries(document.paths)) { + if (EXTERNAL_PREFIXES.some((prefix) => path.startsWith(prefix))) continue; + const item = expand(rawItem); + for (const method of METHODS) { + const operation = item[method] as { security?: unknown[] } | undefined; + if (!operation) continue; + documented.set(canonical(`${method.toUpperCase()} ${path}`), { + path, + method: method.toUpperCase(), + secured: !(Array.isArray(operation.security) && operation.security.length === 0), + }); + } +} + +const implemented = new Set(listRoutes(app).map(canonical)); + +test("todo endpoint documentado no OpenAPI existe no backend", () => { + const missing = [...documented.keys()].filter((route) => !implemented.has(route)); + assert.deepEqual(missing, [], `Documentado mas não implementado:\n${missing.join("\n")}`); +}); + +test("todo endpoint entregue está documentado no OpenAPI (aliases /api/* equivalem a /api/v1/*)", () => { + const undocumented = [...implemented].filter((route) => !documented.has(route)); + assert.deepEqual(undocumented, [], `Implementado mas não documentado:\n${undocumented.join("\n")}`); +}); + +test("endpoints documentados como protegidos recusam requisição sem sessão (401)", async () => { + const server = app.listen(0); + try { + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const failures: string[] = []; + for (const { path, method, secured } of documented.values()) { + if (!secured) continue; + const url = `${base}${path.replace(/\{[^}]+\}/g, "a0000000-0000-4000-8000-000000000001")}`; + const response = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: method === "GET" || method === "DELETE" ? undefined : "{}" }); + if (response.status !== 401) failures.push(`${method} ${path} -> ${response.status}`); + } + assert.deepEqual(failures, [], `Rotas documentadas como protegidas que responderam diferente de 401:\n${failures.join("\n")}`); + } finally { + server.close(); + } +}); + +test("endpoints públicos documentados são exatamente login, registro, raiz e health", () => { + const open = [...documented.values()].filter((entry) => !entry.secured).map((entry) => `${entry.method} ${entry.path}`).sort(); + assert.deepEqual(open, ["GET /api/v1", "GET /health", "POST /api/v1/auth/login", "POST /api/v1/auth/register"]); +}); diff --git a/backend/src/shared/route-inventory.ts b/backend/src/shared/route-inventory.ts new file mode 100644 index 0000000..ab6fa4b --- /dev/null +++ b/backend/src/shared/route-inventory.ts @@ -0,0 +1,48 @@ +interface ExpressLayer { + route?: { path: string | string[]; methods: Record }; + name?: string; + handle?: { stack?: ExpressLayer[] }; + regexp?: RegExp & { fast_slash?: boolean }; + keys?: Array<{ name: string | number }>; +} + +interface ExpressLike { + _router?: { stack: ExpressLayer[] }; + router?: { stack: ExpressLayer[] }; +} + +function mountPath(layer: ExpressLayer): string { + if (!layer.regexp || layer.regexp.fast_slash) return ""; + let index = 0; + const source = layer.regexp.source + .replace("\\/?(?=\\/|$)", "") + .replace(/^\^/, "") + .replace(/\(\?:\\\/\(\[\^[^\]]*\]\+\?\)\)/g, () => `/{${String(layer.keys?.[index++]?.name ?? "param")}}`); + return source.replace(/\\\//g, "/").replace(/\$$/, ""); +} + +function walk(stack: ExpressLayer[], prefix: string, found: Set): void { + for (const layer of stack) { + if (layer.route) { + const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path]; + for (const routePath of paths) { + const path = `${prefix}${routePath === "/" ? "" : routePath}`.replace(/:([A-Za-z0-9_]+)/g, "{$1}") || "/"; + for (const method of Object.keys(layer.route.methods)) found.add(`${method.toUpperCase()} ${path}`); + } + } else if (layer.name === "router" && layer.handle?.stack) { + walk(layer.handle.stack, `${prefix}${mountPath(layer)}`, found); + } + } +} + +export function listRoutes(app: unknown): string[] { + const target = app as ExpressLike; + const stack = target._router?.stack ?? target.router?.stack ?? []; + const found = new Set(); + walk(stack, "", found); + return [...found].sort(); +} + +export function normalizeRoute(route: string): string { + return route.replace(/\{[^}]+\}/g, "{}").replace(/\/+$/, ""); +} diff --git a/database/migrations/013_decision_records.sql b/database/migrations/013_decision_records.sql new file mode 100644 index 0000000..0cf8edb --- /dev/null +++ b/database/migrations/013_decision_records.sql @@ -0,0 +1,11 @@ +-- S1-18: decisões em qualquer nível da hierarquia (projeto, épico, feature, PBI). +ALTER TABLE decisao ADD COLUMN IF NOT EXISTS decisao TEXT; +UPDATE decisao SET decisao = titulo WHERE decisao IS NULL; +ALTER TABLE decisao ALTER COLUMN decisao SET NOT NULL; + +ALTER TABLE decisao DROP CONSTRAINT IF EXISTS ck_decisao_entidade_tipo; +ALTER TABLE decisao ADD CONSTRAINT ck_decisao_entidade_tipo + CHECK (entidade_tipo IN ('projeto', 'epico', 'feature', 'pbi')) NOT VALID; + +CREATE INDEX IF NOT EXISTS idx_decisao_entidade_data + ON decisao(entidade_tipo, entidade_id, created_at, id); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20c3434..df38dd8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -265,6 +265,51 @@ paths: schema: $ref: '#/components/schemas/Error' + patch: + summary: Atualiza dados de um projeto (S1-03 / S1-08) (parcial, mesmo corpo do PUT) + operationId: updateProjectPartial + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProjectRequest' + responses: + '200': + description: Projeto atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectResponse' + '403': + description: Perfil sem permissão de escrita + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Nome duplicado ou tentativa de editar projeto arquivado (somente leitura). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/projects/{id}/archive-impact: get: summary: Consulta quantidade de novos itens afetados pela cascata (S1-09) @@ -523,6 +568,41 @@ paths: '404': description: Épico não encontrado + patch: + summary: Atualiza dados de um épico (S1-05) (parcial, mesmo corpo do PUT) + operationId: updateEpicPartial + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateEpicRequest' + responses: + '200': + description: Épico atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/EpicResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Épico não encontrado + /api/v1/epics/{id}/complete: patch: summary: Marca um épico como concluído quando os campos obrigatórios do guia e ao menos um critério existirem (PBI-01.1.2 / S1-05) @@ -663,6 +743,35 @@ paths: '404': description: Feature não encontrada + patch: + summary: Atualiza dados de uma feature (S1-06) (parcial, mesmo corpo do PUT) + operationId: updateFeaturePartial + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFeatureRequest' + responses: + '200': + description: Feature atualizada + content: + application/json: + schema: + $ref: '#/components/schemas/FeatureResponse' + '404': + description: Feature não encontrada + /api/v1/features/{id}/complete: patch: summary: Marca uma feature como concluída quando descrição e objetivo estiverem preenchidos (PBI-01.1.3 / S1-06) @@ -799,6 +908,35 @@ paths: '404': description: PBI não encontrado + patch: + summary: Atualiza dados de um PBI (S1-07) (parcial, mesmo corpo do PUT) + operationId: updatePbiPartial + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePbiRequest' + responses: + '200': + description: PBI atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/PbiResponse' + '404': + description: PBI não encontrado + /api/v1/pbis/{id}/complete: patch: summary: Marca um PBI como concluído quando ao menos um cenário DADO/QUANDO/ENTÃO existir e o título estiver no infinitivo (PBI-01.1.4, PBI-01.3.1 / S1-07, S1-13) @@ -968,183 +1106,723 @@ paths: type: string responses: '200': - description: Eventos ordenados do mais recente para o mais antigo + description: Eventos ordenados do mais recente para o mais antigo + content: + application/json: + schema: + type: object + required: [items, next_cursor] + properties: + items: + type: array + items: + $ref: '#/components/schemas/AuditHistoryItem' + next_cursor: + type: string + nullable: true + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Entidade não encontrada + + /api/v1/criteria: + post: + summary: Registra um critério de aceitação polimórfico (PBI-01.2.1, PBI-01.2.2, PBI-01.2.3 / S1-10) + operationId: createCriterion + description: >- + Épico e feature exigem texto livre; PBI exige nome, DADO, QUANDO e ENTÃO em conjunto. + Novo critério é sempre adicionado ao final da ordem existente na entidade. Para item concluído, + a política organizacional pode exigir justificativa, armazenada na auditoria da mutação. + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCriterionRequest' + responses: + '201': + description: Critério adicionado ao final da lista da entidade + content: + application/json: + schema: + $ref: '#/components/schemas/CriterionResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Entidade referenciada não encontrada + + get: + summary: Lista critérios de aceitação ordenados de uma entidade (S1-10) + operationId: listCriteria + security: + - cookieAuth: [] + parameters: + - name: entidade_tipo + in: query + required: true + schema: + type: string + enum: [epico, feature, pbi] + - name: entidade_id + in: query + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Critérios ordenados da entidade + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/CriterionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/criteria/{id}: + delete: + summary: Remove um critério de aceitação e reordena os demais da mesma entidade (PBI-01.2.1 / S1-10) + operationId: deleteCriterion + description: A política organizacional pode exigir justificativa ao alterar critérios de item concluído. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + justificativa: + type: string + maxLength: 2000 + responses: + '200': + description: Critério removido + content: + application/json: + schema: + $ref: '#/components/schemas/CriterionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Critério não encontrado + + /api/v1/criteria/{id}/move: + patch: + summary: Reordena um critério em relação aos demais da mesma entidade (PBI-01.2.4 / S1-12) + operationId: moveCriterion + description: >- + Operação idempotente: mover o primeiro item para cima (ou o último para baixo) não + retorna erro, apenas devolve a lista sem alteração. Para item concluído, a política + organizacional pode exigir justificativa para uma movimentação efetiva. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [direction] + properties: + direction: + type: string + enum: [up, down] + justificativa: + type: string + maxLength: 2000 + responses: + '200': + description: Lista ordenada da entidade após a movimentação + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/CriterionResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Critério não encontrado + + /api/v1/projects/{id}/decisions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as decisões do projeto e as herdadas dos ascendentes (S1-18) + description: > + Ordem cronológica (mais antiga primeiro), com autor e data. Decisões de + níveis acima vêm com `origem.herdada = true`; decisões de descendentes + nunca aparecem. + operationId: listProjectsDecisions + security: + - cookieAuth: [] + responses: + '200': + description: Decisões do item e dos ascendentes + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Registra uma decisão no projeto (S1-18) + description: > + Contexto, decisão e justificativa são obrigatórios; alternativas + descartadas são opcionais. Registrar decisão nunca é exigido para + concluir um item. Apenas admin e po. + operationId: createProjectsDecision + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionInput' + responses: + '201': + description: Decisão registrada + content: + application/json: + schema: + $ref: '#/components/schemas/Decision' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + '409': + description: Item ou ancestral arquivado (somente leitura) + + /api/v1/epics/{id}/decisions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as decisões do épico e as herdadas dos ascendentes (S1-18) + description: > + Ordem cronológica (mais antiga primeiro), com autor e data. Decisões de + níveis acima vêm com `origem.herdada = true`; decisões de descendentes + nunca aparecem. + operationId: listEpicsDecisions + security: + - cookieAuth: [] + responses: + '200': + description: Decisões do item e dos ascendentes + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Registra uma decisão no épico (S1-18) + description: > + Contexto, decisão e justificativa são obrigatórios; alternativas + descartadas são opcionais. Registrar decisão nunca é exigido para + concluir um item. Apenas admin e po. + operationId: createEpicsDecision + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionInput' + responses: + '201': + description: Decisão registrada + content: + application/json: + schema: + $ref: '#/components/schemas/Decision' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + '409': + description: Item ou ancestral arquivado (somente leitura) + + /api/v1/features/{id}/decisions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as decisões do feature e as herdadas dos ascendentes (S1-18) + description: > + Ordem cronológica (mais antiga primeiro), com autor e data. Decisões de + níveis acima vêm com `origem.herdada = true`; decisões de descendentes + nunca aparecem. + operationId: listFeaturesDecisions + security: + - cookieAuth: [] + responses: + '200': + description: Decisões do item e dos ascendentes + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Registra uma decisão no feature (S1-18) + description: > + Contexto, decisão e justificativa são obrigatórios; alternativas + descartadas são opcionais. Registrar decisão nunca é exigido para + concluir um item. Apenas admin e po. + operationId: createFeaturesDecision + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionInput' + responses: + '201': + description: Decisão registrada + content: + application/json: + schema: + $ref: '#/components/schemas/Decision' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + '409': + description: Item ou ancestral arquivado (somente leitura) + + /api/v1/pbis/{id}/decisions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as decisões do PBI e as herdadas dos ascendentes (S1-18) + description: > + Ordem cronológica (mais antiga primeiro), com autor e data. Decisões de + níveis acima vêm com `origem.herdada = true`; decisões de descendentes + nunca aparecem. + operationId: listPbisDecisions + security: + - cookieAuth: [] + responses: + '200': + description: Decisões do item e dos ascendentes + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Registra uma decisão no PBI (S1-18) + description: > + Contexto, decisão e justificativa são obrigatórios; alternativas + descartadas são opcionais. Registrar decisão nunca é exigido para + concluir um item. Apenas admin e po. + operationId: createPbisDecision + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DecisionInput' + responses: + '201': + description: Decisão registrada + content: + application/json: + schema: + $ref: '#/components/schemas/Decision' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + '409': + description: Item ou ancestral arquivado (somente leitura) + + /api/v1/auth/register: + post: + summary: Cria uma conta (PO ou dev); administrador exige sessão de administrador + operationId: registerUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nome, email, password] + properties: + nome: {type: string, minLength: 2} + email: {type: string, format: email} + password: {type: string, minLength: 6} + role: {type: string, enum: [po, dev, admin], default: po, description: "admin só é aceito com sessão de administrador"} + responses: + '201': + description: Conta criada e sessão iniciada + '400': + $ref: '#/components/responses/ValidationError' + '403': + description: Tentativa de criar administrador sem sessão de administrador + '409': + description: E-mail já cadastrado + + /api/v1/chat/conversations: + get: + summary: Lista as conversas do usuário autenticado + operationId: listChatConversations + security: [{cookieAuth: []}] + responses: + '200': + description: Conversas do usuário, da mais recente para a mais antiga + '401': + $ref: '#/components/responses/Unauthorized' + post: + summary: Cria uma conversa (opcionalmente escopada a um projeto) + operationId: createChatConversation + security: [{cookieAuth: []}] + requestBody: + content: + application/json: + schema: + type: object + properties: + titulo: {type: string, maxLength: 120} + projeto_id: {type: string, format: uuid} + responses: + '201': + description: Conversa criada + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + + /api/v1/chat/conversations/{id}/messages: + get: + summary: Mensagens de uma conversa do próprio usuário + description: Conversa de outra pessoa retorna 404, sem revelar a existência. + operationId: listChatMessages + security: [{cookieAuth: []}] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': + description: Mensagens em ordem cronológica + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Conversa não encontrada + + /api/v1/chat/query: + post: + summary: Envia uma pergunta ao assistente e registra a troca + description: > + Sem evidência no acervo, responde "Informação não encontrada no acervo + do projeto." sem inventar fontes. `origem` informa se a resposta veio + do assistente, de busca textual (assistente indisponível) ou sem resultado. + operationId: askChatQuestion + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [pergunta] + properties: + pergunta: {type: string, minLength: 1, maxLength: 2000} + conversa_id: {type: string, format: uuid} + projeto_id: {type: string, format: uuid} + responses: + '200': + description: Resposta com fontes content: application/json: schema: type: object - required: [items, next_cursor] + required: [conversa_id, resposta, fontes, origem] properties: - items: + conversa_id: {type: string, format: uuid} + resposta: {type: string} + fontes: type: array items: - $ref: '#/components/schemas/AuditHistoryItem' - next_cursor: - type: string - nullable: true + type: object + properties: + id: {type: string} + titulo: {type: string} + tipo: {type: string} + origem: {type: string, enum: [assistente, busca_textual, sem_resultado]} '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' '404': - description: Entidade não encontrada + description: Conversa (de outro usuário incluída) ou projeto não encontrado - /api/v1/criteria: + /api/v1/projects/{projectId}/repo-analyses: + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + get: + summary: Lista as análises de repositório do projeto + operationId: listRepoAnalyses + security: [{cookieAuth: []}] + responses: + '200': + description: Análises do projeto, da mais recente para a mais antiga + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado post: - summary: Registra um critério de aceitação polimórfico (PBI-01.2.1, PBI-01.2.2, PBI-01.2.3 / S1-10) - operationId: createCriterion - description: >- - Épico e feature exigem texto livre; PBI exige nome, DADO, QUANDO e ENTÃO em conjunto. - Novo critério é sempre adicionado ao final da ordem existente na entidade. Para item concluído, - a política organizacional pode exigir justificativa, armazenada na auditoria da mutação. - security: - - cookieAuth: [] + summary: Inicia uma análise de repositório público do GitHub + operationId: startRepoAnalysis + security: [{cookieAuth: []}] requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateCriterionRequest' + type: object + required: [repositorio_url] + properties: + repositorio_url: {type: string, example: "https://github.com/usuario/repositorio"} responses: '201': - description: Critério adicionado ao final da lista da entidade - content: - application/json: - schema: - $ref: '#/components/schemas/CriterionResponse' + description: Análise iniciada '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' - '403': - description: Perfil sem permissão de escrita '404': - description: Entidade referenciada não encontrada + description: Projeto não encontrado + '409': + description: Projeto arquivado é somente leitura + '503': + description: Motor de análise indisponível + /api/v1/projects/{projectId}/repo-analyses/{id}: get: - summary: Lista critérios de aceitação ordenados de uma entidade (S1-10) - operationId: listCriteria - security: - - cookieAuth: [] + summary: Detalhes de uma análise do projeto + operationId: getRepoAnalysis + security: [{cookieAuth: []}] parameters: - - name: entidade_tipo - in: query - required: true - schema: - type: string - enum: [epico, feature, pbi] - - name: entidade_id - in: query - required: true - schema: - type: string - format: uuid + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': - description: Critérios ordenados da entidade - content: - application/json: - schema: - type: object - required: [items] - properties: - items: - type: array - items: - $ref: '#/components/schemas/CriterionResponse' + description: Análise com etapa, progresso e relatório quando concluída + '400': + $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto ou análise não encontrados - /api/v1/criteria/{id}: - delete: - summary: Remove um critério de aceitação e reordena os demais da mesma entidade (PBI-01.2.1 / S1-10) - operationId: deleteCriterion - description: A política organizacional pode exigir justificativa ao alterar critérios de item concluído. - security: - - cookieAuth: [] + /api/v1/search: + get: + summary: Busca textual no acervo indexado (chunks), com escopo opcional por projeto + operationId: searchKnowledge + security: [{cookieAuth: []}] parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - justificativa: - type: string - maxLength: 2000 + - {name: q, in: query, required: false, schema: {type: string}} + - {name: projeto_id, in: query, required: false, schema: {type: string, format: uuid}} responses: '200': - description: Critério removido - content: - application/json: - schema: - $ref: '#/components/schemas/CriterionResponse' + description: Trechos encontrados + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/developers: + get: + summary: Desenvolvedores, tecnologias e competências + operationId: listDevelopers + security: [{cookieAuth: []}] + responses: + '200': + description: Mapeamento de competências + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/admin/stats: + get: + summary: Indicadores administrativos + operationId: adminStats + security: [{cookieAuth: []}] + responses: + '200': + description: Contagens da base '401': $ref: '#/components/responses/Unauthorized' '403': - description: Perfil sem permissão de escrita - '404': - description: Critério não encontrado + description: Somente administrador - /api/v1/criteria/{id}/move: - patch: - summary: Reordena um critério em relação aos demais da mesma entidade (PBI-01.2.4 / S1-12) - operationId: moveCriterion - description: >- - Operação idempotente: mover o primeiro item para cima (ou o último para baixo) não - retorna erro, apenas devolve a lista sem alteração. Para item concluído, a política - organizacional pode exigir justificativa para uma movimentação efetiva. + /api/v1/admin/ingest-seed: + post: + summary: Carrega dados de demonstração no acervo (somente administrador) + operationId: adminIngestSeed + security: [{cookieAuth: []}] + responses: + '200': + description: Dados carregados + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Somente administrador + + /api/v1: + get: + summary: Informações e módulos da API + operationId: apiRoot + security: [] + responses: + '200': + description: Nome, versão e módulos + + /api/v1/projects/{projectId}/backlog-search: + get: + summary: Busca textual relacional no backlog do projeto (S1-17) + description: > + Pesquisa título, descrição (épico/feature), história e regras (PBI) e o + código do PBI, sem diferenciar acentos ou maiúsculas. Todos os termos + precisam aparecer (interseção) e a busca combina por interseção com + `status` e `tecnologia`. Nunca retorna itens de outro projeto. Não é + busca semântica/RAG. + operationId: searchProjectBacklog security: - cookieAuth: [] parameters: - - name: id + - name: projectId in: path required: true - schema: - type: string - format: uuid - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [direction] - properties: - direction: - type: string - enum: [up, down] - justificativa: - type: string - maxLength: 2000 + schema: {type: string, format: uuid} + - name: q + in: query + required: true + description: Termo(s) com 2 a 100 caracteres + schema: {type: string, minLength: 2, maxLength: 100} + - name: status + in: query + required: false + schema: {type: string, enum: [rascunho, ativo, pronto, concluido, arquivado]} + - name: tecnologia + in: query + required: false + description: ID da tecnologia associada ao próprio item + schema: {type: string, format: uuid} + - name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 100, default: 50} responses: '200': - description: Lista ordenada da entidade após a movimentação + description: Resultados ordenados (título antes de descrição, depois hierarquia) content: application/json: schema: - type: object - required: [items] - properties: - items: - type: array - items: - $ref: '#/components/schemas/CriterionResponse' + $ref: '#/components/schemas/BacklogSearchResponse' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' - '403': - description: Perfil sem permissão de escrita '404': - description: Critério não encontrado + description: Projeto não encontrado /api/v1/projects/{projectId}/documents: parameters: @@ -1375,7 +2053,7 @@ paths: '502': description: Ollama indisponível - /epics/{id}/archive-impact: + /api/v1/epics/{id}/archive-impact: get: summary: Prévia do arquivamento de epics (S1-09) security: [{cookieAuth: []}] @@ -1388,7 +2066,7 @@ paths: application/json: schema: {$ref: '#/components/schemas/HierarchyArchiveImpact'} '404': {description: Item não encontrado} - /epics/{id}/archive: + /api/v1/epics/{id}/archive: patch: summary: Arquiva epics e descendentes com auditoria (S1-09) description: Exclusivo de PO/admin. Não apaga conteúdo. Repetições preservam data e auditoria. Conflito exige nova prévia. @@ -1413,7 +2091,7 @@ paths: '404': {description: Item não encontrado} '409': {description: Impacto alterado ou ancestral somente leitura} - /features/{id}/archive-impact: + /api/v1/features/{id}/archive-impact: get: summary: Prévia do arquivamento de features (S1-09) security: [{cookieAuth: []}] @@ -1426,7 +2104,7 @@ paths: application/json: schema: {$ref: '#/components/schemas/HierarchyArchiveImpact'} '404': {description: Item não encontrado} - /features/{id}/archive: + /api/v1/features/{id}/archive: patch: summary: Arquiva features e descendentes com auditoria (S1-09) description: Exclusivo de PO/admin. Não apaga conteúdo. Repetições preservam data e auditoria. Conflito exige nova prévia. @@ -1451,7 +2129,7 @@ paths: '404': {description: Item não encontrado} '409': {description: Impacto alterado ou ancestral somente leitura} - /pbis/{id}/archive-impact: + /api/v1/pbis/{id}/archive-impact: get: summary: Prévia do arquivamento de pbis (S1-09) security: [{cookieAuth: []}] @@ -1464,7 +2142,7 @@ paths: application/json: schema: {$ref: '#/components/schemas/HierarchyArchiveImpact'} '404': {description: Item não encontrado} - /pbis/{id}/archive: + /api/v1/pbis/{id}/archive: patch: summary: Arquiva pbis e descendentes com auditoria (S1-09) description: Exclusivo de PO/admin. Não apaga conteúdo. Repetições preservam data e auditoria. Conflito exige nova prévia. @@ -2368,6 +3046,118 @@ components: id: { type: string, format: uuid } nome: { type: string } + DecisionNode: + type: object + required: [tipo, id, titulo] + properties: + tipo: {type: string, enum: [projeto, epico, feature, pbi]} + id: {type: string, format: uuid} + titulo: {type: string} + codigo: {type: string, nullable: true} + + DecisionInput: + type: object + additionalProperties: false + required: [titulo, contexto, decisao, justificativa] + properties: + titulo: {type: string, minLength: 3, maxLength: 255} + contexto: {type: string, minLength: 1, maxLength: 5000} + decisao: {type: string, minLength: 1, maxLength: 5000} + justificativa: {type: string, minLength: 1, maxLength: 5000} + alternativas: {type: string, nullable: true, maxLength: 5000} + + Decision: + type: object + required: [id, titulo, contexto, decisao, justificativa, created_at, origem] + properties: + id: {type: string, format: uuid} + titulo: {type: string} + contexto: {type: string} + decisao: {type: string} + justificativa: {type: string} + alternativas: {type: string, nullable: true} + autor: + type: object + nullable: true + properties: + id: {type: string, format: uuid} + nome: {type: string} + created_at: {type: string, format: date-time} + origem: + allOf: + - $ref: '#/components/schemas/DecisionNode' + - type: object + required: [herdada] + properties: + herdada: {type: boolean} + + DecisionList: + type: object + required: [entidade, ancestrais, decisoes] + properties: + entidade: + $ref: '#/components/schemas/DecisionNode' + ancestrais: + type: array + items: + $ref: '#/components/schemas/DecisionNode' + decisoes: + type: array + items: + $ref: '#/components/schemas/Decision' + + BacklogSearchResponse: + type: object + required: [projeto_id, termo, total, limite, items] + properties: + projeto_id: {type: string, format: uuid} + termo: {type: string} + total: {type: integer, minimum: 0, description: Total de correspondências (pode exceder items)} + limite: {type: integer} + items: + type: array + items: + type: object + required: [tipo, id, titulo, status, campo, trecho, caminho, tecnologias] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + id: {type: string, format: uuid} + titulo: {type: string} + codigo: {type: string, nullable: true} + status: {type: string} + campo: {type: string, enum: [titulo, descricao]} + trecho: + type: object + required: [texto, destaques] + properties: + texto: {type: string} + destaques: + type: array + description: Faixas [inicio, fim) em unidades UTF-16 de `texto` + items: + type: array + minItems: 2 + maxItems: 2 + items: {type: integer} + caminho: + type: array + description: Épico → Feature → PBI até o nível do item + items: + type: object + required: [tipo, id, titulo] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + id: {type: string, format: uuid} + titulo: {type: string} + codigo: {type: string, nullable: true} + tecnologias: + type: array + items: + type: object + properties: + id: {type: string, format: uuid} + nome: {type: string} + ProjectDocument: type: object required: [id, projeto_id, nome, status_processamento, created_at] diff --git a/docs/qa/S1-23-matriz-cenarios.md b/docs/qa/S1-23-matriz-cenarios.md new file mode 100644 index 0000000..442b272 --- /dev/null +++ b/docs/qa/S1-23-matriz-cenarios.md @@ -0,0 +1,51 @@ +# S1-23 — Matriz de cenários dos fluxos Must da Sprint 1 + +Escopo: PBIs Must entregues até a S1-22 (mais S1-16, S1-17 e S1-18). Evidência = teste automatizado reproduzível +(`backend`, `frontend`, `e2e`). Resultado = última execução registrada em `docs/qa/S1-23-roteiro-review.md`. +"Responsável" é a área que entregou; nomes individuais ficam no quadro da sprint, não neste repositório. + +Legenda de evidência: **B** = testes de backend (unidade/rotas), **DB** = testes com PostgreSQL real, **F** = testes de +frontend, **E2E** = navegador + API real + PostgreSQL (`e2e/tests/…`). + +| PBI | Fluxo / cenário | Área | Evidência | Resultado | +|---|---|---|---|---| +| 01.1.1 Cadastrar projeto | Criar com sucesso, validar obrigatórios, nome duplicado (409), perfil dev bloqueado | Backend + Frontend | B `projects.service.test`, `projects.routes.test`; E2E `flows` "Projetos: formulário valida…" (a tela ativa é `ProjectsView`) | Aprovado | +| 01.1.2 Cadastrar épico | Criar por API e pela interface, título obrigatório, bloqueio em projeto arquivado | Backend + Frontend | B `epics.service.test`, `epics.routes.test`; E2E `hierarchy-ui` "Épico → feature → PBI…" | Aprovado | +| 01.1.3 Cadastrar feature | Criar sob épico, herança de projeto arquivado | Backend + Frontend | B `features.service.test`, `features.routes.test`; E2E `hierarchy-ui` | Aprovado | +| 01.1.4 Cadastrar PBI | Título com verbo, história obrigatória, código sequencial | Backend + Frontend | B `pbis.service.test`, `pbis.routes.test`; E2E `hierarchy-ui` | Aprovado | +| 01.1.5 Editar item | Edição, justificativa de item concluído (S1-24), histórico | Backend + Frontend | B `audit.justificativa.test`, `completed-item-policy.test`; DB `hierarchy-archive.db.test`; F `Justificativa.test` | Aprovado | +| 01.2.1 / 01.2.2 Critérios de épico e feature | Criar, ordenar, remover, texto obrigatório | Backend + Frontend | B `criteria.service.test`, `criteria.routes.test`; F `Justificativa.test` | Aprovado | +| 01.2.3 Cenário de PBI | DADO/QUANDO/ENTÃO obrigatórios; cenário melhora a completude | Backend + Frontend | B `criteria.service.test`; E2E `flows` "Hierarquia, critérios e qualidade…", `hierarchy-ui` | Aprovado | +| 01.3.1 / 01.3.2 / 01.3.3 Validações do PBI | Título infinitivo, história completa, formato dos cenários | Backend + Frontend | B `quality.rules.test`, `quality.service.test`, `quality.routes.test` | Aprovado | +| 01.3.5 Checklist de qualidade | Pontuação determinística e painel em tempo real | Backend + Frontend | B `quality.service.test`; E2E `flows` (score sobe com cenário), `hierarchy-ui` (painel) | Aprovado | +| 01.4.1 Navegar a árvore | Árvore expansível, breadcrumb navegável, estado vazio | Frontend + Backend | DB `projects.backlog-tree.db.test`; F `Hierarchy.test`; E2E `flows` "Hierarquia…" | Aprovado | +| 01.4.2 Filtrar e localizar (S1-16 + S1-17) | Filtros status/tecnologia persistidos; busca por título/descrição com trecho e caminho; interseção; isolamento; vazio com limpar; persistência na sessão | Frontend + Backend + Banco | B `backlog-search.*.test`; DB `backlog-search.db.test`; F `BacklogSearch.test`, `Hierarchy.test`; E2E `flows` "Busca S1-17…" | Aprovado | +| 01.5.1 Registrar decisão (S1-18) | Contexto, decisão, justificativa, alternativas, autor/data, qualquer nível; arquivado 409; dev 403 | Backend + Frontend + Banco | B `decisions.service.test`, `decisions.routes.test`; DB `decisions.db.test`, `migration-013.db.test`; F `DecisionsPanel.test`; E2E `flows` "Decisões S1-18…" | Aprovado | +| 01.5.2 Consultar decisões | Ordem cronológica, herança de projeto/épico/feature, descendentes não aparecem | Backend + Frontend | idem 01.5.1 | Aprovado | +| 02.1.1 Anexar documento (S1-19) | Formatos, tipo real, limite, isolamento, falha sem resíduo, auditoria, 409 arquivado | Backend + Segurança | B `documents.validation.test`, `documents.service.test`, `documents.routes.test`, `documents.storage.test`; DB `documents.repository.db.test`, `migration-012.db.test`; E2E `documents` | Aprovado | +| 02.1.2 Consultar documentos (S1-20) | Lista por projeto, estados, cursor, atualização | Frontend + Backend | F `DocumentsView.test`, `api_documents.test`; E2E `documents` | Aprovado | +| (S1-22) Remoção confirmada | Confirmação, idempotência, evento/outbox, consistência com o arquivo | Backend | B `documents.service.test`; DB `documents.repository.db.test` (lease, backoff, corrida com arquivamento); E2E `documents` | Aprovado | +| 06.1.1 / 06.1.2 / 06.1.3 Autenticação e perfis | Login, sessão, expiração, papéis, retorno seguro; cadastro público não cria admin | Backend + Frontend | B `auth.routes.test`, `auth.register-role.test`, `session.service.test`, `requireAuth.test`, `requireRole.test`; F `Auth.test`; E2E `flows` "Autorização e isolamento…", `assistants` | Aprovado | +| Transversal: autorização | Todo endpoint documentado como protegido responde 401 sem sessão; públicos são login, registro, raiz e health | Backend | B `openapi.contract.test` | Aprovado | +| Transversal: isolamento por projeto | Busca, documentos, decisões, análises e chat nunca cruzam projetos ou usuários | Backend + E2E | DB `backlog-search.db.test`, `decisions.db.test`, `documents.repository.db.test`, `chat.repository.db.test`; E2E `documents`, `flows`, `assistants` | Aprovado | +| Transversal: OpenAPI x entrega | Documentado ⊆ implementado e implementado ⊆ documentado (aliases `/api/*` equivalem a `/api/v1/*`) | Documentação + Backend | B `openapi.contract.test` | Aprovado | +| Transversal: acessibilidade básica | axe (WCAG 2 A/AA) sem violação crítica/séria em 10 telas; foco visível; erros anunciados; mobile sem overflow | Frontend | E2E `a11y`, `documents`, `assistants` | Aprovado | + +## Cobertura por camada de estados de erro + +| Estado | Onde é provado | +|---|---| +| 400 validação | rotas de cada módulo + E2E `flows`, `documents` | +| 401 sem sessão | `openapi.contract.test` (todas as rotas protegidas) + E2E | +| 403 perfil sem permissão | `requireRole.test` + E2E `documents`, `flows` (dev) | +| 404 recurso/projeto inexistente ou de outro usuário | rotas + E2E `assistants` (conversa alheia) | +| 409 projeto/item arquivado | DB (corridas) + E2E `documents`, `flows`, `hierarchy-ui` | +| 413 arquivo acima do limite | `documents.validation.test` + E2E `documents` | +| 503 dependência indisponível (armazenamento, motor de IA) | `documents.service.test`, `repo-analyses.routes.test` + E2E `assistants` | + +## Lacunas conhecidas (não escondidas) + +1. Várias suítes de frontend anteriores (`Epics.test`, `Features` em `Epics.test`, `QualityPanel.test`, `ItemArchive.test`, `Projects.test`, `ProjectArchive.test`) exercitam **componentes legados** em `frontend/src/backlog/*` e `frontend/src/projects/Projects.tsx`, que a aplicação não importa mais (as telas ativas estão em `frontend/src/views/*`). As telas ativas de épico/feature/PBI/qualidade/arquivamento são provadas pelos cenários `hierarchy-ui`, `flows` e `a11y`. Recomendação: migrar ou remover os componentes e testes legados em tarefa própria. +2. Não há cenário E2E de administrador (o cadastro público não cria admin e o ambiente de teste não provisiona um); o perfil é coberto por testes de rota (`requireRole.test`, `auth.register-role.test`). +3. Ingestão e indexação de documentos são S2-01; a matriz cobre apenas o armazenamento, listagem e remoção. +4. O serviço de IA não é exercitado; os cenários provam o comportamento com ele indisponível. diff --git a/docs/qa/S1-23-roteiro-review.md b/docs/qa/S1-23-roteiro-review.md new file mode 100644 index 0000000..6395ac4 --- /dev/null +++ b/docs/qa/S1-23-roteiro-review.md @@ -0,0 +1,86 @@ +# S1-23 — Roteiro de review e registro de execução + +## Ambiente da execução registrada + +- Data: 2026-09-25. +- Windows 11, Node 24, Google Chrome estável. +- PostgreSQL 18 embutido (`embedded-postgres`), banco descartável `*_test`. Como o binário não traz pgvector, a coluna + `chunk.embedding` e o índice HNSW foram substituídos por `real[]`/omitidos **somente nesta execução local**; o CI usa + `pgvector/pgvector:pg16` com o schema original. +- Backend real (`tsx src/index.ts`) e frontend Vite reais; serviço de IA e n8n **não** executados. + +## Comandos + +```bash +# 1. Backend: build, unidade, rotas, contrato OpenAPI e testes com PostgreSQL +cd backend +npm ci && npm run build +ARCHIVE_TEST_DATABASE_URL=postgresql://USER:PASS@localhost:5432/sinapse_x_test \ +BACKLOG_TREE_TEST_DATABASE_URL=$ARCHIVE_TEST_DATABASE_URL \ +SEED_TEST_DATABASE_URL=$ARCHIVE_TEST_DATABASE_URL npm test +npm run test:seed + +# 2. Frontend +cd ../frontend && npm ci && npx tsc -b && npm test && npm run build + +# 3. E2E de navegador (banco migrado, backend em :3001 e frontend em :5173 em execução) +cd ../e2e && npm ci && E2E_CHROME_PATH="" npm test +``` + +No GitHub: workflow **E2E (navegador)** (`workflow_dispatch`) executa o passo 3; o `ci.yml` executa os passos 1 e 2 e os +testes de PostgreSQL (`validate-seed`). + +## Resultado registrado + +| Verificação | Resultado | +|---|---| +| `backend` `npm run build` (tsc) | Aprovado | +| `backend` `npm test` com PostgreSQL | **318 aprovados, 0 falhas, 0 ignorados** | +| `backend` `npm run test:seed` / `seed:validate` | 6 aprovados / manifesto válido (15 registros) | +| `frontend` `tsc -b` e `npm run build` | Aprovados | +| `frontend` `npm test` | **192 aprovados** (27 arquivos) | +| `e2e` `npm test` | **22 aprovados** (documentos 7, assistentes 5, fluxos 6, hierarquia pela UI 2, acessibilidade 2) | +| axe-core (WCAG 2 A/AA) em login, projetos, projeto (5 abas), PBI, chat, conhecimento | 0 violações críticas ou sérias | +| `openapi.contract.test` | 4 aprovados: documentado ⊆ implementado, implementado ⊆ documentado, protegidos respondem 401, públicos são 4 | +| Migrations 001–013 em banco limpo e 012/013 sobre banco existente | Aprovadas, idempotentes, dados legados preservados | + +## Achados corrigidos durante a S1-23 + +| Achado | Gravidade | Correção | +|---|---|---| +| Cadastro público aceitava `role: "admin"` | Crítica | Administrador só com sessão de administrador (403); opção removida do formulário | +| Chat: leitura e escrita em conversa de outro usuário; fallback retornava trechos de todos os projetos sem relação com a pergunta | Alta | Posse da conversa (404), escopo por projeto, busca textual honesta com `origem` | +| OpenAPI: rotas de arquivamento sem `/api/v1`; 20+ endpoints entregues sem documentação | Média | Corrigido e coberto por teste de contrato | +| Contraste insuficiente (texto branco sobre laranja, cinza `--dim`) em todas as telas | Média (WCAG AA) | Botão primário com texto escuro (padrão do protótipo) e `--dim` elevado | +| `/api/v1/search` retornava 500 para `projeto_id` inválido e tratava `%`/`_` como curingas | Baixa | Validação de UUID (400) e escape | +| RepoAnalyzer API: UUID inválido → 500, projeto inexistente/arquivado sem tratamento | Média | 400/404/409/503 padronizados | +| Cabeçalho e abas transbordavam em 390 px | Baixa | Responsivo corrigido; E2E valida sem overflow | + +## Checklist de revisão manual (a executar no review) + +Teclado (sem mouse): login → projetos → projeto → abas por setas/Home/End → documentos (Escolher arquivo, Remover, Esc fecha o +diálogo) → backlog (busca, filtros, expandir) → PBI (Novo cenário, Registrar decisão) → chat (Enter envia, Shift+Enter quebra). +Conferir foco visível, rótulos lidos por leitor de tela e mensagens de erro anunciadas. + +Perfis: repetir os fluxos com `po` e `dev`; `dev` deve consultar e nunca ver ações de escrita. + +## Limitações declaradas + +- O E2E usa PostgreSQL sem pgvector e sem serviço de IA/n8n; busca semântica e RAG (Sprint 2) estão fora do escopo. +- O workflow E2E do GitHub é manual e ainda não foi executado no GitHub Actions (somente localmente). +- Contraste foi verificado por axe em estado padrão; estados `hover` e telas de erro raras não foram varridos. +- Migrar/remover os componentes legados descritos em `S1-23-matriz-cenarios.md` fica como pendência. +- Não há teste de carga nem de segurança externa (pentest). Riscos residuais conhecidos: `cors()` aberto (ajustar origens em + produção), ausência de cabeçalhos de segurança (helmet), token de sessão em `localStorage` (mitigado por expiração e escopo). + +## Revisão independente + +**Estado: pendente.** Este documento e a execução acima foram produzidos pelo autor. A aprovação independente deve ser +registrada abaixo por QA/outra pessoa, com um dos resultados: + +- [ ] Aprovado sem pendências +- [ ] Aprovado com pendências objetivas (listar) +- [ ] Reprovado (listar bloqueios) + +Pendências objetivas já conhecidas para o revisor considerar: (1) executar o workflow E2E no GitHub; (2) validar o consumidor +n8n de `document.removed`; (3) decidir o destino dos componentes legados do frontend; (4) endurecer CORS/cabeçalhos antes de produção. diff --git a/e2e/README.md b/e2e/README.md index e831f9c..d7d37e8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -10,15 +10,26 @@ Não substituem os testes de unidade de cada módulo. - Frontend em `http://localhost:5173` (`cd frontend && npm run dev`). - Google Chrome instalado (ou `E2E_CHROME_PATH` apontando para um executável Chromium). - O serviço de IA **não** é necessário: os testes verificam o comportamento quando ele está indisponível. -- Opcional: `E2E_ADMIN_EMAIL` e `E2E_ADMIN_PASSWORD` para os cenários de administrador (o cadastro público não cria admin). + +## Suítes + +| Arquivo | Cobre | +|---|---| +| `tests/documents.e2e.mjs` | Upload/lista/remoção, perfis, arquivado, isolamento, limite, cursor, abas | +| `tests/assistants.e2e.mjs` | RepoAnalyzer, chat, cadastro público sem admin | +| `tests/flows.e2e.mjs` | Projetos, arquivamento, hierarquia, critérios/qualidade, busca S1-17, decisões S1-18, autorização | +| `tests/hierarchy-ui.e2e.mjs` | Épico → feature → PBI e arquivamento pelas telas ativas | +| `tests/a11y.e2e.mjs` | axe (WCAG 2 A/AA) em 10 telas e foco visível por teclado | + +No GitHub há o workflow manual **E2E (navegador)** (`.github/workflows/e2e.yml`). ## Execução ```bash cd e2e npm ci -npm test # todos os cenários -node --test tests/documents.e2e.mjs +npm test # todos os cenários +node --test tests/documents.e2e.mjs # uma suíte ``` Variáveis: `E2E_API_URL`, `E2E_APP_URL`, `E2E_CHROME_PATH`, `E2E_TIMEOUT_MS`. diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b71b9ef..57f0ae3 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -7,10 +7,22 @@ "": { "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", diff --git a/e2e/package.json b/e2e/package.json index 329f81d..b4dd7e4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,9 +5,12 @@ "description": "Testes E2E de navegador (API real + PostgreSQL + frontend)", "type": "module", "scripts": { - "test": "node --test --test-concurrency=1 ./tests/" + "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..651796b --- /dev/null +++ b/e2e/run.mjs @@ -0,0 +1,8 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +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/support.mjs b/e2e/support.mjs index 05854f0..0a84966 100644 --- a/e2e/support.mjs +++ b/e2e/support.mjs @@ -84,3 +84,49 @@ 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" } }); + 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" } }); + 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/a11y.e2e.mjs b/e2e/tests/a11y.e2e.mjs new file mode 100644 index 0000000..7c86a2a --- /dev/null +++ b/e2e/tests/a11y.e2e.mjs @@ -0,0 +1,96 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { APP, addScenario, api, closeBrowser, createHierarchy, createProject, createUser, formatViolations, getBrowser, openPage, runAxe, uploadRaw, pdfContent } from "../support.mjs"; + +after(() => closeBrowser()); + +async function scan(page, label, prepare) { + if (prepare) await prepare(); + await page.waitForTimeout(500); + return runAxe(page, label); +} + +test("axe (WCAG 2 A/AA): nenhuma violação crítica ou séria nas telas principais", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Acessibilidade"); + const { epic, feature, pbi } = await createHierarchy(po, project); + await addScenario(po, pbi.id); + await uploadRaw(po, project.id, "escopo.pdf", pdfContent()); + await api(`/projects/${project.id}/decisions`, { method: "POST", token: po.token, body: { titulo: "Decisão de exemplo", contexto: "c", decisao: "d", justificativa: "j" } }); + + const reports = []; + const anonymous = await (await getBrowser()).newContext({ viewport: { width: 1280, height: 800 } }); + const loginPage = await anonymous.newPage(); + await loginPage.goto(`${APP}/login`); + await loginPage.getByRole("button", { name: /Entrar/ }).first().waitFor(); + reports.push(await runAxe(loginPage, "login")); + await anonymous.close(); + + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects`); + reports.push(await scan(page, "lista de projetos", () => page.getByText(project.nome).first().waitFor())); + + await page.goto(`${APP}/projects/${project.id}`); + reports.push(await scan(page, "projeto: visão geral", () => page.getByRole("tab", { name: "Visão geral" }).waitFor())); + const ready = { + Backlog: () => page.getByRole("list", { name: "Hierarquia do backlog" }).waitFor(), + Decisões: () => page.getByText("Decisão de exemplo").first().waitFor(), + Documentos: () => page.getByText("escopo.pdf").first().waitFor(), + "Análise de repositório": () => page.getByLabel(/URL do repositório/).waitFor(), + }; + for (const [tab, wait] of Object.entries(ready)) { + await page.getByRole("tab", { name: tab }).click(); + reports.push(await scan(page, `projeto: ${tab}`, wait)); + } + + await page.goto(`${APP}/projects/${project.id}/epics/${epic.id}/features/${feature.id}/pbis/${pbi.id}`); + reports.push(await scan(page, "PBI: detalhe", () => page.getByText("Login válido").first().waitFor())); + await page.goto(`${APP}/chat`); + reports.push(await scan(page, "chat", () => page.getByLabel("Sua pergunta").waitFor())); + await page.goto(`${APP}/knowledge`); + reports.push(await scan(page, "conhecimento", () => page.waitForTimeout(300))); + } finally { + await context.close(); + } + + const report = reports.map(formatViolations).filter(Boolean).join("\n"); + if (process.env.E2E_A11Y_VERBOSE) { + for (const item of reports) console.log(`# ${item.label}: ${item.all.length} violação(ões) total, ${item.blocking.length} bloqueante(s)`); + } + assert.equal(report, "", `Violações de acessibilidade:\n${report}`); +}); + +test("foco visível e operação por teclado nos controles críticos", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Teclado"); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}#documents`); + await page.getByRole("tab", { name: "Documentos" }).waitFor(); + const controls = [page.getByRole("tab", { name: "Backlog" }), page.getByRole("button", { name: "Escolher arquivo" }), page.getByRole("button", { name: "Atualizar" })]; + for (const control of controls) { + await page.keyboard.press("Shift"); + await control.focus(); + const style = await control.evaluate((element) => { + const computed = getComputedStyle(element); + return { visible: element.matches(":focus-visible"), outline: computed.outlineStyle, width: parseFloat(computed.outlineWidth), shadow: computed.boxShadow }; + }); + assert.ok(style.visible, "o controle deve receber :focus-visible por teclado"); + assert.ok((style.outline !== "none" && style.width > 0) || style.shadow !== "none", `sem indicador de foco visível: ${JSON.stringify(style)}`); + } + + await page.goto(`${APP}/projects/new`); + await page.getByLabel("Nome do projeto").focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + const alerts = await page.getByRole("alert").allTextContents(); + assert.ok(alerts.some((text) => /nome|cliente/i.test(text)), "erro compreensível anunciado como alerta"); + const focused = await page.evaluate(() => document.activeElement?.getAttribute("name")); + assert.equal(focused, "nome", "foco vai ao primeiro campo inválido"); + } finally { + await context.close(); + } +}); diff --git a/e2e/tests/documents.e2e.mjs b/e2e/tests/documents.e2e.mjs index 58d5f42..dd56471 100644 --- a/e2e/tests/documents.e2e.mjs +++ b/e2e/tests/documents.e2e.mjs @@ -145,6 +145,7 @@ test("abas do projeto funcionam por teclado e mobile não transborda", async () await page.getByRole("tab", { name: "Visão geral" }).focus(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); await expect(page.getByRole("tab", { name: "Documentos" })).toHaveAttribute("aria-selected", "true"); assert.match(page.url(), /#documents$/); await assertNoHorizontalOverflow(page, "documentos mobile"); diff --git a/e2e/tests/flows.e2e.mjs b/e2e/tests/flows.e2e.mjs new file mode 100644 index 0000000..fdbaa30 --- /dev/null +++ b/e2e/tests/flows.e2e.mjs @@ -0,0 +1,196 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { expect } from "./expect.mjs"; +import { APP, addScenario, api, archiveProject, closeBrowser, createHierarchy, createProject, createUser, openPage } from "../support.mjs"; + +after(() => closeBrowser()); + +test("Projetos: formulário valida, cria, recusa duplicidade e perfil dev não cria", async () => { + const po = await createUser("po"); + const dev = await createUser("dev"); + const name = `Projeto E2E ${Date.now()}`; + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/new`); + await page.getByRole("button", { name: "Criar projeto" }).click(); + await expect(page.getByText("Informe o nome do projeto.")).toBeVisible(); + await expect(page.getByText("Informe o cliente.")).toBeVisible(); + + await page.getByLabel("Nome do projeto").fill(name); + await page.getByLabel("Cliente").fill("Cliente E2E"); + await page.getByRole("button", { name: "Criar projeto" }).click(); + await expect(page.getByRole("heading", { name })).toBeVisible(); + + await page.goto(`${APP}/projects/new`); + await page.getByLabel("Nome do projeto").fill(name); + await page.getByLabel("Cliente").fill("Cliente E2E"); + await page.getByRole("button", { name: "Criar projeto" }).click(); + await expect(page.getByText("Este nome já está em uso por um projeto ativo.")).toBeVisible(); + } finally { + await context.close(); + } + + const denied = await openPage(dev); + try { + await denied.page.goto(`${APP}/projects/new`); + await expect(denied.page.getByText("Seu perfil não permite criar projetos.")).toBeVisible(); + } finally { + await denied.context.close(); + } + assert.equal((await api("/projects", { method: "POST", token: dev.token, body: { nome: "Negado", cliente: "X" } })).status, 403); + assert.equal((await api("/projects")).status, 401); +}); + +test("Projeto: arquivamento com prévia deixa o projeto somente leitura em todas as áreas", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Arquivar"); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}`); + await page.getByRole("button", { name: "Arquivar projeto" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByRole("button", { name: "Confirmar arquivamento" }).click(); + await expect(page.getByText(/Somente leitura/)).toBeVisible(); + } finally { + await context.close(); + } + assert.equal((await api("/epics", { method: "POST", token: po.token, body: { projeto_id: project.id, titulo: "Não deve criar" } })).status >= 400, true); + const decision = await api(`/projects/${project.id}/decisions`, { method: "POST", token: po.token, body: { titulo: "Bloqueada", contexto: "c", decisao: "d", justificativa: "j" } }); + assert.equal(decision.status, 409); +}); + +test("Hierarquia, critérios e qualidade: navegar da árvore ao PBI, breadcrumb e pontuação", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Hierarquia"); + const { epic, feature, pbi } = await createHierarchy(po, project); + const before = await api(`/quality/pbis/${pbi.id}/quality`, { token: po.token }); + assert.equal(before.status, 200); + const scenario = await addScenario(po, pbi.id); + assert.equal(scenario.status, 201); + const after = await api(`/quality/pbis/${pbi.id}/quality`, { token: po.token }); + assert.ok(after.json.score_completude > before.json.score_completude, "o cenário estruturado aumenta a pontuação de completude"); + assert.ok(after.json.checks.find((check) => check.check_id === "cenario_estruturado")?.passed); + const scenarios = await api(`/criteria?entidade_tipo=pbi&entidade_id=${pbi.id}`, { token: po.token }); + assert.equal(scenarios.json.items.length, 1); + + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}#backlog`); + await page.getByRole("button", { name: `Expandir épico ${epic.titulo}` }).click(); + await page.getByRole("button", { name: `Expandir feature ${feature.titulo}` }).click(); + await expect(page.getByText(pbi.titulo)).toBeVisible(); + await page.goto(`${APP}/projects/${project.id}/epics/${epic.id}/features/${feature.id}/pbis/${pbi.id}`); + await expect(page.getByRole("navigation", { name: /Breadcrumb|Caminho|Você está em/i })).toBeVisible(); + await expect(page.getByText("Login válido")).toBeVisible(); + } finally { + await context.close(); + } +}); + +test("Busca S1-17: título, descrição, filtros combinados, isolamento, vazio e persistência", async () => { + const po = await createUser("po"); + const projectA = await createProject(po, "Busca A"); + const projectB = await createProject(po, "Busca B"); + const a = await createHierarchy(po, projectA, { pbi: "Recuperar senha por e-mail" }); + await createHierarchy(po, projectB, { epic: "Épico de autenticação", feature: "Sessão do usuário", pbi: "Recuperar senha por e-mail" }); + + const api1 = await api(`/projects/${projectA.id}/backlog-search?q=${encodeURIComponent("autenticacao")}`, { token: po.token }); + assert.equal(api1.status, 200); + assert.ok(api1.json.items.some((item) => item.id === a.epic.id)); + assert.ok(api1.json.items.every((item) => item.caminho[0].id === a.epic.id), "somente itens do projeto A"); + const byDescription = await api(`/projects/${projectA.id}/backlog-search?q=${encodeURIComponent("acessar meus projetos")}`, { token: po.token }); + assert.deepEqual(byDescription.json.items.map((item) => item.id), [a.pbi.id]); + assert.equal(byDescription.json.items[0].campo, "descricao"); + + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${projectA.id}#backlog`); + await page.getByLabel(/Buscar no backlog/).fill("recuperar senha"); + const results = page.getByRole("region", { name: "Resultados da busca no backlog" }); + await expect(results).toBeVisible(); + await expect(results.getByText("Recuperar", { exact: false }).first()).toBeVisible(); + await expect(results.getByRole("navigation", { name: /Caminho de/ })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole("region", { name: "Resultados da busca no backlog" })).toBeVisible(); + assert.equal(await page.getByLabel(/Buscar no backlog/).inputValue(), "recuperar senha"); + + await page.getByLabel(/Buscar no backlog/).fill("inexistenteabc"); + await expect(page.getByText("Nenhum item encontrado")).toBeVisible(); + await page.getByRole("button", { name: "Limpar critérios" }).click(); + await expect(page.getByRole("list", { name: "Hierarquia do backlog" })).toBeVisible(); + assert.equal(await page.getByLabel(/Buscar no backlog/).inputValue(), ""); + } finally { + await context.close(); + } +}); + +test("Decisões S1-18: registrar no PBI, ver herança do projeto e restrições por perfil e arquivamento", async () => { + const po = await createUser("po"); + const dev = await createUser("dev"); + const project = await createProject(po, "Decisões"); + const { epic, feature, pbi } = await createHierarchy(po, project); + const inherited = await api(`/projects/${project.id}/decisions`, { method: "POST", token: po.token, body: { titulo: "Stack única em TypeScript", contexto: "Equipe pequena", decisao: "Usar TypeScript em todo o código", justificativa: "Reduz troca de contexto", alternativas: "Python no backend" } }); + assert.equal(inherited.status, 201); + + const url = `${APP}/projects/${project.id}/epics/${epic.id}/features/${feature.id}/pbis/${pbi.id}`; + const { page, context } = await openPage(po); + try { + await page.goto(url); + await expect(page.getByText("Herdadas dos níveis acima (1)")).toBeVisible(); + await expect(page.getByText(/Herdada · Projeto/)).toBeVisible(); + await page.getByRole("button", { name: "Registrar decisão" }).click(); + await page.getByRole("button", { name: "Registrar decisão" }).last().click(); + await expect(page.getByText("Informe um título com ao menos 3 caracteres.")).toBeVisible(); + await page.getByLabel("Título").fill("Hash de senha com scrypt"); + await page.getByLabel("Contexto").fill("Senhas precisam de proteção"); + await page.getByLabel(/^Decisão$/).fill("Usar scrypt com salt por usuário"); + await page.getByLabel("Justificativa").fill("Resistente a ataques de hardware"); + await page.getByLabel(/Alternativas/).fill("bcrypt"); + await page.getByRole("button", { name: "Registrar decisão" }).last().click(); + await expect(page.getByText("Decisão registrada com autor e data.")).toBeVisible(); + await expect(page.getByText("Neste item (1)")).toBeVisible(); + } finally { + await context.close(); + } + const listed = await api(`/pbis/${pbi.id}/decisions`, { token: po.token }); + assert.deepEqual(listed.json.decisoes.map((item) => [item.titulo, item.origem.herdada]), [["Stack única em TypeScript", true], ["Hash de senha com scrypt", false]]); + assert.ok(listed.json.decisoes.every((item) => item.autor?.nome && item.created_at)); + assert.equal((await api(`/epics/${epic.id}/decisions`, { token: po.token })).json.decisoes.length, 1, "épico não vê decisão do descendente"); + + const readOnly = await openPage(dev); + try { + await readOnly.page.goto(url); + await expect(readOnly.page.getByText("Neste item (1)")).toBeVisible(); + assert.equal(await readOnly.page.getByRole("button", { name: "Registrar decisão" }).count(), 0); + } finally { + await readOnly.context.close(); + } + assert.equal((await api(`/pbis/${pbi.id}/decisions`, { method: "POST", token: dev.token, body: { titulo: "Negada", contexto: "c", decisao: "d", justificativa: "j" } })).status, 403); + assert.equal((await api(`/pbis/${pbi.id}/decisions`)).status, 401); + assert.equal((await api(`/pbis/00000000-0000-4000-8000-000000000000/decisions`, { token: po.token })).status, 404); + + await archiveProject(po, project); + assert.equal((await api(`/pbis/${pbi.id}/decisions`, { method: "POST", token: po.token, body: { titulo: "Após arquivar", contexto: "c", decisao: "d", justificativa: "j" } })).status, 409); + assert.equal((await api(`/pbis/${pbi.id}/decisions`, { token: po.token })).status, 200); +}); + +test("Autorização e isolamento: perfis, sessão e itens de outro projeto", async () => { + const po = await createUser("po"); + const other = await createUser("po"); + const dev = await createUser("dev"); + const project = await createProject(po, "Isolado"); + const foreign = await createProject(other, "Alheio"); + const { pbi } = await createHierarchy(other, foreign); + + assert.equal((await api("/epics", { method: "POST", token: dev.token, body: { projeto_id: project.id, titulo: "Negado" } })).status, 403); + assert.equal((await api(`/projects/${project.id}/backlog-tree`)).status, 401); + assert.equal((await api(`/projects/${project.id}/backlog-search?q=recuperar`, { token: po.token })).json.items.length, 0); + const cross = await api(`/projects/${project.id}/backlog-search?q=${encodeURIComponent("credenciais")}`, { token: po.token }); + assert.ok(!cross.json.items.some((item) => item.id === pbi.id), "item do outro projeto nunca aparece na busca deste"); + assert.equal((await api("/search?projeto_id=nao-uuid", { token: po.token })).status, 400); + assert.equal((await api("/search?q=teste", { token: po.token })).status, 200); + assert.equal((await api("/auth/me")).status, 401); + assert.equal((await api("/auth/me", { token: "token-invalido" })).status, 401); + assert.equal((await api("/admin/stats", { token: dev.token })).status === 200, false); +}); diff --git a/e2e/tests/hierarchy-ui.e2e.mjs b/e2e/tests/hierarchy-ui.e2e.mjs new file mode 100644 index 0000000..72a8730 --- /dev/null +++ b/e2e/tests/hierarchy-ui.e2e.mjs @@ -0,0 +1,78 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { expect } from "./expect.mjs"; +import { APP, api, closeBrowser, createProject, createUser, openPage } from "../support.mjs"; + +after(() => closeBrowser()); + +test("Épico → feature → PBI pela interface: validação, qualidade em tempo real, cenário e conclusão", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Hierarquia UI"); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}/epics/new`); + await page.getByRole("button", { name: "Criar épico" }).click(); + await expect(page.getByRole("alert").first()).toBeVisible(); + await page.getByLabel(/Título \(obrigatório\)/).fill("Épico criado pela interface"); + await page.getByRole("button", { name: "Criar épico" }).click(); + await expect(page.getByRole("heading", { name: "Épico criado pela interface" })).toBeVisible(); + + await page.getByRole("button", { name: "Nova feature" }).click(); + await page.getByLabel(/Título \(obrigatório\)/).fill("Feature criada pela interface"); + await page.getByRole("button", { name: "Criar feature" }).click(); + await expect(page.getByRole("heading", { name: "Feature criada pela interface" })).toBeVisible(); + + await page.getByRole("button", { name: "Novo PBI" }).click(); + const title = page.getByLabel(/Título \(obrigatório, verbo no infinitivo\)/); + await title.fill("Login do sistema"); + await page.getByLabel(/COMO UM/).fill("usuário cadastrado"); + await page.getByLabel(/EU QUERO/).fill("entrar com e-mail e senha"); + await page.getByLabel(/PARA QUE/).fill("acessar meus projetos"); + await expect(page.getByText("Checklist de qualidade em tempo real")).toBeVisible(); + await title.fill("Validar login do sistema"); + await page.getByRole("button", { name: "Criar PBI" }).click(); + await expect(page.getByRole("heading", { name: /Validar login do sistema/ })).toBeVisible(); + + await page.getByRole("button", { name: "Marcar como concluído" }).click(); + await expect(page.getByText(/Faltam preencher: Cenários de aceitação/)).toBeVisible(); + + await page.getByRole("button", { name: "Novo cenário" }).click(); + await page.getByLabel("Nome do cenário").fill("Login válido"); + await page.getByLabel("DADO").fill("um usuário cadastrado"); + await page.getByLabel("QUANDO").fill("informar credenciais corretas"); + await page.getByLabel("ENTÃO").fill("o sistema abre a área interna"); + await page.getByRole("button", { name: "Adicionar" }).click(); + await expect(page.getByText("Login válido")).toBeVisible(); + + await page.getByRole("button", { name: "Marcar como concluído" }).click(); + await expect(page.getByText(/Concluído/i).first()).toBeVisible(); + } finally { + await context.close(); + } + const tree = await api(`/projects/${project.id}/backlog-tree`, { token: po.token }); + assert.equal(tree.json.epics.length, 1); + assert.equal(tree.json.epics[0].features[0].pbis[0].titulo, "Validar login do sistema"); + assert.equal(tree.json.epics[0].features[0].pbis[0].status, "concluido"); +}); + +test("Arquivar item de backlog exige prévia e deixa o item somente leitura", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Arquivar item"); + const epic = await api("/epics", { method: "POST", token: po.token, body: { projeto_id: project.id, titulo: "Épico a arquivar" } }); + assert.equal(epic.status, 201); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}/epics/${epic.json.id}`); + await page.getByRole("button", { name: "Arquivar item" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByRole("button", { name: "Cancelar" }).click(); + assert.equal((await api(`/epics/${epic.json.id}`, { token: po.token })).json.status !== "arquivado", true); + await page.getByRole("button", { name: "Arquivar item" }).click(); + await page.getByRole("button", { name: /Confirmar arquivamento/ }).click(); + await expect(page.getByText(/somente leitura|Arquivado em/i).first()).toBeVisible(); + } finally { + await context.close(); + } + assert.equal((await api(`/epics/${epic.json.id}`, { token: po.token })).json.status, "arquivado"); + assert.ok((await api("/features", { method: "POST", token: po.token, body: { epico_id: epic.json.id, titulo: "Não deve criar" } })).status >= 400); +}); diff --git a/frontend/src/api/api_backlog_search.ts b/frontend/src/api/api_backlog_search.ts new file mode 100644 index 0000000..00bfc75 --- /dev/null +++ b/frontend/src/api/api_backlog_search.ts @@ -0,0 +1,96 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; + +export type BacklogItemType = "epico" | "feature" | "pbi"; + +export interface SearchPathNode { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; +} + +export interface BacklogSearchHit { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; + status: string; + campo: "titulo" | "descricao"; + trecho: { texto: string; destaques: Array<[number, number]> }; + caminho: SearchPathNode[]; + tecnologias: Array<{ id: string; nome: string }>; +} + +export interface BacklogSearchResult { + projeto_id: string; + termo: string; + total: number; + limite: number; + items: BacklogSearchHit[]; +} + +export const MIN_QUERY_LENGTH = 2; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseHit(value: unknown): BacklogSearchHit { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string" + || !["epico", "feature", "pbi"].includes(String(value.tipo)) + || !isRecord(value.trecho) || typeof value.trecho.texto !== "string" || !Array.isArray(value.trecho.destaques) + || !Array.isArray(value.caminho)) { + throw new Error("Resultado de busca inválido"); + } + return { + tipo: value.tipo as BacklogItemType, + id: value.id, + titulo: value.titulo, + codigo: typeof value.codigo === "string" ? value.codigo : null, + status: typeof value.status === "string" ? value.status : "", + campo: value.campo === "descricao" ? "descricao" : "titulo", + trecho: { + texto: value.trecho.texto, + destaques: (value.trecho.destaques as unknown[]).flatMap((range) => + Array.isArray(range) && typeof range[0] === "number" && typeof range[1] === "number" ? [[range[0], range[1]] as [number, number]] : []), + }, + caminho: value.caminho.flatMap((node) => + isRecord(node) && typeof node.id === "string" && typeof node.titulo === "string" && ["epico", "feature", "pbi"].includes(String(node.tipo)) + ? [{ tipo: node.tipo as BacklogItemType, id: node.id, titulo: node.titulo, codigo: typeof node.codigo === "string" ? node.codigo : null }] + : []), + tecnologias: Array.isArray(value.tecnologias) + ? value.tecnologias.flatMap((tech) => (isRecord(tech) && typeof tech.id === "string" && typeof tech.nome === "string" ? [{ id: tech.id, nome: tech.nome }] : [])) + : [], + }; +} + +export async function searchBacklog( + projectId: string, + criteria: { q: string; status?: string; tecnologiaId?: string; limit?: number }, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ q: criteria.q }); + if (criteria.status) params.set("status", criteria.status); + if (criteria.tecnologiaId) params.set("tecnologia", criteria.tecnologiaId); + if (criteria.limit) params.set("limit", String(criteria.limit)); + const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/backlog-search?${params.toString()}`, { signal }); + const data: unknown = await response.json(); + if (!isRecord(data) || !Array.isArray(data.items) || typeof data.total !== "number") throw new Error("Resposta de busca inválida"); + return { + projeto_id: String(data.projeto_id ?? projectId), + termo: String(data.termo ?? criteria.q), + total: data.total, + limite: typeof data.limite === "number" ? data.limite : data.items.length, + items: data.items.map(parseHit), + }; +} + +export function describeSearchError(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 404) return "Projeto não encontrado."; + if (error.status === 400) return serverMessage(error) ?? "Revise os critérios da busca."; + if (error.status === 401) return "Sua sessão expirou. Entre novamente para buscar."; + } + return "Não foi possível buscar no backlog. Tente novamente."; +} diff --git a/frontend/src/api/api_decisions.ts b/frontend/src/api/api_decisions.ts new file mode 100644 index 0000000..79df564 --- /dev/null +++ b/frontend/src/api/api_decisions.ts @@ -0,0 +1,101 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; + +export type DecisionKind = "projeto" | "epico" | "feature" | "pbi"; + +export interface DecisionNode { + tipo: DecisionKind; + id: string; + titulo: string; + codigo: string | null; +} + +export interface Decision { + id: string; + titulo: string; + contexto: string; + decisao: string; + justificativa: string; + alternativas: string | null; + autor: { id: string; nome: string } | null; + created_at: string; + origem: DecisionNode & { herdada: boolean }; +} + +export interface DecisionList { + entidade: DecisionNode; + ancestrais: DecisionNode[]; + decisoes: Decision[]; +} + +export interface DecisionInput { + titulo: string; + contexto: string; + decisao: string; + justificativa: string; + alternativas: string; +} + +const PATHS: Record = { + projeto: "projects", + epico: "epics", + feature: "features", + pbi: "pbis", +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseNode(value: unknown): DecisionNode { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string" || !Object.keys(PATHS).includes(String(value.tipo))) { + throw new Error("Nó de decisão inválido"); + } + return { tipo: value.tipo as DecisionKind, id: value.id, titulo: value.titulo, codigo: typeof value.codigo === "string" ? value.codigo : null }; +} + +export function parseDecision(value: unknown): Decision { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string" || typeof value.contexto !== "string" + || typeof value.decisao !== "string" || typeof value.justificativa !== "string" || typeof value.created_at !== "string" + || !isRecord(value.origem)) { + throw new Error("Decisão inválida"); + } + const author = value.autor; + return { + id: value.id, + titulo: value.titulo, + contexto: value.contexto, + decisao: value.decisao, + justificativa: value.justificativa, + alternativas: typeof value.alternativas === "string" ? value.alternativas : null, + autor: isRecord(author) && typeof author.id === "string" && typeof author.nome === "string" ? { id: author.id, nome: author.nome } : null, + created_at: value.created_at, + origem: { ...parseNode(value.origem), herdada: value.origem.herdada === true }, + }; +} + +export async function listDecisions(kind: DecisionKind, id: string, signal?: AbortSignal): Promise { + const data: unknown = await (await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/decisions`, { signal })).json(); + if (!isRecord(data) || !Array.isArray(data.decisoes) || !Array.isArray(data.ancestrais)) throw new Error("Lista de decisões inválida"); + return { entidade: parseNode(data.entidade), ancestrais: data.ancestrais.map(parseNode), decisoes: data.decisoes.map(parseDecision) }; +} + +export async function createDecision(kind: DecisionKind, id: string, input: DecisionInput, signal?: AbortSignal): Promise { + const response = await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/decisions`, { + method: "POST", + body: JSON.stringify({ ...input, alternativas: input.alternativas.trim() || null }), + signal, + }); + return parseDecision(await response.json()); +} + +export function describeDecisionError(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 401) return "Sua sessão expirou. Entre novamente para registrar a decisão."; + if (error.status === 403) return "Seu perfil não permite registrar decisões."; + if (error.status === 404) return "O item não foi encontrado. Atualize a página."; + if (error.status === 409) return serverMessage(error) ?? "O item está arquivado e não recebe novas decisões."; + if (error.status === 400) return serverMessage(error) ?? "Revise os campos da decisão."; + } + return "Não foi possível registrar a decisão. O texto foi mantido; tente novamente."; +} diff --git a/frontend/src/assets/styles/backlog-search.css b/frontend/src/assets/styles/backlog-search.css new file mode 100644 index 0000000..a02f883 --- /dev/null +++ b/frontend/src/assets/styles/backlog-search.css @@ -0,0 +1,21 @@ +.backlog-search-field { flex: 2 1 260px; min-width: 0; } +.backlog-search-field input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-secondary); color: var(--text-primary); font: inherit; } +.backlog-search-back { padding: 0; background: none; border: 0; color: var(--orange); text-decoration: underline; font: inherit; } +.backlog-search-results { display: grid; gap: 12px; } +.backlog-search-summary { color: var(--text-secondary); font-size: .9rem; margin: 0; } +.backlog-search-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } +.backlog-search-item { padding: 16px 18px; display: grid; gap: 10px; } +.backlog-search-item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.backlog-search-code { font: 12px var(--font-mono); color: var(--text-muted); } +.backlog-search-title { padding: 0; background: none; border: 0; color: #fff; font-size: 1rem; font-weight: 600; text-align: left; overflow-wrap: anywhere; } +.backlog-search-title:hover { color: var(--orange); text-decoration: underline; } +.backlog-search-snippet { margin: 0; color: var(--text-secondary); font-size: .9rem; line-height: 1.6; overflow-wrap: anywhere; } +.backlog-search-snippet mark { background: color-mix(in srgb, var(--orange) 30%, transparent); color: #fff; border-radius: 3px; padding: 0 2px; } +.backlog-search-path ol { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: .8rem; color: var(--text-muted); } +.backlog-search-path li:not(:last-child)::after { content: "›"; margin-left: 6px; color: var(--text-muted); } +.backlog-search-path button { padding: 0; background: none; border: 0; color: var(--text-secondary); font: inherit; text-decoration: underline; text-decoration-color: transparent; } +.backlog-search-path button:hover { color: var(--orange); text-decoration-color: currentColor; } +.backlog-search-path [aria-current="page"] { color: var(--text-primary); } +.backlog-search-tech { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; } +.backlog-search-tech li { padding: 2px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; font-size: .75rem; color: var(--text-secondary); } +.backlog-search-empty .ds-empty { border: 0; } diff --git a/frontend/src/assets/styles/decisions.css b/frontend/src/assets/styles/decisions.css new file mode 100644 index 0000000..b4587ec --- /dev/null +++ b/frontend/src/assets/styles/decisions.css @@ -0,0 +1,20 @@ +.decisions-panel { padding: 20px; display: grid; gap: 16px; margin-top: 24px; } +.decisions-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.decisions-head h3 { margin: 0 0 4px; font-size: 1.1rem; } +.decisions-head p { max-width: 62ch; font-size: .88rem; } +.decisions-group { margin: 0 0 10px; font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } +.decision-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } +.decision-card { padding: 14px 16px; border: 1px solid var(--line); border-left: 4px solid var(--orange); border-radius: var(--r); background: var(--bg); display: grid; gap: 8px; } +.decision-card header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; } +.decision-card h4 { margin: 0; font-size: 1rem; overflow-wrap: anywhere; } +.decision-card--inherited { border-left-style: dashed; background: color-mix(in srgb, var(--card) 60%, transparent); } +.decision-card--inherited[data-level="projeto"] { border-left-color: var(--step-2); } +.decision-card--inherited[data-level="epico"] { border-left-color: var(--step-3); } +.decision-card--inherited[data-level="feature"] { border-left-color: var(--step-4); } +.decision-meta { margin: 0; font-size: .8rem; color: var(--dim); } +.decision-card dl { margin: 0; display: grid; gap: 4px; } +.decision-card dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-top: 6px; } +.decision-card dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; } +.decision-form { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--line); border-radius: var(--r); background: var(--bg); } +.decision-form h4 { margin: 0; } +.decision-error { margin: 2px 0 0; color: var(--red); font-size: 12px; } diff --git a/frontend/src/assets/styles/garakis-prototype.css b/frontend/src/assets/styles/garakis-prototype.css index 03bbe5b..8741fcb 100644 --- a/frontend/src/assets/styles/garakis-prototype.css +++ b/frontend/src/assets/styles/garakis-prototype.css @@ -7,7 +7,7 @@ --strong: rgba(196, 199, 204, .3); --text: #e5e7eb; --muted: #a6aab3; - --dim: #6b7280; + --dim: #8b93a1; --orange: #ed6a32; --orange-bg: #4a200f; --green: #22b458; diff --git a/frontend/src/backlog/Hierarchy.test.tsx b/frontend/src/backlog/Hierarchy.test.tsx index 5178b7d..eb2cbb8 100644 --- a/frontend/src/backlog/Hierarchy.test.tsx +++ b/frontend/src/backlog/Hierarchy.test.tsx @@ -288,6 +288,7 @@ describe( ).toEqual({ status: "rascunho", technologyId: "react", + query: "", }); view.unmount(); diff --git a/frontend/src/index.css b/frontend/src/index.css index 4561ab5..3a6cc70 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -360,8 +360,8 @@ button { /* Buttons */ .btn-primary { - background: var(--brand-primary); - color: var(--text-primary); + background: var(--brand-gradient); + color: var(--on-brand); padding: 10px 24px; border-radius: var(--radius-full); font-weight: 500; @@ -373,7 +373,7 @@ button { } .btn-primary:hover:not(:disabled) { - background: var(--brand-primary-hover); + opacity: 0.92; box-shadow: var(--shadow-glow); } diff --git a/frontend/src/models/backlogSearch.test.ts b/frontend/src/models/backlogSearch.test.ts new file mode 100644 index 0000000..1dde7e2 --- /dev/null +++ b/frontend/src/models/backlogSearch.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { highlightParts, isSearchQuery, nodeHref, summarize } from "./backlogSearch"; + +const path = [ + { tipo: "epico" as const, id: "e1", titulo: "Épico", codigo: null }, + { tipo: "feature" as const, id: "f1", titulo: "Feature", codigo: null }, + { tipo: "pbi" as const, id: "p1", titulo: "PBI", codigo: "PBI-1" }, +]; + +it("divide o texto em trechos com e sem destaque", () => { + expect(highlightParts("Cadastrar usuário", [[10, 17]])).toEqual([ + { text: "Cadastrar ", match: false }, + { text: "usuário", match: true }, + ]); + expect(highlightParts("abc", [])).toEqual([{ text: "abc", match: false }]); + expect(highlightParts("abcdef", [[0, 2], [4, 6]]).map((part) => part.text)).toEqual(["ab", "cd", "ef"]); +}); + +it("ignora faixas inválidas, sobrepostas ou fora do texto", () => { + expect(highlightParts("abc", [[2, 2], [5, 9]])).toEqual([{ text: "abc", match: false }]); + expect(highlightParts("abcd", [[0, 3], [1, 4]]).map((part) => [part.text, part.match])).toEqual([["abc", true], ["d", true]]); +}); + +it("monta os endereços navegáveis de cada segmento do caminho", () => { + expect(nodeHref("p", path, 0)).toBe("/projects/p/epics/e1"); + expect(nodeHref("p", path, 1)).toBe("/projects/p/epics/e1/features/f1"); + expect(nodeHref("p", path, 2)).toBe("/projects/p/epics/e1/features/f1/pbis/p1"); +}); + +it("resume a quantidade de resultados e valida o tamanho mínimo da busca", () => { + expect(summarize(0, 0, "x")).toBe("Nenhum resultado para “x”."); + expect(summarize(1, 1, "login")).toBe("1 resultado para “login”."); + expect(summarize(80, 50, "login")).toBe("Mostrando 50 de 80 resultados para “login”."); + expect(isSearchQuery(" a ", 2)).toBe(false); + expect(isSearchQuery("ab", 2)).toBe(true); +}); diff --git a/frontend/src/models/backlogSearch.ts b/frontend/src/models/backlogSearch.ts new file mode 100644 index 0000000..96d153b --- /dev/null +++ b/frontend/src/models/backlogSearch.ts @@ -0,0 +1,45 @@ +import type { BacklogItemType, SearchPathNode } from "../api/api_backlog_search"; + +export const TYPE_LABEL: Record = { + epico: "Épico", + feature: "Feature", + pbi: "PBI", +}; + +export interface TextPart { + text: string; + match: boolean; +} + +export function highlightParts(text: string, ranges: Array<[number, number]>): TextPart[] { + const parts: TextPart[] = []; + let cursor = 0; + for (const [from, to] of [...ranges].sort((left, right) => left[0] - right[0])) { + const start = Math.max(from, cursor); + const end = Math.min(to, text.length); + if (start >= end) continue; + if (start > cursor) parts.push({ text: text.slice(cursor, start), match: false }); + parts.push({ text: text.slice(start, end), match: true }); + cursor = end; + } + if (cursor < text.length) parts.push({ text: text.slice(cursor), match: false }); + return parts.length > 0 ? parts : [{ text, match: false }]; +} + +export function nodeHref(projectId: string, path: SearchPathNode[], index: number): string { + const segments = [`/projects/${projectId}`]; + for (const node of path.slice(0, index + 1)) { + segments.push(node.tipo === "epico" ? `epics/${node.id}` : node.tipo === "feature" ? `features/${node.id}` : `pbis/${node.id}`); + } + return segments.join("/"); +} + +export function isSearchQuery(query: string, minimum: number): boolean { + return query.trim().length >= minimum; +} + +export function summarize(total: number, shown: number, query: string): string { + if (total === 0) return `Nenhum resultado para “${query}”.`; + const noun = total === 1 ? "resultado" : "resultados"; + return shown < total ? `Mostrando ${shown} de ${total} ${noun} para “${query}”.` : `${total} ${noun} para “${query}”.`; +} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index a9cb600..42d480f 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -13,10 +13,10 @@ } .ds-button--primary { - background: var(--brand-primary); - color: var(--text-primary); + background: var(--brand-gradient); + color: var(--on-brand); } -.ds-button--primary:hover:not(:disabled) { background: var(--brand-primary-hover); } +.ds-button--primary:hover:not(:disabled) { opacity: 0.92; } .ds-button--secondary { background: var(--bg-tertiary); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 9712dd2..beb0284 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -8,6 +8,8 @@ --text-primary: #ffffff; --text-secondary: var(--muted, #a6aab3); --text-muted: var(--dim, #8b93a1); + --on-brand: #17191c; + --brand-gradient: linear-gradient(135deg, #f1885b, var(--brand-primary)); --step-1: #f28c68; --step-2: #d97354; diff --git a/frontend/src/views/backlog/BacklogSearch.test.tsx b/frontend/src/views/backlog/BacklogSearch.test.tsx new file mode 100644 index 0000000..c47669d --- /dev/null +++ b/frontend/src/views/backlog/BacklogSearch.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { BacklogTreeView } from "./BacklogTreeView"; +import type { ProjectBacklogTree } from "../../api/api_backlog_tree"; + +const tree: ProjectBacklogTree = { + project: { id: "project-1", nome: "Sinapse", status: "ativo" }, + technologies: [{ id: "react", nome: "React" }, { id: "node", nome: "Node.js" }], + epics: [{ + id: "epic-1", titulo: "Organizar requisitos", status: "ativo", tecnologias: [], + features: [{ + id: "feature-1", titulo: "Navegação do backlog", status: "concluido", tecnologias: [{ id: "react", nome: "React" }], + pbis: [{ id: "pbi-1", codigo: "PBI-01.4.1", titulo: "Expandir árvore", status: "rascunho", tecnologias: [{ id: "react", nome: "React" }] }], + }], + }], +}; + +const hit = (overrides: Record = {}) => ({ + tipo: "pbi", id: "pbi-1", titulo: "Expandir árvore", codigo: "PBI-01.4.1", status: "rascunho", campo: "titulo", + trecho: { texto: "Expandir árvore", destaques: [[0, 8]] }, + caminho: [ + { tipo: "epico", id: "epic-1", titulo: "Organizar requisitos", codigo: null }, + { tipo: "feature", id: "feature-1", titulo: "Navegação do backlog", codigo: null }, + { tipo: "pbi", id: "pbi-1", titulo: "Expandir árvore", codigo: "PBI-01.4.1" }, + ], + tecnologias: [{ id: "react", nome: "React" }], + ...overrides, +}); + +const search = (items: unknown[], total = items.length, termo = "expandir") => ({ projeto_id: "project-1", termo, total, limite: 50, items }); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +function stub(handler: (url: URL) => Promise) { + const request = vi.fn((input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/v1/projects/project-1/backlog-tree") return json(tree); + return handler(url); + }); + vi.stubGlobal("fetch", request); + return request; +} + +const searchCalls = (request: ReturnType) => + request.mock.calls.map((call) => new URL(String(call[0]), "http://localhost")).filter((url) => url.pathname.endsWith("/backlog-search")); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + window.sessionStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +const type = async (value: string) => { + fireEvent.change(await screen.findByLabelText(/Buscar no backlog/), { target: { value } }); +}; + +it("mostra o campo de busca junto aos filtros e orienta o mínimo de caracteres sem chamar a API", async () => { + const request = stub(() => json(search([]))); + render(); + + await type("a"); + expect(screen.getByText("Digite ao menos 2 caracteres.")).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(searchCalls(request)).toHaveLength(0); + expect(screen.getByRole("list", { name: "Hierarquia do backlog" })).toBeInTheDocument(); + expect(screen.getByText("1 filtro ativo")).toBeInTheDocument(); +}); + +it("título encontrado retorna o item correto com trecho destacado, tipo e caminho navegável", async () => { + const request = stub(() => json(search([hit()]))); + render(); + + await type("expandir"); + const results = await screen.findByRole("region", { name: "Resultados da busca no backlog" }); + expect(searchCalls(request)[0].searchParams.get("q")).toBe("expandir"); + expect(within(results).getByText("PBI")).toBeInTheDocument(); + expect(within(results).getByText("PBI-01.4.1")).toBeInTheDocument(); + expect(within(results).getByText("Expandir", { selector: "mark" })).toBeInTheDocument(); + expect(within(results).getByRole("status")).toHaveTextContent("1 resultado para “expandir”."); + expect(screen.queryByRole("list", { name: "Hierarquia do backlog" })).toBeNull(); + + const path = within(results).getByRole("navigation", { name: "Caminho de Expandir árvore" }); + const items = within(path).getAllByRole("listitem").map((item) => item.textContent); + expect(items).toEqual(["Sinapse", "Organizar requisitos", "Navegação do backlog", "PBI-01.4.1 · Expandir árvore"]); + expect(within(path).getByText(/Expandir árvore/)).toHaveAttribute("aria-current", "page"); + + fireEvent.click(within(path).getByRole("button", { name: "Navegação do backlog" })); + expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1/features/feature-1"); + fireEvent.click(within(results).getByRole("button", { name: "Expandir árvore" })); + expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1/features/feature-1/pbis/pbi-1"); + fireEvent.click(within(path).getByRole("button", { name: "Organizar requisitos" })); + expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1"); +}); + +it("descrição encontrada mostra o trecho da descrição e indica o campo", async () => { + stub(() => json(search([hit({ campo: "descricao", trecho: { texto: "…Eu quero recuperar a senha por e-mail…", destaques: [[10, 19]] } })], 1, "recuperar"))); + render(); + + await type("recuperar"); + const snippet = await screen.findByText("recuperar", { selector: "mark" }); + expect(snippet.closest("p")).toHaveAttribute("data-field", "descricao"); + expect(screen.getByText("Trecho da descrição:")).toBeInTheDocument(); +}); + +it("combina texto, status e tecnologia e conta os filtros ativos", async () => { + const request = stub(() => json(search([hit()]))); + render(); + + await type("expandir"); + await screen.findByRole("region", { name: "Resultados da busca no backlog" }); + fireEvent.change(screen.getByLabelText("Status"), { target: { value: "rascunho" } }); + fireEvent.change(screen.getByLabelText("Tecnologia"), { target: { value: "react" } }); + + await waitFor(() => { + const calls = searchCalls(request); + const last = calls[calls.length - 1]; + expect(last.searchParams.get("q")).toBe("expandir"); + expect(last.searchParams.get("status")).toBe("rascunho"); + expect(last.searchParams.get("tecnologia")).toBe("react"); + }); + expect(screen.getByText("3 filtros ativos")).toBeInTheDocument(); +}); + +it("a busca é sempre enviada para o projeto aberto", async () => { + const request = stub(() => json(search([]))); + render(); + await type("login"); + await waitFor(() => expect(searchCalls(request)).toHaveLength(1)); + expect(searchCalls(request)[0].pathname).toBe("/api/v1/projects/project-1/backlog-search"); +}); + +it("estado vazio permite limpar os critérios e voltar à árvore", async () => { + stub(() => json(search([], 0, "inexistente"))); + render(); + + fireEvent.change(await screen.findByLabelText("Status"), { target: { value: "rascunho" } }); + await type("inexistente"); + expect(await screen.findByText("Nenhum item encontrado")).toBeInTheDocument(); + expect(screen.getByText(/com os filtros atuais/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Limpar só a busca" })); + expect(await screen.findByRole("list", { name: "Hierarquia do backlog" })).toBeInTheDocument(); + expect((screen.getByLabelText(/Buscar no backlog/) as HTMLInputElement).value).toBe(""); + expect(screen.getByLabelText("Status")).toHaveValue("rascunho"); + + await type("inexistente"); + fireEvent.click(await screen.findByRole("button", { name: "Limpar critérios" })); + expect(await screen.findByRole("list", { name: "Hierarquia do backlog" })).toBeInTheDocument(); + expect(screen.getByLabelText("Status")).toHaveValue(""); + expect(screen.getByText("0 filtros ativos")).toBeInTheDocument(); +}); + +it("texto e filtros persistem na sessão ao sair e voltar", async () => { + stub(() => json(search([hit()]))); + const first = render(); + await type("expandir"); + fireEvent.change(screen.getByLabelText("Status"), { target: { value: "rascunho" } }); + await screen.findByRole("region", { name: "Resultados da busca no backlog" }); + fireEvent.click(screen.getByRole("button", { name: "Expandir árvore" })); + first.unmount(); + + expect(JSON.parse(window.sessionStorage.getItem("sinapse.backlog.filters.project-1")!)).toEqual({ status: "rascunho", technologyId: "", query: "expandir" }); + render(); + expect(await screen.findByRole("region", { name: "Resultados da busca no backlog" })).toBeInTheDocument(); + expect((screen.getByLabelText(/Buscar no backlog/) as HTMLInputElement).value).toBe("expandir"); + expect(screen.getByLabelText("Status")).toHaveValue("rascunho"); +}); + +it("filtros salvos por versões anteriores (sem texto) continuam válidos", async () => { + window.sessionStorage.setItem("sinapse.backlog.filters.project-1", JSON.stringify({ status: "concluido", technologyId: "" })); + stub(() => json(search([]))); + render(); + expect(await screen.findByLabelText("Status")).toHaveValue("concluido"); + expect((screen.getByLabelText(/Buscar no backlog/) as HTMLInputElement).value).toBe(""); +}); + +it("erro da busca oferece nova tentativa e mensagens do servidor são legíveis", async () => { + let calls = 0; + stub(() => (++calls === 1 ? json({ error: "x" }, 500) : json(search([hit()])))); + render(); + + await type("expandir"); + expect((await screen.findByRole("alert")).textContent).toBe("Não foi possível buscar no backlog. Tente novamente."); + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByRole("region", { name: "Resultados da busca no backlog" })).toBeInTheDocument(); +}); + +it("resultados acima do limite avisam que a lista foi truncada", async () => { + stub(() => json(search([hit()], 80))); + render(); + await type("expandir"); + expect(await screen.findByText("Mostrando 1 de 80 resultados para “expandir”.")).toBeInTheDocument(); + expect(screen.getByText(/Há mais resultados do que o limite exibido/)).toBeInTheDocument(); +}); + +it("digitar rapidamente dispara uma única busca depois do atraso", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const request = stub(() => json(search([hit()]))); + render(); + const input = await screen.findByLabelText(/Buscar no backlog/); + + for (const value of ["ex", "exp", "expa", "expan"]) fireEvent.change(input, { target: { value } }); + expect(searchCalls(request)).toHaveLength(0); + await act(async () => { await vi.advanceTimersByTimeAsync(350); }); + await waitFor(() => expect(searchCalls(request)).toHaveLength(1)); + expect(searchCalls(request)[0].searchParams.get("q")).toBe("expan"); +}); diff --git a/frontend/src/views/backlog/BacklogSearchResults.tsx b/frontend/src/views/backlog/BacklogSearchResults.tsx new file mode 100644 index 0000000..ac5d10e --- /dev/null +++ b/frontend/src/views/backlog/BacklogSearchResults.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { describeSearchError, searchBacklog, type BacklogSearchResult } from "../../api/api_backlog_search"; +import { TYPE_LABEL, highlightParts, nodeHref, summarize } from "../../models/backlogSearch"; +import { navigate } from "../../models/navigation"; +import { Alert, Badge, Button, EmptyState } from "../common/ui"; +import "../../assets/styles/backlog-search.css"; + +type State = + | { state: "loading" } + | { state: "error"; message: string } + | { state: "ready"; result: BacklogSearchResult }; + +const STATUS_LABEL: Record = { + rascunho: "Rascunho", + ativo: "Ativo", + pronto: "Pronto", + concluido: "Concluído", + arquivado: "Arquivado", +}; + +export function BacklogSearchResults({ + projectId, + projectName, + query, + status, + technologyId, + filtersActive, + onClearSearch, + onClearAll, +}: { + projectId: string; + projectName?: string; + query: string; + status: string; + technologyId: string; + filtersActive: boolean; + onClearSearch: () => void; + onClearAll: () => void; +}) { + const [state, setState] = useState({ state: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setState({ state: "loading" }); + searchBacklog(projectId, { q: query, status, tecnologiaId: technologyId }, controller.signal) + .then((result) => { if (!controller.signal.aborted) setState({ state: "ready", result }); }) + .catch((error) => { if (!controller.signal.aborted) setState({ state: "error", message: describeSearchError(error) }); }); + return () => controller.abort(); + }, [projectId, query, status, technologyId, attempt]); + + if (state.state === "loading") { + return
Buscando no backlog…
; + } + + if (state.state === "error") { + return ( +
+

{state.message}

+ +
+ ); + } + + const { result } = state; + + if (result.items.length === 0) { + return ( +
+ +
+ + {filtersActive && } +
+
+
+ ); + } + + return ( +
+

{summarize(result.total, result.items.length, result.termo)}

+
    + {result.items.map((item) => { + const parts = highlightParts(item.trecho.texto, item.trecho.destaques); + const last = item.caminho.length - 1; + return ( +
  • +
    + {TYPE_LABEL[item.tipo]} + {item.codigo && {item.codigo}} + + {item.status && {STATUS_LABEL[item.status] ?? item.status}} +
    + +

    + {item.campo === "titulo" ? "Título: " : "Trecho da descrição: "} + {parts.map((part, index) => (part.match ? {part.text} : {part.text}))} +

    + + + + {item.tecnologias.length > 0 && ( +
      + {item.tecnologias.map((tech) =>
    • {tech.nome}
    • )} +
    + )} +
  • + ); + })} +
+ {result.total > result.items.length && ( + Há mais resultados do que o limite exibido. Refine o termo ou use os filtros para reduzir a lista. + )} +
+ ); +} diff --git a/frontend/src/views/backlog/BacklogTreeView.tsx b/frontend/src/views/backlog/BacklogTreeView.tsx index e76da36..d685096 100644 --- a/frontend/src/views/backlog/BacklogTreeView.tsx +++ b/frontend/src/views/backlog/BacklogTreeView.tsx @@ -20,11 +20,20 @@ import { navigate, } from "../../models/navigation"; +import { MIN_QUERY_LENGTH } from "../../api/api_backlog_search"; +import { isSearchQuery } from "../../models/backlogSearch"; +import { BacklogSearchResults } from "./BacklogSearchResults"; + export interface BacklogFilters { status: string; technologyId: string; + query: string; } +const MAX_QUERY_LENGTH = 100; + +type TreeFilters = Pick; + type Result = | { state: "loading"; @@ -42,6 +51,7 @@ const EMPTY_FILTERS: BacklogFilters = { status: "", technologyId: "", + query: "", }; const STATUS_OPTIONS = [ @@ -98,6 +108,12 @@ function readFilters( === "string" ? value.technologyId : "", + + query: + typeof value.query + === "string" + ? value.query.slice(0, MAX_QUERY_LENGTH) + : "", }; } catch { return EMPTY_FILTERS; @@ -114,7 +130,7 @@ function matches( }>; }, - filters: BacklogFilters, + filters: TreeFilters, ) { return ( ( @@ -135,7 +151,7 @@ function matches( export function filterBacklogTree( tree: ProjectBacklogTree, - filters: BacklogFilters, + filters: TreeFilters, ): ProjectBacklogTree { if ( !filters.status @@ -639,6 +655,33 @@ export function BacklogTreeView({ projectId, ]); + const [ + debouncedQuery, + setDebouncedQuery, + ] = useState( + () => filters.query.trim(), + ); + + useEffect(() => { + const timer = + window.setTimeout( + () => + setDebouncedQuery( + filters.query.trim(), + ), + 300, + ); + + return () => + window.clearTimeout(timer); + }, [filters.query]); + + const searchMode = + isSearchQuery( + debouncedQuery, + MIN_QUERY_LENGTH, + ); + const filteredTree = useMemo( () => @@ -662,6 +705,17 @@ export function BacklogTreeView({ Boolean( filters.technologyId, ), + ) + + Number( + Boolean( + filters.query.trim(), + ), + ); + + const treeFiltersActive = + Boolean(filters.status) + || Boolean( + filters.technologyId, ); const filtersActive = @@ -683,11 +737,25 @@ export function BacklogTreeView({ return next; }; - const clearFilters = () => + const clearFilters = () => { setFilters( EMPTY_FILTERS, ); + setDebouncedQuery(""); + }; + + const clearSearch = () => { + setFilters( + (current) => ({ + ...current, + query: "", + }), + ); + + setDebouncedQuery(""); + }; + return (
+ +