Files
eden/apps/api/test/contracts-edit-lock.spec.ts
Matheus (Handix) f38f2407ff feat(products): catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade com submenu e dropdowns
Menu Produtos ganha submenu (AppShell agora suporta itens aninhados) para
cadastrar Tipos/Grupos/Marcas/Unidades; Subgrupo é gerenciado a partir da
tela do Grupo (sempre pertence a um). O cadastro de item passa a usar
dropdowns desses catálogos em vez de texto livre — exatamente o que o
comentário original da migration de products já previa como trabalho
futuro.

Backend: 5 tabelas de catálogo simples (mesmo padrão dos catálogos
fiscais), products ganha as FKs correspondentes com backfill, e
endpoints CRUD (sem exclusão física, active=false é o "apagar").

Corrige também um buraco de integridade real encontrado na integração:
PATCH /v1/products/:id usava COALESCE para os campos de catálogo
opcionais, então não dava para distinguir "não mexer" de "limpar" —
trocar o Grupo de um produto sem tocar no Subgrupo deixava o vínculo
antigo órfão e inconsistente, sem nenhuma validação pegando isso. Agora
a atualização valida o estado efetivo resultante (bloqueia com 400 em
vez de corromper o vínculo) e productSubgroupId: null limpa de verdade.

Suíte: 95/95 verdes (92→95). pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:46:14 -03:00

177 lines
5.7 KiB
TypeScript

import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agent,
bearer,
closeTestApp,
createTestApp,
login,
loginSuperAdmin,
qaEmail,
qaKey,
qaName,
type AuthedTokens,
} from "./setup.js";
/**
* Protects the edit-lock invariant ADR-0006 explicitly promises but that
* ContractsService didn't actually enforce until now: "fluxo de
* fechamento de oferta → geração de contrato preserva a trava equivalente
* [...] exceto por papel com permissão de correção auditada equivalente
* ao super_admin do legado" (docs/adr/0006-contract-first-class.md).
* Mirrors the shape of quotes.service.ts's assertNotLocked, ported to
* Contract's own status field (draft = editable, anything past it =
* locked except for super_admin).
*/
describe("contracts — edit lock past 'draft'", () => {
let app: INestApplication;
let admin: AuthedTokens;
let resellerId: string;
let productId: string;
let contractId: string;
let lowRoleKey: string;
let lowActorUserId: string;
let lowActorEmail: string;
let lowActorTokens: AuthedTokens;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
const reseller = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Revenda Contract Lock" })
.expect(201);
resellerId = reseller.body.id;
const productType = await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Tipo Contract Lock") })
.expect(201);
const unit = await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Unidade Contract Lock") })
.expect(201);
const product = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: "qa-test- Produto Contract Lock",
productTypeId: productType.body.id,
unitId: unit.body.id,
price0: 100,
})
.expect(201);
productId = product.body.id;
const contract = await agent(app)
.post("/api/v1/contracts")
.set(...bearer(admin.accessToken))
.send({
resellerId,
contractPeriod: 0,
items: [{ productId, quantity: 1 }],
parties: [{ partyRole: "contratante", fullName: "qa-test Cliente Contract Lock" }],
})
.expect(201);
contractId = contract.body.id;
expect(contract.body.status).toBe("draft");
// Non-super_admin actor holding contracts:edit — isolates the lock
// check itself from the generic PermissionGuard (same pattern as
// role-weight.spec.ts).
lowRoleKey = qaKey("contractlock");
await agent(app)
.post("/api/v1/roles")
.set(...bearer(admin.accessToken))
.send({ key: lowRoleKey, label: "QA test contract-lock role", weight: 10 })
.expect(201);
await agent(app)
.patch(`/api/v1/roles/${lowRoleKey}/permissions`)
.set(...bearer(admin.accessToken))
.send({
permissions: [
{ resource: "contracts", action: "edit", scope: "all" },
{ resource: "contracts", action: "view", scope: "all" },
],
})
.expect(200);
lowActorEmail = qaEmail("contractlock-actor");
const invite = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(admin.accessToken))
.send({ email: lowActorEmail, roleKey: lowRoleKey, applications: ["core"] })
.expect(201);
lowActorUserId = invite.body.user.id;
lowActorTokens = await login(app, lowActorEmail, invite.body.tempPassword);
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/users/${lowActorUserId}`)
.set(...bearer(admin.accessToken))
.send({ roleKey: "user", isActive: false })
.expect(200);
await agent(app)
.delete(`/api/v1/roles/${lowRoleKey}`)
.set(...bearer(admin.accessToken))
.expect(204);
await agent(app)
.patch(`/api/v1/products/${productId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await agent(app)
.patch(`/api/v1/resellers/${resellerId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await closeTestApp(app);
});
it("holding contracts:edit is enough to add an item while the contract is still draft", async () => {
await agent(app)
.post(`/api/v1/contracts/${contractId}/items`)
.set(...bearer(lowActorTokens.accessToken))
.send({ productId, quantity: 1 })
.expect(201);
});
it("once the contract leaves draft, a non-super_admin holding contracts:edit is blocked (409)", async () => {
await agent(app)
.patch(`/api/v1/contracts/${contractId}/status`)
.set(...bearer(admin.accessToken))
.send({ toStatus: "pending_signature" })
.expect(200);
const res = await agent(app)
.post(`/api/v1/contracts/${contractId}/items`)
.set(...bearer(lowActorTokens.accessToken))
.send({ productId, quantity: 1 })
.expect(409);
expect(res.body.message).toMatch(/rascunho/i);
await agent(app)
.post(`/api/v1/contracts/${contractId}/parties`)
.set(...bearer(lowActorTokens.accessToken))
.send({ partyRole: "contratante", fullName: "qa-test Outra Parte" })
.expect(409);
});
it("super_admin can still add an item past draft — an audited override, not a silent bypass", async () => {
const res = await agent(app)
.post(`/api/v1/contracts/${contractId}/items`)
.set(...bearer(admin.accessToken))
.send({ productId, quantity: 1 })
.expect(201);
expect(res.body.product_id).toBe(productId);
});
});