From 1e558a4de2692f391cef5e29a74ee153e5acb6bf Mon Sep 17 00:00:00 2001 From: Daniel Dias Pereira <86617987+DanielDPereira@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:32:15 -0300 Subject: [PATCH 1/8] feat(backend): add justification rule and audit history for concluded items (S1-24) --- backend/src/index.ts | 2 + .../modules/audit/audit.justificativa.test.ts | 235 ++++++++++++++++++ backend/src/modules/audit/audit.routes.ts | 25 ++ backend/src/modules/audit/audit.service.ts | 36 +++ backend/src/modules/epics/epics.service.ts | 6 + .../src/modules/features/features.service.ts | 9 + backend/src/modules/pbis/pbis.repository.ts | 6 + backend/src/modules/pbis/pbis.service.ts | 9 + .../quality.configuration.repository.ts | 3 + .../src/modules/quality/quality.controller.ts | 1 + backend/src/modules/quality/quality.types.ts | 2 + 11 files changed, 334 insertions(+) create mode 100644 backend/src/modules/audit/audit.justificativa.test.ts create mode 100644 backend/src/modules/audit/audit.routes.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 5b95ab0..072c5fb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,7 @@ import { searchRouter } from "./modules/search/search.routes.js"; import { chatRouter } from "./modules/chat/chat.routes.js"; import { developersRouter } from "./modules/developers/developers.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js"; +import { auditRouter } from "./modules/audit/audit.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; import { requireAuth } from "./middleware/requireAuth.js"; @@ -57,6 +58,7 @@ app.use("/api/v1/features", requireAuth, featuresRouter); app.use("/api/v1/pbis", requireAuth, pbisRouter); app.use("/api/v1/criteria", requireAuth, criteriaRouter); app.use("/api/v1/quality", qualityRouter); +app.use("/api/v1/audit", auditRouter); // Documentos do projeto app.use("/api/v1/projects/:projectId/documents", documentsRouter); diff --git a/backend/src/modules/audit/audit.justificativa.test.ts b/backend/src/modules/audit/audit.justificativa.test.ts new file mode 100644 index 0000000..08a532e --- /dev/null +++ b/backend/src/modules/audit/audit.justificativa.test.ts @@ -0,0 +1,235 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EpicsService } from "../epics/epics.service.js"; +import { FeaturesService } from "../features/features.service.js"; +import { PbisService } from "../pbis/pbis.service.js"; +import { ValidationError } from "../../shared/errors.js"; +import { PbiQualityConfigurationRecord } from "../quality/quality.types.js"; + +const PROJETO_ID = "d0000000-0000-4000-8000-000000000001"; +const EPICO_ID = "a0000000-0000-4000-8000-000000000001"; +const FEATURE_ID = "f0000000-0000-4000-8000-000000000001"; +const PBI_ID = "b0000000-0000-4000-8000-000000000001"; + +class StubQualityConfigRepo { + public config: PbiQualityConfigurationRecord = { + rule_version: "pbi-quality-v1", + checks: { + titulo_infinitivo: true, + historia_completa: true, + cenario_estruturado: true, + termos_vagos: true, + prototipo_vinculado: true, + }, + vague_terms: ["adequado"], + exigir_justificativa_item_concluido: true, + updated_at: new Date().toISOString(), + updated_by: null, + }; + + async getPbiConfiguration() { + return this.config; + } +} + +class StubProjectsRepo { + async findById(id: string) { + if (id === PROJETO_ID) { + return { id: PROJETO_ID, status: "ativo" } as any; + } + return null; + } +} + +class StubEpicsRepo { + public epic: any = { + id: EPICO_ID, + projeto_id: PROJETO_ID, + titulo: "Título Épico", + descricao: "Descrição", + objetivo: "Objetivo", + escopo_macro: "Escopo", + resultado_esperado: "Resultado", + status: "rascunho", + projeto_status: "ativo", + }; + + async findById(id: string) { + if (id === EPICO_ID) return this.epic; + return null; + } + + async update(id: string, data: any) { + this.epic = { ...this.epic, ...data }; + return this.epic; + } +} + +class StubFeaturesRepo { + public feature: any = { + id: FEATURE_ID, + epico_id: EPICO_ID, + projeto_id: PROJETO_ID, + titulo: "Título Feature", + descricao: "Descrição", + objetivo: "Objetivo", + status: "rascunho", + projeto_status: "ativo", + }; + + async findById(id: string) { + if (id === FEATURE_ID) return this.feature; + return null; + } + + async update(id: string, data: any) { + this.feature = { ...this.feature, ...data }; + return this.feature; + } +} + +class StubPbisRepo { + public pbi: any = { + id: PBI_ID, + feature_id: FEATURE_ID, + projeto_id: PROJETO_ID, + titulo: "Cadastrar item", + historia_como_um: "PO", + historia_eu_quero: "Cadastrar", + historia_para_que: "Manter", + status: "rascunho", + projeto_status: "ativo", + }; + + async findById(id: string) { + if (id === PBI_ID) return this.pbi; + return null; + } + + async update(id: string, data: any) { + this.pbi = { ...this.pbi, ...data }; + return this.pbi; + } +} + +class StubQualityService { + async validatePbi() { + return { score_completude: 100 } as any; + } +} + +test("S1-24: Rascunhos salvam sem exigir justificativa", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + // Status is rascunho + epicsRepo.epic.status = "rascunho"; + featuresRepo.feature.status = "rascunho"; + pbisRepo.pbi.status = "rascunho"; + + // Updating without justification should succeed for rascunhos + const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Título Épico Alterado" }); + assert.equal(epicUpdated.titulo, "Título Épico Alterado"); + + const featureUpdated = await featuresService.update(FEATURE_ID, { titulo: "Título Feature Alterado" }); + assert.equal(featureUpdated.titulo, "Título Feature Alterado"); + + const pbiUpdated = await pbisService.update(PBI_ID, { titulo: "Cadastrar novo item" }); + assert.equal(pbiUpdated.titulo, "Cadastrar novo item"); +}); + +test("S1-24: Política ativa bloqueia alteração de item concluído sem justificativa", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + qualityConfigRepo.config.exigir_justificativa_item_concluido = true; + + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + // Set status to concluido + epicsRepo.epic.status = "concluido"; + featuresRepo.feature.status = "concluido"; + pbisRepo.pbi.status = "concluido"; + + // Attempting to update without justification must throw ValidationError + await assert.rejects( + () => epicsService.update(EPICO_ID, { titulo: "Novo Épico Concluído" }), + (err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"), + ); + + await assert.rejects( + () => featuresService.update(FEATURE_ID, { titulo: "Nova Feature Concluída" }), + (err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"), + ); + + await assert.rejects( + () => pbisService.update(PBI_ID, { titulo: "Consultar item cadastrado" }), + (err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"), + ); +}); + +test("S1-24: Permitir salvar alteração de item concluído quando justificativa é fornecida", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + qualityConfigRepo.config.exigir_justificativa_item_concluido = true; + + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + epicsRepo.epic.status = "concluido"; + featuresRepo.feature.status = "concluido"; + pbisRepo.pbi.status = "concluido"; + + // Updating WITH justification must succeed + const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Épico Reajustado", justificativa: "Ajuste solicitado pelo cliente" }); + assert.equal(epicUpdated.titulo, "Épico Reajustado"); + + const featureUpdated = await featuresService.update(FEATURE_ID, { titulo: "Feature Reajustada", justificativa: "Mudança no escopo de integração" }); + assert.equal(featureUpdated.titulo, "Feature Reajustada"); + + const pbiUpdated = await pbisService.update(PBI_ID, { titulo: "Alterar item concluído", justificativa: "Correção ortográfica" }); + assert.equal(pbiUpdated.titulo, "Alterar item concluído"); +}); + +test("S1-24: Política desativada permite salvar item concluído sem justificativa", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + qualityConfigRepo.config.exigir_justificativa_item_concluido = false; + + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + epicsRepo.epic.status = "concluido"; + featuresRepo.feature.status = "concluido"; + pbisRepo.pbi.status = "concluido"; + + // When policy is disabled, saving without justification passes + const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Épico Sem Justificativa Obrigatória" }); + assert.equal(epicUpdated.titulo, "Épico Sem Justificativa Obrigatória"); +}); diff --git a/backend/src/modules/audit/audit.routes.ts b/backend/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..7138e9a --- /dev/null +++ b/backend/src/modules/audit/audit.routes.ts @@ -0,0 +1,25 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { auditService } from "./audit.service.js"; +import { requireAuth } from "../../middleware/requireAuth.js"; +import { validateUuid } from "../../shared/errors.js"; + +export const auditRouter = Router(); + +auditRouter.use(requireAuth); + +function getStringParam(param: string | string[] | undefined): string { + if (Array.isArray(param)) return param[0] ?? ""; + return param ?? ""; +} + +auditRouter.get("/:entidade_tipo/:entidade_id/history", async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const entidadeTipo = getStringParam(req.params.entidade_tipo); + const entidadeId = getStringParam(req.params.entidade_id); + validateUuid(entidadeId, "ID da entidade"); + const history = await auditService.getHistory(entidadeTipo, entidadeId); + res.status(200).json({ items: history }); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/audit/audit.service.ts b/backend/src/modules/audit/audit.service.ts index d34a05c..922c9d6 100644 --- a/backend/src/modules/audit/audit.service.ts +++ b/backend/src/modules/audit/audit.service.ts @@ -10,6 +10,18 @@ export interface AuditEntry { dados_json: Record; } +export interface AuditHistoryRecord { + id: string; + usuario_id: string | null; + usuario_nome: string | null; + entidade_tipo: string; + entidade_id: string; + acao: string; + justificativa: string | null; + dados_json: Record; + created_at: string; +} + export class AuditService { private db: Pool | PoolClient; @@ -41,6 +53,30 @@ export class AuditService { await executor.query(query, values); } + + async getHistory(entidadeTipo: string, entidadeId: string): Promise { + const query = ` + SELECT + a.id, + a.usuario_id, + u.nome AS usuario_nome, + a.entidade_tipo, + a.entidade_id, + a.acao, + a.justificativa, + a.dados_json, + a.created_at + FROM auditoria a + LEFT JOIN usuario u ON u.id = a.usuario_id + WHERE a.entidade_tipo = $1 AND a.entidade_id = $2 + ORDER BY a.created_at DESC + `; + const result = await this.db.query(query, [entidadeTipo, entidadeId]); + return result.rows.map((row) => ({ + ...row, + created_at: new Date(row.created_at).toISOString(), + })); + } } export const auditService = new AuditService(); diff --git a/backend/src/modules/epics/epics.service.ts b/backend/src/modules/epics/epics.service.ts index 9dc5de8..5f218d8 100644 --- a/backend/src/modules/epics/epics.service.ts +++ b/backend/src/modules/epics/epics.service.ts @@ -1,12 +1,14 @@ import { createEpicSchema, updateEpicSchema, epicQuerySchema, Epic, EpicWithStats, PaginatedEpics, EPIC_REQUIRED_FIELDS } from "./epics.types.js"; import { EpicsRepository, epicsRepository } from "./epics.repository.js"; import { ProjectsRepository, projectsRepository } from "../projects/projects.repository.js"; +import { QualityConfigurationRepository, qualityConfigurationRepository } from "../quality/quality.configuration.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; export class EpicsService { constructor( private readonly repository: EpicsRepository = epicsRepository, private readonly projectsRepo: ProjectsRepository = projectsRepository, + private readonly qualityConfigRepo: QualityConfigurationRepository = qualityConfigurationRepository, ) {} private async ensureWritable(epic: Epic): Promise { @@ -81,6 +83,10 @@ export class EpicsService { if (EPIC_REQUIRED_FIELDS.some((field) => !String(merged[field] ?? "").trim())) { throw new ValidationError("Não é possível remover campos obrigatórios de um épico concluído."); } + const config = await this.qualityConfigRepo.getPbiConfiguration(); + if (config.exigir_justificativa_item_concluido && !parseResult.data.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído.", { code: "JUSTIFICATIVA_REQUERIDA", campo: "justificativa" }); + } } const updated = await this.repository.update(id, parseResult.data, usuarioId); if (!updated) { diff --git a/backend/src/modules/features/features.service.ts b/backend/src/modules/features/features.service.ts index 2e343e5..d032fd8 100644 --- a/backend/src/modules/features/features.service.ts +++ b/backend/src/modules/features/features.service.ts @@ -1,12 +1,14 @@ import { createFeatureSchema, updateFeatureSchema, featureQuerySchema, Feature, FeatureWithStats, PaginatedFeatures, FEATURE_REQUIRED_FIELDS } from "./features.types.js"; import { FeaturesRepository, featuresRepository } from "./features.repository.js"; import { EpicsRepository, epicsRepository } from "../epics/epics.repository.js"; +import { QualityConfigurationRepository, qualityConfigurationRepository } from "../quality/quality.configuration.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; export class FeaturesService { constructor( private readonly repository: FeaturesRepository = featuresRepository, private readonly epicsRepo: EpicsRepository = epicsRepository, + private readonly qualityConfigRepo: QualityConfigurationRepository = qualityConfigurationRepository, ) {} async create(input: unknown, usuarioId?: string | null): Promise { @@ -68,6 +70,13 @@ export class FeaturesService { throw new ValidationError(issue.message, parseResult.error.format()); } + if (existing.status === "concluido") { + const config = await this.qualityConfigRepo.getPbiConfiguration(); + if (config.exigir_justificativa_item_concluido && !parseResult.data.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído.", { code: "JUSTIFICATIVA_REQUERIDA", campo: "justificativa" }); + } + } + const updated = await this.repository.update(id, parseResult.data, usuarioId); if (!updated) { throw new NotFoundError("Feature não encontrada."); diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts index a14aca9..6a079ee 100644 --- a/backend/src/modules/pbis/pbis.repository.ts +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -172,6 +172,12 @@ export class PbisRepository { client, ); + await client.query( + `INSERT INTO pbi_versao (pbi_id, versao, snapshot_json, justificativa, autor_id, created_at) + VALUES ($1, (SELECT COALESCE(MAX(versao), 0) + 1 FROM pbi_versao WHERE pbi_id = $1), $2, $3, $4, CURRENT_TIMESTAMP)`, + [id, JSON.stringify(updated), data.justificativa ?? "", usuarioId ?? null], + ); + await client.query("COMMIT"); return await this.findById(id); } catch (error) { diff --git a/backend/src/modules/pbis/pbis.service.ts b/backend/src/modules/pbis/pbis.service.ts index f56e4ac..5d981cd 100644 --- a/backend/src/modules/pbis/pbis.service.ts +++ b/backend/src/modules/pbis/pbis.service.ts @@ -2,6 +2,7 @@ import { createPbiSchema, updatePbiSchema, pbiQuerySchema, Pbi, PbiWithContext, import { PbisRepository, pbisRepository } from "./pbis.repository.js"; import { FeaturesRepository, featuresRepository } from "../features/features.repository.js"; import { QualityService, qualityService, RelatorioQualidadePbi } from "../quality/quality.service.js"; +import { QualityConfigurationRepository, qualityConfigurationRepository } from "../quality/quality.configuration.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; const COMPLETION_BLOCKING_FIELDS: Record = { @@ -15,6 +16,7 @@ export class PbisService { private readonly repository: PbisRepository = pbisRepository, private readonly featuresRepo: FeaturesRepository = featuresRepository, private readonly qualityChecker: QualityService = qualityService, + private readonly qualityConfigRepo: QualityConfigurationRepository = qualityConfigurationRepository, ) {} async create(input: unknown, usuarioId?: string | null): Promise { @@ -97,6 +99,13 @@ export class PbisService { throw new ValidationError(issue.message, parseResult.error.format()); } + if (existing.status === "concluido") { + const config = await this.qualityConfigRepo.getPbiConfiguration(); + if (config.exigir_justificativa_item_concluido && !parseResult.data.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído.", { code: "JUSTIFICATIVA_REQUERIDA", campo: "justificativa" }); + } + } + const updated = await this.repository.update( id, parseResult.data, diff --git a/backend/src/modules/quality/quality.configuration.repository.ts b/backend/src/modules/quality/quality.configuration.repository.ts index 2e30cc6..d1e90e0 100644 --- a/backend/src/modules/quality/quality.configuration.repository.ts +++ b/backend/src/modules/quality/quality.configuration.repository.ts @@ -18,6 +18,7 @@ type ConfigurationRow = { configuration: { checks: Record; vague_terms: string[]; + exigir_justificativa_item_concluido?: boolean; }; updated_at: Date | string; updated_by: string | null; @@ -223,6 +224,8 @@ export class QualityConfigurationRepository { vague_terms: raw.vague_terms ?? [...TERMOS_VAGOS_PADRAO], + exigir_justificativa_item_concluido: + raw.exigir_justificativa_item_concluido ?? true, updated_at: new Date(row.updated_at).toISOString(), updated_by: diff --git a/backend/src/modules/quality/quality.controller.ts b/backend/src/modules/quality/quality.controller.ts index 594f4f4..dfa1d43 100644 --- a/backend/src/modules/quality/quality.controller.ts +++ b/backend/src/modules/quality/quality.controller.ts @@ -12,6 +12,7 @@ const configurationSchema = z.object({ prototipo_vinculado: z.boolean(), }).strict(), vague_terms: z.array(z.string().trim().min(1).max(80)).max(100), + exigir_justificativa_item_concluido: z.boolean().optional(), }).strict(); function getParamId(param: string | string[] | undefined): string { diff --git a/backend/src/modules/quality/quality.types.ts b/backend/src/modules/quality/quality.types.ts index d9bd8ad..962111b 100644 --- a/backend/src/modules/quality/quality.types.ts +++ b/backend/src/modules/quality/quality.types.ts @@ -33,10 +33,12 @@ export interface PbiQualityRuleConfiguration { export interface PbiQualityConfigurationInput { checks: Record; vague_terms: string[]; + exigir_justificativa_item_concluido?: boolean; } export interface PbiQualityConfigurationRecord extends PbiQualityConfigurationInput { rule_version: string; + exigir_justificativa_item_concluido: boolean; updated_at: string; updated_by: { id: string; nome: string } | null; } From 083f27ea055eafb3dcacc5371efe35d39a8d123b Mon Sep 17 00:00:00 2001 From: Daniel Dias Pereira <86617987+DanielDPereira@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:48:06 -0300 Subject: [PATCH 2/8] feat(frontend): add justification form and audit history view for backlog items (S1-24) --- frontend/src/api/api_backlog.ts | 29 +++++ frontend/src/backlog/Justificativa.test.tsx | 107 ++++++++++++++++ frontend/src/views/backlog/EpicsView.tsx | 35 +++++- frontend/src/views/backlog/FeaturesView.tsx | 35 +++++- .../src/views/backlog/ItemHistoryView.tsx | 116 ++++++++++++++++++ frontend/src/views/backlog/PbisView.tsx | 50 +++++++- 6 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 frontend/src/backlog/Justificativa.test.tsx create mode 100644 frontend/src/views/backlog/ItemHistoryView.tsx diff --git a/frontend/src/api/api_backlog.ts b/frontend/src/api/api_backlog.ts index d1d9101..fa2ae5a 100644 --- a/frontend/src/api/api_backlog.ts +++ b/frontend/src/api/api_backlog.ts @@ -20,6 +20,7 @@ export interface EpicInput { objetivo: string; escopo_macro: string; resultado_esperado: string; + justificativa?: string | null; } export interface Epic extends EpicInput { @@ -37,6 +38,7 @@ export interface FeatureInput { titulo: string; descricao: string; objetivo: string; + justificativa?: string | null; } export interface Feature extends FeatureInput { @@ -58,6 +60,8 @@ export interface PbiInput { historia_eu_quero: string; historia_para_que: string; requer_interface: boolean; + regras_observacoes?: string | null; + justificativa?: string | null; } export interface Pbi extends PbiInput { @@ -888,4 +892,29 @@ export async function moveCriterion( } return data.items.map(parseCriterion); +} + +export interface AuditHistoryItem { + id: string; + usuario_id: string | null; + usuario_nome: string | null; + entidade_tipo: string; + entidade_id: string; + acao: string; + justificativa: string | null; + dados_json: Record; + created_at: string; +} + +export async function getItemHistory( + entidadeTipo: string, + entidadeId: string, + signal?: AbortSignal, +): Promise { + const response = await apiRequest( + `/audit/${encodeURIComponent(entidadeTipo)}/${encodeURIComponent(entidadeId)}/history`, + { signal }, + ); + const data = await response.json(); + return Array.isArray(data.items) ? data.items : []; } \ No newline at end of file diff --git a/frontend/src/backlog/Justificativa.test.tsx b/frontend/src/backlog/Justificativa.test.tsx new file mode 100644 index 0000000..7ba24a1 --- /dev/null +++ b/frontend/src/backlog/Justificativa.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { ItemHistoryView } from "../views/backlog/ItemHistoryView"; +import { EpicDetail } from "../views/backlog/EpicsView"; +import { FeatureDetail } from "../views/backlog/FeaturesView"; + +const epicConcluido = { + id: "epic-c1", + projeto_id: "project-1", + titulo: "Épico Concluído", + descricao: "Descrição", + objetivo: "Objetivo", + escopo_macro: "Escopo", + resultado_esperado: "Resultado", + status: "concluido" as const, + prioridade: "Must" as const, + features_count: 0, + criterios_count: 1, + projeto_status: "ativo", + archived_at: null, +}; + +const featureConcluida = { + id: "feat-c1", + epico_id: "epic-c1", + projeto_id: "project-1", + epico_titulo: "Épico Concluído", + titulo: "Feature Concluída", + descricao: "Descrição", + objetivo: "Objetivo", + status: "concluido" as const, + prioridade: "Must" as const, + pbis_count: 0, + criterios_count: 1, + projeto_status: "ativo", + archived_at: null, +}; + +const reply = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn((url: string) => { + if (url.includes("/history")) { + return reply({ + items: [ + { + id: "hist-1", + usuario_id: "user-1", + usuario_nome: "Maria PO", + entidade_tipo: "epico", + entidade_id: "epic-c1", + acao: "ATUALIZAR_EPICO", + justificativa: "Ajuste na regra de negócio solicitado pelo cliente", + dados_json: {}, + created_at: new Date().toISOString(), + }, + ], + }); + } + if (url.includes("/criteria")) return reply({ items: [] }); + if (url.includes("/quality")) return reply({ checks: {}, vague_terms: [], exigir_justificativa_item_concluido: true }); + if (url.includes("/epics/epic-c1")) return reply(epicConcluido); + if (url.includes("/features/feat-c1")) return reply(featureConcluida); + return reply({}); + })); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("ItemHistoryView renderiza histórico com autor, data e justificativa", async () => { + render(); + expect(await screen.findByText("Maria PO")).toBeTruthy(); + expect(screen.getByText(/Ajuste na regra de negócio/i)).toBeTruthy(); +}); + +it("EpicDetail exige justificativa ao alterar épico concluído", async () => { + render(); + + const editBtn = await screen.findByText("Editar"); + fireEvent.click(editBtn); + + expect(screen.getByLabelText(/Justificativa da alteração/i)).toBeTruthy(); + + const saveBtn = screen.getByText("Salvar alterações"); + fireEvent.click(saveBtn); + + expect(await screen.findByText("A justificativa é obrigatória ao alterar um item concluído.")).toBeTruthy(); +}); + +it("FeatureDetail exige justificativa ao alterar feature concluída", async () => { + render(); + + const editBtn = await screen.findByText("Editar"); + fireEvent.click(editBtn); + + expect(screen.getByLabelText(/Justificativa da alteração/i)).toBeTruthy(); + + const saveBtn = screen.getByText("Salvar alterações"); + fireEvent.click(saveBtn); + + expect(await screen.findByText("A justificativa é obrigatória ao alterar um item concluído.")).toBeTruthy(); +}); diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index cced8e3..8e8f455 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -107,10 +107,12 @@ export function EpicForm({ projetoId }: { projetoId: string }) { ); } -type EpicFields = Pick; +import { ItemHistoryView } from "./ItemHistoryView"; + +type EpicFields = Pick & { justificativa?: string }; function toFields(epic: Epic): EpicFields { - return { titulo: epic.titulo, descricao: epic.descricao, objetivo: epic.objetivo, escopo_macro: epic.escopo_macro, resultado_esperado: epic.resultado_esperado }; + return { titulo: epic.titulo, descricao: epic.descricao, objetivo: epic.objetivo, escopo_macro: epic.escopo_macro, resultado_esperado: epic.resultado_esperado, justificativa: "" }; } export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId: string; epicId: string; canEdit: boolean; children?: ReactNode }) { @@ -179,17 +181,40 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId : setFormValues((v) => v && { ...v, [field]: e.target.value })} />} ))} + {epic.status === "concluido" && ( +
+ +