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); expect(list.body[0].group_name).toBe(group.body.name); const unscoped = await agent(app) .get("/api/v1/products/subgroups") .set(...bearer(admin.accessToken)) .expect(200); const found = unscoped.body.find((s: { id: string }) => s.id === subgroup.body.id); expect(found?.group_name).toBe(group.body.name); 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(); }); }); }); });