Files
eden/apps/api/test/product-catalogs.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

445 lines
15 KiB
TypeScript

import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaName, type AuthedTokens } from "./setup.js";
/**
* Covers the Tipo/Grupo/Subgrupo/Marca/Unidade catalogs
* (1789694035000_product-catalogs.cjs) that replaced products'
* product_type/product_group/product_subgroup/brand/unit free-TEXT
* columns: create/list/update per catalog, case-insensitive duplicate
* name -> 409, subgroup without groupId -> 400, and the product-creation
* flow referencing these catalogs by ID including the subgroup/group
* mismatch -> 400 case (products.service.ts's assertCatalogReferencesValid).
*
* Every catalog name uses qaName() (random suffix) rather than a fixed
* "qa-test- Foo" literal: these 5 catalogs are enforced by a
* case-insensitive UNIQUE index and have no delete endpoint (active=false
* is the only "apagar"), so a hardcoded name would collide with a row left
* behind by a previous run of this suite against the same shared dev
* Postgres.
*/
describe("product catalogs — types/groups/subgroups/brands/units", () => {
let app: INestApplication;
let admin: AuthedTokens;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
});
afterAll(async () => {
await closeTestApp(app);
});
it("creates, lists and updates a product type", async () => {
const name = qaName("Tipo Catalogo");
const created = await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name })
.expect(201);
expect(created.body.id).toBeTypeOf("string");
expect(created.body.active).toBe(true);
const list = await agent(app)
.get("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.expect(200);
expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true);
const updated = await agent(app)
.patch(`/api/v1/products/types/${created.body.id}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
expect(updated.body.active).toBe(false);
});
it("rejects a duplicate product type name case-insensitively with 409", async () => {
const name = qaName("Tipo Duplicado");
await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name })
.expect(201);
await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name: name.toUpperCase() })
.expect(409);
});
it("creates, lists and updates a product group", async () => {
const name = qaName("Grupo Catalogo");
const created = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name })
.expect(201);
expect(created.body.id).toBeTypeOf("string");
const list = await agent(app)
.get("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.expect(200);
expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true);
const renamed = `${name} Renomeado`;
const updated = await agent(app)
.patch(`/api/v1/products/groups/${created.body.id}`)
.set(...bearer(admin.accessToken))
.send({ name: renamed })
.expect(200);
expect(updated.body.name).toBe(renamed);
});
it("rejects a duplicate product group name case-insensitively with 409", async () => {
const name = qaName("Grupo Duplicado");
await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name })
.expect(201);
await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: name.toLowerCase() })
.expect(409);
});
it("creates a subgroup scoped to a group, lists it filtered by groupId, and updates it", async () => {
const group = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Grupo Para Subgrupo") })
.expect(201);
const subgroupName = qaName("Subgrupo A");
const subgroup = await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: subgroupName, groupId: group.body.id })
.expect(201);
expect(subgroup.body.group_id).toBe(group.body.id);
const list = await agent(app)
.get(`/api/v1/products/subgroups?groupId=${group.body.id}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(list.body).toHaveLength(1);
expect(list.body[0].id).toBe(subgroup.body.id);
const renamed = `${subgroupName} Renomeado`;
const updated = await agent(app)
.patch(`/api/v1/products/subgroups/${subgroup.body.id}`)
.set(...bearer(admin.accessToken))
.send({ name: renamed })
.expect(200);
expect(updated.body.name).toBe(renamed);
});
it("allows the same subgroup name under two different groups", async () => {
const groupA = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Grupo Repeticao A") })
.expect(201);
const groupB = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Grupo Repeticao B") })
.expect(201);
const subgroupName = qaName("Subgrupo Repetido");
await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: subgroupName, groupId: groupA.body.id })
.expect(201);
await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: subgroupName, groupId: groupB.body.id })
.expect(201);
});
it("rejects a duplicate subgroup name within the same group with 409", async () => {
const group = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Grupo Subgrupo Duplicado") })
.expect(201);
const subgroupName = qaName("Subgrupo Dup");
await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: subgroupName, groupId: group.body.id })
.expect(201);
await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: subgroupName.toUpperCase(), groupId: group.body.id })
.expect(409);
});
it("rejects creating a subgroup without groupId with 400", async () => {
await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Subgrupo Sem Grupo") })
.expect(400);
});
it("creates, lists and updates a brand", async () => {
const created = await agent(app)
.post("/api/v1/products/brands")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Marca Catalogo") })
.expect(201);
const list = await agent(app)
.get("/api/v1/products/brands")
.set(...bearer(admin.accessToken))
.expect(200);
expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true);
await agent(app)
.patch(`/api/v1/products/brands/${created.body.id}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
});
it("rejects a duplicate brand name case-insensitively with 409", async () => {
const name = qaName("Marca Duplicada");
await agent(app)
.post("/api/v1/products/brands")
.set(...bearer(admin.accessToken))
.send({ name })
.expect(201);
await agent(app)
.post("/api/v1/products/brands")
.set(...bearer(admin.accessToken))
.send({ name: name.toUpperCase() })
.expect(409);
});
it("creates, lists and updates a unit", async () => {
const created = await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Unidade Catalogo") })
.expect(201);
const list = await agent(app)
.get("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.expect(200);
expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true);
await agent(app)
.patch(`/api/v1/products/units/${created.body.id}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
});
it("rejects a duplicate unit name case-insensitively with 409", async () => {
const name = qaName("Unidade Duplicada");
await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name })
.expect(201);
await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name: name.toLowerCase() })
.expect(409);
});
describe("product creation using catalog IDs", () => {
let productTypeId: string;
let unitId: string;
let groupId: string;
let otherGroupId: string;
let subgroupId: string;
let brandId: string;
let productTypeName: string;
let unitName: string;
let groupName: string;
let subgroupName: string;
let brandName: string;
beforeAll(async () => {
productTypeName = qaName("Tipo Produto Final");
const type = await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name: productTypeName })
.expect(201);
productTypeId = type.body.id;
unitName = qaName("Unidade Produto Final");
const unit = await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name: unitName })
.expect(201);
unitId = unit.body.id;
groupName = qaName("Grupo Produto Final");
const group = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: groupName })
.expect(201);
groupId = group.body.id;
const otherGroup = await agent(app)
.post("/api/v1/products/groups")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Outro Grupo Produto Final") })
.expect(201);
otherGroupId = otherGroup.body.id;
subgroupName = qaName("Subgrupo Produto Final");
const subgroup = await agent(app)
.post("/api/v1/products/subgroups")
.set(...bearer(admin.accessToken))
.send({ name: subgroupName, groupId })
.expect(201);
subgroupId = subgroup.body.id;
brandName = qaName("Marca Produto Final");
const brand = await agent(app)
.post("/api/v1/products/brands")
.set(...bearer(admin.accessToken))
.send({ name: brandName })
.expect(201);
brandId = brand.body.id;
});
it("creates a product referencing every catalog and resolves *_name on GET", async () => {
const product = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: qaName("Produto Catalogos"),
productTypeId,
unitId,
productGroupId: groupId,
productSubgroupId: subgroupId,
brandId,
price0: 10,
})
.expect(201);
expect(product.body.product_type_id).toBe(productTypeId);
expect(product.body.unit_id).toBe(unitId);
expect(product.body.product_group_id).toBe(groupId);
expect(product.body.product_subgroup_id).toBe(subgroupId);
expect(product.body.brand_id).toBe(brandId);
const fetched = await agent(app)
.get(`/api/v1/products/${product.body.id}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(fetched.body.product_type_name).toBe(productTypeName);
expect(fetched.body.unit_name).toBe(unitName);
expect(fetched.body.product_group_name).toBe(groupName);
expect(fetched.body.product_subgroup_name).toBe(subgroupName);
expect(fetched.body.brand_name).toBe(brandName);
});
it("rejects (400) a product whose subgroup belongs to a different group than the one informed", async () => {
const res = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: qaName("Produto Grupo Errado"),
productTypeId,
unitId,
productGroupId: otherGroupId,
productSubgroupId: subgroupId,
})
.expect(400);
expect(res.body.message).toContain("Subgrupo não pertence ao grupo informado");
});
it("rejects (400) a product whose subgroup is informed without a groupId", async () => {
const res = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: qaName("Produto Sem Grupo"),
productTypeId,
unitId,
productSubgroupId: subgroupId,
})
.expect(400);
expect(res.body.message).toContain("Grupo de produto é obrigatório");
});
describe("update — subgroup consistency (COALESCE can't express 'clear', handled explicitly)", () => {
let productId: string;
beforeAll(async () => {
const product = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: qaName("Produto Update Subgrupo"),
productTypeId,
unitId,
productGroupId: groupId,
productSubgroupId: subgroupId,
})
.expect(201);
productId = product.body.id;
});
it("rejects (400) changing the group without resolving the now-orphaned subgroup — never silently corrupts the invariant", async () => {
const res = await agent(app)
.patch(`/api/v1/products/${productId}`)
.set(...bearer(admin.accessToken))
.send({ productGroupId: otherGroupId })
.expect(400);
expect(res.body.message).toMatch(/incompatível/);
const unchanged = await agent(app).get(`/api/v1/products/${productId}`).set(...bearer(admin.accessToken)).expect(200);
expect(unchanged.body.product_group_id).toBe(groupId);
expect(unchanged.body.product_subgroup_id).toBe(subgroupId);
});
it("explicit productSubgroupId: null actually clears it (not a no-op)", async () => {
const res = await agent(app)
.patch(`/api/v1/products/${productId}`)
.set(...bearer(admin.accessToken))
.send({ productSubgroupId: null })
.expect(200);
expect(res.body.product_subgroup_id).toBeNull();
expect(res.body.product_subgroup_name).toBeNull();
const fetched = await agent(app).get(`/api/v1/products/${productId}`).set(...bearer(admin.accessToken)).expect(200);
expect(fetched.body.product_subgroup_id).toBeNull();
});
it("changing the group now works once the subgroup is cleared", async () => {
const res = await agent(app)
.patch(`/api/v1/products/${productId}`)
.set(...bearer(admin.accessToken))
.send({ productGroupId: otherGroupId })
.expect(200);
expect(res.body.product_group_id).toBe(otherGroupId);
expect(res.body.product_subgroup_id).toBeNull();
});
});
});
});