diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 872edf2..30eed58 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
name: CI
on:
+ workflow_dispatch:
push:
branches:
- main
@@ -111,7 +112,7 @@ jobs:
run: npm run audit:security
validate-frontend:
- name: Build Frontend (React + Vite)
+ name: Test and Build Frontend (React + Vite)
runs-on: ubuntu-latest
steps:
@@ -129,14 +130,14 @@ jobs:
working-directory: frontend
run: npm ci
- - name: Build Production Assets
- working-directory: frontend
- run: npm run build
-
- name: Run frontend tests
working-directory: frontend
run: npm test
+ - name: Build Production Assets
+ working-directory: frontend
+ run: npm run build
+
validate-ai-service:
name: Validate AI Service (Python / FastAPI)
runs-on: ubuntu-latest
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..4c48cc4
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,65 @@
+# PRE-04 — Fundação de testes
+
+Os três serviços possuem suítes determinísticas executadas pela CI em
+`.github/workflows/ci.yml`. Esta fundação não implementa funcionalidades pendentes
+dos PBIs e não substitui testes ponta a ponta com serviços reais.
+
+## Execução local
+
+Use Node.js 20 e Python 3.11, as mesmas versões configuradas na CI.
+Execute cada grupo no diretório indicado:
+
+| Diretório | Instalação | Testes | Compilação |
+| --- | --- | --- | --- |
+| `backend` | `npm ci` | `npm test` | `npm run build` |
+| `frontend` | `npm ci` | `npm test` | `npm run build` |
+| `ai-service` | `python -m pip install -r requirements.txt` | `python -m unittest discover -s tests -v` | `python -m py_compile main.py config.py services/__init__.py services/chunker.py services/ollama_client.py` |
+
+Para Python, recomenda-se um ambiente virtual local (`python -m venv .venv`).
+Ative-o antes de instalar dependências e executar os comandos.
+
+## Cobertura inicial
+
+- Backend: testes de configuração, autenticação, autorização, seed e projetos, incluindo regras de
+ negócio e HTTP com repositório em memória. `scripts/test.mjs` descobre arquivos
+ `src/**/*.test.ts` recursivamente sem depender da expansão de globs do shell.
+ Nenhum arquivo encontrado ou falha em qualquer teste retorna código diferente de zero.
+- Frontend: Vitest, Testing Library e jsdom; autenticação, projetos, navegação e ações explícitas sobre sugestões de IA,
+ botão desabilitado, erro acessível de formulário e limites do indicador de progresso.
+ Novos arquivos `*.test.ts`/`*.test.tsx` são descobertos pelo Vitest. O comando não
+ permanece em modo watch e falha se não encontrar testes.
+- IA: testes existentes de chunking/metadados e novos testes HTTP de validação,
+ embeddings, contexto RAG e indisponibilidade do Ollama. Chamadas ao cliente de
+ modelos usam `AsyncMock`; não exigem GPU, Ollama, banco ou conexão externa.
+
+Dados de teste são sintéticos. Não adicionar tokens, senhas reais, documentos de
+clientes, dumps de ambiente ou respostas privadas aos testes e logs.
+
+## Integração contínua
+
+Os jobs do backend, frontend e IA executam os mesmos comandos acima em pull requests
+para `main`, `HT-setup` e `develop`, e em pushes nessas branches. O workflow também
+permite execução manual (`workflow_dispatch`). Um comando de teste com falha reprova
+seu job; não há `continue-on-error` nem tolerância automática por repetição.
+Os checks existentes de n8n, Docker Compose e auditoria do backend foram mantidos.
+
+## Limites e conclusão
+
+Após integrar a main em 16/09/2026, a validação local em Windows passou com
+52 testes de backend (1 teste de PostgreSQL ignorado localmente), 50 de frontend
+e 9 de IA. Os builds TypeScript/Vite e a compilação Python passaram. A auditoria
+das dependências de produção do backend não encontrou vulnerabilidades.
+O teste de banco exige SEED_TEST_DATABASE_URL e é executado no job
+validate-seed com PostgreSQL/pgvector; não deve ser contado como aprovado localmente.
+A CI usa Node.js 20/Python 3.11; a validação local usa Node.js 22/Python 3.12.
+
+Os PBIs referenciados orientam a expansão da suíte: completude, busca semântica,
+respostas fundamentadas e autenticação. A fundação não comprova que esses PBIs estão
+concluídos. Em particular, os testes RAG verificam o transporte do contexto, não a
+qualidade factual de um modelo real. Os testes de autenticação e projetos já integrados à main foram preservados,
+assim como os scripts de seed e test:integration:s1.
+
+Nenhum endpoint foi alterado; não há alteração de OpenAPI nesta tarefa.
+Para encerrar a PRE-04, confirmar uma execução remota verde dos três jobs no PR e
+obter revisão por outra pessoa. Tornar os checks obrigatórios para merge depende
+das regras de proteção da branch no GitHub, que não são alteradas por este commit.
diff --git a/ai-service/tests/test_api.py b/ai-service/tests/test_api.py
new file mode 100644
index 0000000..e4dde30
--- /dev/null
+++ b/ai-service/tests/test_api.py
@@ -0,0 +1,55 @@
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from fastapi.testclient import TestClient
+from main import app, ollama_client
+
+
+class ApiTests(unittest.TestCase):
+ def setUp(self):
+ self.client = self.enterContext(TestClient(app))
+ # All model calls are mocked: tests never require a running Ollama.
+ self.embedding = self.enterContext(patch.object(
+ ollama_client, "get_embedding", new_callable=AsyncMock))
+ self.generate = self.enterContext(patch.object(
+ ollama_client, "generate_response", new_callable=AsyncMock))
+ self.health = self.enterContext(patch.object(
+ ollama_client, "check_health", new_callable=AsyncMock))
+
+ def test_health_reports_unavailable_dependency(self):
+ self.health.return_value = False
+ result = self.client.get("/health")
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.json()["status"], "degraded")
+
+ def test_missing_embedding_text_is_rejected_before_model_call(self):
+ result = self.client.post("/embeddings", json={})
+ self.assertEqual(result.status_code, 422)
+ self.embedding.assert_not_awaited()
+
+ def test_embedding_returns_model_vector_and_dimension(self):
+ self.embedding.return_value = [0.25, 0.5]
+ result = self.client.post("/embeddings", json={"text": "Requisito", "model": "test-model"})
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.json(), {"model": "test-model", "dimension": 2, "embedding": [0.25, 0.5]})
+ self.embedding.assert_awaited_once_with("Requisito", model="test-model")
+
+ def test_embedding_dependency_failure_returns_502(self):
+ self.embedding.side_effect = RuntimeError("test dependency unavailable")
+ self.assertEqual(self.client.post("/embeddings", json={"text": "Requisito"}).status_code, 502)
+
+ def test_rag_preserves_context_and_project_in_response(self):
+ self.generate.return_value = "Resposta simulada [doc-1]"
+ result = self.client.post("/rag/query", json={
+ "query": "Como funciona?", "project_id": "project-1",
+ "context_chunks": ["doc-1: evidência de teste"],
+ })
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.json()["project_id"], "project-1")
+ self.assertEqual(result.json()["context_chunks_used"], 1)
+ self.assertEqual(result.json()["response"], "Resposta simulada [doc-1]")
+ self.assertIn("doc-1: evidência de teste", self.generate.await_args.args[0])
+
+ def test_rag_dependency_failure_returns_502(self):
+ self.generate.side_effect = RuntimeError("test dependency unavailable")
+ self.assertEqual(self.client.post("/rag/query", json={"query": "Pergunta"}).status_code, 502)
diff --git a/frontend/src/components/ui/index.test.tsx b/frontend/src/components/ui/index.test.tsx
new file mode 100644
index 0000000..39f505d
--- /dev/null
+++ b/frontend/src/components/ui/index.test.tsx
@@ -0,0 +1,37 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { AISuggestion, Button, Field, Progress } from "./index";
+
+afterEach(cleanup);
+
+it("botão desabilitado não executa a ação", () => {
+ const action = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Salvar" }));
+ expect(action).not.toHaveBeenCalled();
+});
+
+it("campo apresenta erro com alerta e mantém rótulo acessível", () => {
+ render();
+ expect(screen.getByLabelText(/Nome/)).toBeTruthy();
+ expect(screen.getByRole("alert").textContent).toBe("Nome obrigatório");
+});
+
+it.each([[-20, 0], [45, 45], [150, 100]])("progresso %s fica no intervalo acessível", (value, expected) => {
+ render();
+ expect(screen.getByRole("progressbar", { name: "Completude" }).getAttribute("aria-valuenow")).toBe(String(expected));
+});
+
+it("sugestão de IA exige ação explícita e diferencia aceitar, editar e descartar", () => {
+ const accept = vi.fn(); const edit = vi.fn(); const discard = vi.fn();
+ render(Sugestão de requisito);
+ expect(accept).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Editar antes de aceitar" }));
+ expect(edit).toHaveBeenCalledTimes(1);
+ expect(accept).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Descartar" }));
+ expect(discard).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole("button", { name: "Aceitar" }));
+ expect(accept).toHaveBeenCalledTimes(1);
+});