Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
4 changes: 2 additions & 2 deletions backend/src/database/seed-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions backend/src/database/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion database/seed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"))));
});

Expand All @@ -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(<App />);
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(<App />);
expect(screen.queryByRole("button", { name: "Abrir administração" })).not.toBeInTheDocument();

mockUser.role = "admin";
rerender(<App />);
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(<App />);

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(<App />);
const exploreBtn = screen.getByRole("button", { name: "Explorar Projetos" });
Expand Down
23 changes: 18 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof typeof routes>).find((key) => routes[key] === pathname) || "projects";
Expand All @@ -31,7 +32,7 @@ export const App: React.FC = () => {
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column", background: "var(--bg)", color: "var(--text)" }}>
{/* Top Header Bar (Protótipo Garakis) */}
<header className="top-header">
<a className="brand" onClick={() => navigate("/projects")}>
<a className="brand" href="/projects" onClick={(event) => { event.preventDefault(); navigate("/projects"); }}>
S<b>•</b>NAPSE
</a>

Expand Down Expand Up @@ -63,9 +64,15 @@ export const App: React.FC = () => {
</nav>

<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<button className="user-menu-btn" onClick={() => navigate("/admin")}>
{session.user?.nome || session.user?.name || "Cauan Gabriel"} · {session.user?.role?.toUpperCase() || "PO"} ▾
</button>
{isAdmin ? (
<button className="user-menu-btn" onClick={() => navigate("/admin")} aria-label="Abrir administração">
{session.user?.nome || session.user?.name || "Cauan Gabriel"} · ADMIN ▾
</button>
) : (
<span className="user-menu-label">
{session.user?.nome || session.user?.name || "Cauan Gabriel"} · {session.user?.role?.toUpperCase() || "PO"}
</span>
)}
<button
className="btn-garakis secondary"
style={{ minHeight: "34px", padding: "0 12px", fontSize: "0.8rem" }}
Expand All @@ -83,7 +90,13 @@ export const App: React.FC = () => {
{activeTab === "documents" && <DocumentsView />}
{activeTab === "knowledge" && <KnowledgeView />}
{(activeTab === "chat" || activeTab === "rag") && <ChatView />}
{activeTab === "admin" && <AdminView />}
{activeTab === "admin" && (isAdmin ? <AdminView /> : (
<section className="page-container" role="alert" aria-labelledby="admin-access-title">
<h1 id="admin-access-title">Acesso restrito</h1>
<p>Esta área está disponível somente para administradores.</p>
<button className="btn-garakis secondary" onClick={() => navigate("/projects")}>Voltar para projetos</button>
</section>
))}
{activeTab === "architecture" && <DeveloperDashboardView />}
{activeTab === "landing" && <LandingPageView />}
</main>
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/assets/styles/garakis-prototype.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,22 @@ h2 {
color: #fff;
}

.user-menu-btn {
.user-menu-btn,
.user-menu-label {
color: var(--muted);
border: 0;
background: transparent;
white-space: nowrap;
font-weight: 600;
padding: 6px 12px;
border-radius: 8px;
}

.user-menu-label {
cursor: default;
}

.user-menu-btn {
transition: background 0.15s ease;
}

Expand Down Expand Up @@ -579,7 +587,8 @@ h2 {
.top-header {
padding: 0 18px;
}
.user-menu-btn {
.user-menu-btn,
.user-menu-label {
display: none;
}
.nav-links {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/auth/AuthView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ export function AuthScreen({ initialMode }: { initialMode: "login" | "register"
)}
</div>

{mode === "login" ? <LoginOnCard onSwitch={() => switchMode("register")} /> : <RegisterOnCard onSwitch={() => switchMode("login")} />}
{mode === "login" ? <LoginOnCard /> : <RegisterOnCard onSwitch={() => switchMode("login")} />}
</section>
</main>
);
}

export function LoginOnCard({ onSwitch }: { onSwitch: () => void }) {
export function LoginOnCard() {
const { login, notice } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Expand Down
1 change: 1 addition & 0 deletions frontend/src/views/projects/RepoAnalyzerView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 7 additions & 3 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/// <reference types="vitest/config" />
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",
Expand All @@ -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,
},
},
},
};
});
Loading