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>
445 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|
|
});
|