Skip to content
Open
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
15 changes: 14 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import cors from "cors";
import { env } from "./config/env.js";
import { checkDatabaseConnection } from "./database/db.js";
import { projectsRouter } from "./modules/projects/projects.routes.js";
import { epicsRouter } from "./modules/epics/epics.routes.js";
import { featuresRouter } from "./modules/features/features.routes.js";
import { pbisRouter } from "./modules/pbis/pbis.routes.js";
import { criteriaRouter } from "./modules/criteria/criteria.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { errorHandler } from "./middleware/errorHandler.js";
import { requireAuth } from "./middleware/requireAuth.js";
Expand Down Expand Up @@ -34,6 +38,12 @@ app.get("/health", async (_req: Request, res: Response) => {
app.use("/api/v1/projects", requireAuth, projectsRouter);
app.use("/api/projects", requireAuth, projectsRouter);

// Hierarquia do backlog: épicos, features, PBIs e critérios de aceitação (S1-05/06/07/10)
app.use("/api/v1/epics", requireAuth, epicsRouter);
app.use("/api/v1/features", requireAuth, featuresRouter);
app.use("/api/v1/pbis", requireAuth, pbisRouter);
app.use("/api/v1/criteria", requireAuth, criteriaRouter);

// Root Information Endpoint
app.get("/api/v1", (_req: Request, res: Response) => {
res.json({
Expand All @@ -43,7 +53,10 @@ app.get("/api/v1", (_req: Request, res: Response) => {
documentation: "/docs",
modules: [
{ name: "projects", status: "ready" },
{ name: "requirements", status: "in_development" },
{ name: "epics", status: "ready" },
{ name: "features", status: "ready" },
{ name: "pbis", status: "ready" },
{ name: "criteria", status: "ready" },
{ name: "decisions", status: "in_development" },
{ name: "ai-bridge", status: "ready" },
],
Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "../modules/projects/projects.service.js";
import { AppError } from "../shared/errors.js";

export function errorHandler(
err: Error,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/requireRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function requireRole(...roles: UserRole[]) {
return;
}
if (!roles.includes(req.auth.role)) {
res.status(403).json({ error: "Seu perfil não permite alterar projetos.", code: "FORBIDDEN" });
res.status(403).json({ error: "Seu perfil não permite realizar esta operação.", code: "FORBIDDEN" });
return;
}
next();
Expand Down
43 changes: 43 additions & 0 deletions backend/src/modules/criteria/criteria.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request, Response, NextFunction } from "express";
import { criteriaService, CriteriaService } from "./criteria.service.js";

function getParamId(param: string | string[] | undefined): string {
if (Array.isArray(param)) return param[0] ?? "";
return param ?? "";
}

export class CriteriaController {
constructor(private readonly service: CriteriaService = criteriaService) {}

create = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const usuarioId = req.auth?.id ?? null;
const result = await this.service.create(req.body, usuarioId);
res.status(201).json(result);
} catch (error) {
next(error);
}
};

list = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.service.list(req.query);
res.status(200).json({ items: result });
} catch (error) {
next(error);
}
};

delete = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const id = getParamId(req.params.id);
const usuarioId = req.auth?.id ?? null;
const result = await this.service.delete(id, usuarioId);
res.status(200).json(result);
} catch (error) {
next(error);
}
};
}

export const criteriaController = new CriteriaController();
141 changes: 141 additions & 0 deletions backend/src/modules/criteria/criteria.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Pool, PoolClient } from "pg";
import { pool } from "../../database/db.js";
import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js";
import { auditService } from "../audit/audit.service.js";

const ENTITY_TABLE: Record<CriterionEntityType, string> = {
epico: "epico",
feature: "feature",
pbi: "pbi",
};

export class CriteriaRepository {
private pool: Pool;

constructor(customPool?: Pool) {
this.pool = customPool ?? pool;
}

async entityExists(tipo: CriterionEntityType, id: string): Promise<boolean> {
const table = ENTITY_TABLE[tipo];
const result = await this.pool.query(`SELECT 1 FROM ${table} WHERE id = $1`, [id]);
return (result.rowCount ?? 0) > 0;
}

async findById(id: string): Promise<Criterion | null> {
const result = await this.pool.query<Criterion>(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]);
return result.rows[0] ?? null;
}

async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise<Criterion[]> {
const result = await this.pool.query<Criterion>(
`SELECT * FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2 ORDER BY ordem ASC`,
[tipo, entidadeId],
);
return result.rows;
}

async countByEntity(tipo: CriterionEntityType, entidadeId: string): Promise<number> {
const result = await this.pool.query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2`,
[tipo, entidadeId],
);
return result.rows[0]?.total ?? 0;
}

async create(dto: CreateCriterionDTO, usuarioId?: string | null): Promise<Criterion> {
const client: PoolClient = await this.pool.connect();

try {
await client.query("BEGIN");

const ordemResult = await client.query<{ proxima_ordem: number }>(
`SELECT COALESCE(MAX(ordem), 0) + 1 AS proxima_ordem FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2`,
[dto.entidade_tipo, dto.entidade_id],
);
const ordem = ordemResult.rows[0]?.proxima_ordem ?? 1;

const isScenario = dto.entidade_tipo === "pbi";
const insertQuery = `
INSERT INTO criterio_aceitacao (entidade_tipo, entidade_id, texto, nome, dado, quando, entao, ordem)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const values = [
dto.entidade_tipo,
dto.entidade_id,
isScenario ? null : dto.texto,
isScenario ? dto.nome : null,
isScenario ? dto.dado : null,
isScenario ? dto.quando : null,
isScenario ? dto.entao : null,
ordem,
];

const result = await client.query<Criterion>(insertQuery, values);
const created = result.rows[0];

await auditService.record(
{
usuario_id: usuarioId ?? null,
entidade_tipo: dto.entidade_tipo,
entidade_id: dto.entidade_id,
acao: "ADICIONAR_CRITERIO",
dados_json: { criterio_id: created.id, ordem: created.ordem, nome: created.nome, texto: created.texto },
},
client,
);

await client.query("COMMIT");
return created;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}

async delete(id: string, usuarioId?: string | null): Promise<Criterion | null> {
const client: PoolClient = await this.pool.connect();

try {
await client.query("BEGIN");

const existing = await client.query<Criterion>(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]);
const removed = existing.rows[0];
if (!removed) {
await client.query("ROLLBACK");
return null;
}

await client.query(`DELETE FROM criterio_aceitacao WHERE id = $1`, [id]);

await client.query(
`UPDATE criterio_aceitacao SET ordem = ordem - 1 WHERE entidade_tipo = $1 AND entidade_id = $2 AND ordem > $3`,
[removed.entidade_tipo, removed.entidade_id, removed.ordem],
);

await auditService.record(
{
usuario_id: usuarioId ?? null,
entidade_tipo: removed.entidade_tipo,
entidade_id: removed.entidade_id,
acao: "REMOVER_CRITERIO",
dados_json: { criterio_id: removed.id, ordem: removed.ordem, nome: removed.nome, texto: removed.texto },
},
client,
);

await client.query("COMMIT");
return removed;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}

export const criteriaRepository = new CriteriaRepository();
154 changes: 154 additions & 0 deletions backend/src/modules/criteria/criteria.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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 { CriteriaController } from "./criteria.controller.js";
import { CriteriaService } from "./criteria.service.js";
import { CriteriaRepository } from "./criteria.repository.js";
import { errorHandler } from "../../middleware/errorHandler.js";
import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js";

const PBI_ID = "d0000000-0000-4000-8000-000000000001";

class MockCriteriaRepo extends CriteriaRepository {
public criteria: Criterion[] = [];
private seq = 0;

constructor() { super(); }

async entityExists(_tipo: CriterionEntityType, id: string): Promise<boolean> {
return id === PBI_ID;
}

async findById(id: string): Promise<Criterion | null> {
return this.criteria.find((c) => c.id === id) ?? null;
}

async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise<Criterion[]> {
return this.criteria.filter((c) => c.entidade_tipo === tipo && c.entidade_id === entidadeId).sort((a, b) => a.ordem - b.ordem);
}

async create(dto: CreateCriterionDTO): Promise<Criterion> {
this.seq += 1;
const existentes = await this.listByEntity(dto.entidade_tipo, dto.entidade_id);
const isScenario = dto.entidade_tipo === "pbi";
const created: Criterion = {
id: `e0000000-0000-4000-8000-00000000000${this.seq}`,
entidade_tipo: dto.entidade_tipo,
entidade_id: dto.entidade_id,
texto: isScenario ? null : dto.texto,
nome: isScenario ? dto.nome : null,
dado: isScenario ? dto.dado : null,
quando: isScenario ? dto.quando : null,
entao: isScenario ? dto.entao : null,
ordem: existentes.length + 1,
created_at: new Date().toISOString(),
};
this.criteria.push(created);
return created;
}

async delete(id: string): Promise<Criterion | null> {
const index = this.criteria.findIndex((c) => c.id === id);
if (index === -1) return null;
const [removed] = this.criteria.splice(index, 1);
return removed;
}
}

test("Testes de integração HTTP - Rotas de Critérios de Aceitação", async (t) => {
const repository = new MockCriteriaRepo();
const service = new CriteriaService(repository);
const controller = new CriteriaController(service);

const testApp = express();
testApp.use(express.json());

const router = express.Router();
router.post("/", controller.create);
router.get("/", controller.list);
router.delete("/:id", controller.delete);

testApp.use("/api/v1/criteria", router);
testApp.use(errorHandler);

let server: Server;
let baseUrl: string;

before(async () => {
await new Promise<void>((resolve) => {
server = testApp.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}/api/v1/criteria`;
resolve();
});
});
});

after(async () => {
await new Promise<void>((resolve) => { server.close(() => resolve()); });
});

await t.test("POST /api/v1/criteria - adiciona cenário nomeado de PBI com status 201", async () => {
const res = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entidade_tipo: "pbi",
entidade_id: PBI_ID,
nome: "Cenário via HTTP",
dado: "que eu esteja autenticado",
quando: "eu confirmar",
entao: "o sistema deve responder",
}),
});

assert.equal(res.status, 201);
const body = (await res.json()) as Criterion;
assert.equal(body.nome, "Cenário via HTTP");
});

await t.test("POST /api/v1/criteria - retorna 400 quando o cenário está incompleto", async () => {
const res = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entidade_tipo: "pbi", entidade_id: PBI_ID, nome: "Incompleto", dado: "algo" }),
});

assert.equal(res.status, 400);
});

await t.test("POST /api/v1/criteria - retorna 404 para entidade inexistente", async () => {
const res = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entidade_tipo: "epico", entidade_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", texto: "Critério órfão" }),
});

assert.equal(res.status, 404);
});

await t.test("GET /api/v1/criteria - lista critérios ordenados de uma entidade", async () => {
const res = await fetch(`${baseUrl}?entidade_tipo=pbi&entidade_id=${PBI_ID}`);
assert.equal(res.status, 200);
const body = (await res.json()) as { items: Criterion[] };
assert.equal(body.items.length, 1);
});

await t.test("DELETE /api/v1/criteria/:id - remove critério existente", async () => {
const created = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entidade_tipo: "pbi", entidade_id: PBI_ID, nome: "A remover", dado: "d", quando: "q", entao: "e" }),
}).then((r) => r.json()) as Criterion;

const res = await fetch(`${baseUrl}/${created.id}`, { method: "DELETE" });
assert.equal(res.status, 200);
});

await t.test("DELETE /api/v1/criteria/:id - retorna 404 para critério inexistente", async () => {
const res = await fetch(`${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff`, { method: "DELETE" });
assert.equal(res.status, 404);
});
});
Loading
Loading