From d4b5a6a00332302c32c0e7aaf3d91db869b09ba7 Mon Sep 17 00:00:00 2001 From: LoadCG Date: Sun, 27 Sep 2026 15:54:24 -0300 Subject: [PATCH] fix: consolidate sprint 1 QA findings --- .github/workflows/ci.yml | 3 ++ .github/workflows/e2e.yml | 22 ++++++++++-- backend/src/database/seed-lib.ts | 4 +-- backend/src/database/seed.test.ts | 1 + database/seed/README.md | 2 +- frontend/src/App.test.tsx | 36 ++++++++++++++++++- frontend/src/App.tsx | 23 +++++++++--- .../src/assets/styles/garakis-prototype.css | 16 +++++++-- frontend/src/views/auth/AuthView.tsx | 4 +-- .../views/projects/RepoAnalyzerView.test.tsx | 1 + frontend/vite.config.ts | 10 ++++-- 11 files changed, 103 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c1ff03..1ea3aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: cache-dependency-path: backend/package-lock.json - run: npm ci working-directory: backend + - name: Build backend scripts used by seed validation + run: npm run build + working-directory: backend - run: npm run migrate working-directory: backend - run: npm run seed:validate diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 38216e6..19b875c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,8 +1,26 @@ 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. +# O serviço de IA não é necessário: os cenários cobrem seu estado indisponível. +# Executa a regressão de navegador em mudanças de aplicação, dados e testes. on: + pull_request: + branches: [main] + paths: + - "backend/**" + - "frontend/**" + - "database/**" + - "e2e/**" + - "docker-compose.yml" + - ".github/workflows/e2e.yml" + push: + branches: [main] + paths: + - "backend/**" + - "frontend/**" + - "database/**" + - "e2e/**" + - "docker-compose.yml" + - ".github/workflows/e2e.yml" workflow_dispatch: jobs: diff --git a/backend/src/database/seed-lib.ts b/backend/src/database/seed-lib.ts index 1c07783..3425832 100644 --- a/backend/src/database/seed-lib.ts +++ b/backend/src/database/seed-lib.ts @@ -58,8 +58,8 @@ export function validateTarget(url: string | undefined, environment: string | un } const target = new URL(url); const dbName = decodeURIComponent(target.pathname).replace(/^\//, ""); - if (!["postgres:", "postgresql:"].includes(target.protocol) || (!/_(dev|test)$/.test(dbName) && dbName !== "sinapse")) { - throw new Error("Banco deve terminar em _dev, _test ou ser 'sinapse'"); + if (!["postgres:", "postgresql:"].includes(target.protocol) || !/^[a-zA-Z0-9_-]+_(dev|test)$/.test(dbName)) { + throw new Error("Banco deve terminar em _dev ou _test."); } return url; } diff --git a/backend/src/database/seed.test.ts b/backend/src/database/seed.test.ts index f8bfe15..e0c709e 100644 --- a/backend/src/database/seed.test.ts +++ b/backend/src/database/seed.test.ts @@ -28,6 +28,7 @@ test("recusa produção, destino ausente e banco não dedicado", () => { assert.throws(() => validateTarget(undefined, "test")); assert.throws(() => validateTarget("postgresql://localhost/acervo_test", "production")); assert.throws(() => validateTarget("postgresql://localhost/sinapse", "development")); + assert.throws(() => validateTarget("postgresql://localhost/acervo", "development")); assert.equal(validateTarget("postgresql://localhost/acervo_test", "test"), "postgresql://localhost/acervo_test"); }); test("carga SQL é idempotente e recusa sobrescrita", { skip: !process.env.SEED_TEST_DATABASE_URL }, async () => { diff --git a/database/seed/README.md b/database/seed/README.md index 63d52aa..becac5b 100644 --- a/database/seed/README.md +++ b/database/seed/README.md @@ -9,7 +9,7 @@ competências inventadas. Cada registro tem repositório, revisão, caminho, loc hash SHA-256 do texto fonte em UTF-8 e descrição da transformação; a carga persiste essa origem em `auditoria` e nos metadados dos chunks. -Dentro de `backend`, execute `npm ci`, `npm run seed:validate` e `npm run test:seed`. +Dentro de `backend`, execute `npm ci`, `npm run build`, `npm run seed:validate` e `npm run test:seed`. Para carregar, prepare um banco dedicado com as migrações 001–003. Configure `SEED_DATABASE_URL` por variável de ambiente (não em argumentos nem arquivos versionados) e execute `npm run seed:apply`. O nome do banco deve terminar em `_dev` ou `_test`; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 6d24039..52d3db5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2,14 +2,20 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { App } from "./App"; +const { mockUser } = vi.hoisted(() => ({ + mockUser: { id: "test-user", name: "Test User", role: "po" as "admin" | "po" | "dev" }, +})); + vi.mock("./auth/Auth", () => ({ useAuth: () => ({ - session: { status: "authenticated", user: { id: "test-user", name: "Test User" } }, + session: { status: "authenticated", user: mockUser }, logout: vi.fn(), }), })); beforeEach(() => { + mockUser.role = "po"; + window.history.replaceState(null, "", "/"); vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("services unavailable in unit test")))); }); @@ -26,6 +32,34 @@ test("renders the landing page view as main default view", () => { expect(screen.getByText("Pilares do Ecossistema")).toBeInTheDocument(); }); +test("brand is a keyboard-accessible link to projects", () => { + render(); + const brand = screen.getByRole("link", { name: /NAPSE/i }); + expect(brand).toHaveAttribute("href", "/projects"); + fireEvent.click(brand); + expect(window.location.pathname).toBe("/projects"); +}); + +test("shows administration navigation only to admins", () => { + const { rerender } = render(); + expect(screen.queryByRole("button", { name: "Abrir administração" })).not.toBeInTheDocument(); + + mockUser.role = "admin"; + rerender(); + expect(screen.getByRole("button", { name: "Abrir administração" })).toBeInTheDocument(); +}); + +test("blocks direct navigation to administration for non-admin users", () => { + window.history.replaceState(null, "", "/admin"); + const fetchMock = vi.mocked(fetch); + render(); + + expect(screen.getByRole("heading", { name: "Acesso restrito" })).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalledWith("/api/v1/admin/stats", expect.anything()); + fireEvent.click(screen.getByRole("button", { name: "Voltar para projetos" })); + expect(window.location.pathname).toBe("/projects"); +}); + test("allows navigating from landing page using action buttons", () => { render(); const exploreBtn = screen.getByRole("button", { name: "Explorar Projetos" }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6830f17..001eee3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ export const App: React.FC = () => { const { session, logout } = useAuth(); const [pathname, setPathname] = useState(window.location.pathname); + const isAdmin = session.user?.role === "admin"; const activeTab = isProjectPath(pathname) ? "projects" : (Object.keys(routes) as Array).find((key) => routes[key] === pathname) || "projects"; @@ -31,7 +32,7 @@ export const App: React.FC = () => {
{/* Top Header Bar (Protótipo Garakis) */}
- navigate("/projects")}> + { event.preventDefault(); navigate("/projects"); }}> S•NAPSE @@ -63,9 +64,15 @@ export const App: React.FC = () => {
- + {isAdmin ? ( + + ) : ( + + {session.user?.nome || session.user?.name || "Cauan Gabriel"} · {session.user?.role?.toUpperCase() || "PO"} + + )} + + ))} {activeTab === "architecture" && } {activeTab === "landing" && } diff --git a/frontend/src/assets/styles/garakis-prototype.css b/frontend/src/assets/styles/garakis-prototype.css index 8741fcb..fa11fc4 100644 --- a/frontend/src/assets/styles/garakis-prototype.css +++ b/frontend/src/assets/styles/garakis-prototype.css @@ -118,7 +118,8 @@ h2 { color: #fff; } -.user-menu-btn { +.user-menu-btn, +.user-menu-label { color: var(--muted); border: 0; background: transparent; @@ -126,6 +127,13 @@ h2 { font-weight: 600; padding: 6px 12px; border-radius: 8px; +} + +.user-menu-label { + cursor: default; +} + +.user-menu-btn { transition: background 0.15s ease; } @@ -579,7 +587,8 @@ h2 { .top-header { padding: 0 18px; } - .user-menu-btn { + .user-menu-btn, + .user-menu-label { display: none; } .nav-links { @@ -628,7 +637,8 @@ h2 { margin-left: auto; } - .user-menu-btn { + .user-menu-btn, + .user-menu-label { max-width: 150px; overflow: hidden; text-overflow: ellipsis; diff --git a/frontend/src/views/auth/AuthView.tsx b/frontend/src/views/auth/AuthView.tsx index 22fbe11..119e5b1 100644 --- a/frontend/src/views/auth/AuthView.tsx +++ b/frontend/src/views/auth/AuthView.tsx @@ -76,13 +76,13 @@ export function AuthScreen({ initialMode }: { initialMode: "login" | "register" )}
- {mode === "login" ? switchMode("register")} /> : switchMode("login")} />} + {mode === "login" ? : switchMode("login")} />} ); } -export function LoginOnCard({ onSwitch }: { onSwitch: () => void }) { +export function LoginOnCard() { const { login, notice } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); diff --git a/frontend/src/views/projects/RepoAnalyzerView.test.tsx b/frontend/src/views/projects/RepoAnalyzerView.test.tsx index c0c1e60..a6929ce 100644 --- a/frontend/src/views/projects/RepoAnalyzerView.test.tsx +++ b/frontend/src/views/projects/RepoAnalyzerView.test.tsx @@ -58,6 +58,7 @@ it("alterna o relatório entre leitura e Markdown bruto", async () => { await screen.findByRole("heading", { name: "Título" }); fireEvent.click(screen.getByRole("button", { name: "Markdown" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true")); expect(screen.queryByRole("heading", { name: "Título" })).toBeNull(); expect(screen.getByText(/# Título/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1ddf9a6..4eec3d4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,12 @@ /// import { defineConfig } from "vitest/config"; +import { loadEnv } from "vite"; import react from "@vitejs/plugin-react"; // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, ".", "VITE_"); + return { plugins: [react()], test: { environment: "jsdom", @@ -14,13 +17,14 @@ export default defineConfig({ port: 5173, proxy: { "/api": { - target: "http://localhost:3001", + target: env.VITE_API_PROXY_TARGET ?? "http://localhost:3001", changeOrigin: true, }, "/health": { - target: "http://localhost:3001", + target: env.VITE_API_PROXY_TARGET ?? "http://localhost:3001", changeOrigin: true, }, }, }, + }; });