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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 22 additions & 5 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 1 addition & 53 deletions backend/src/database/migration-012.db.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string> {
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<void> {
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<void>): Promise<void> {
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);
Expand Down
38 changes: 38 additions & 0 deletions backend/src/database/migration-013.db.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
55 changes: 55 additions & 0 deletions backend/src/database/migration-test-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string> {
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<void> {
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<void>): Promise<void> {
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();
}
}
11 changes: 11 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading