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>
This commit is contained in:
9
apps/api/src/products/dto/create-brand.dto.ts
Normal file
9
apps/api/src/products/dto/create-brand.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
|
||||
|
||||
export class CreateBrandDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
9
apps/api/src/products/dto/create-product-group.dto.ts
Normal file
9
apps/api/src/products/dto/create-product-group.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
|
||||
|
||||
export class CreateProductGroupDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
12
apps/api/src/products/dto/create-product-subgroup.dto.ts
Normal file
12
apps/api/src/products/dto/create-product-subgroup.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional, IsUUID, MinLength, IsString } from "class-validator";
|
||||
|
||||
export class CreateProductSubgroupDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsUUID()
|
||||
groupId!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
9
apps/api/src/products/dto/create-product-type.dto.ts
Normal file
9
apps/api/src/products/dto/create-product-type.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
|
||||
|
||||
export class CreateProductTypeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min, MinLength, ValidateNested } from "class-validator";
|
||||
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min, MinLength, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { SERVICE_TYPES, type ServiceType } from "@eden/domain-shared";
|
||||
import { TariffRatesDto } from "./tariff-rate.dto.js";
|
||||
@@ -10,14 +10,19 @@ export class CreateProductDto {
|
||||
|
||||
@IsOptional() @IsString() description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productType!: string;
|
||||
@IsUUID()
|
||||
productTypeId!: string;
|
||||
|
||||
@IsOptional() @IsString() productGroup?: string;
|
||||
@IsOptional() @IsString() productSubgroup?: string;
|
||||
@IsOptional() @IsString() brand?: string;
|
||||
@IsOptional() @IsString() unit?: string;
|
||||
@IsOptional() @IsUUID() productGroupId?: string;
|
||||
// `null` is only meaningful on update (PATCH) — an explicit "remove the
|
||||
// subgroup" instruction, distinct from omitting the field ("leave
|
||||
// unchanged"). On create it's equivalent to omitting it. See
|
||||
// ProductsService.assertSubgroupConsistent.
|
||||
@IsOptional() @IsUUID() productSubgroupId?: string | null;
|
||||
@IsOptional() @IsUUID() brandId?: string;
|
||||
|
||||
@IsUUID()
|
||||
unitId!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsBoolean() discontinued?: boolean;
|
||||
|
||||
9
apps/api/src/products/dto/create-unit.dto.ts
Normal file
9
apps/api/src/products/dto/create-unit.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
|
||||
|
||||
export class CreateUnitDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
4
apps/api/src/products/dto/update-brand.dto.ts
Normal file
4
apps/api/src/products/dto/update-brand.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateBrandDto } from "./create-brand.dto.js";
|
||||
|
||||
export class UpdateBrandDto extends PartialType(CreateBrandDto) {}
|
||||
4
apps/api/src/products/dto/update-product-group.dto.ts
Normal file
4
apps/api/src/products/dto/update-product-group.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateProductGroupDto } from "./create-product-group.dto.js";
|
||||
|
||||
export class UpdateProductGroupDto extends PartialType(CreateProductGroupDto) {}
|
||||
7
apps/api/src/products/dto/update-product-subgroup.dto.ts
Normal file
7
apps/api/src/products/dto/update-product-subgroup.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
|
||||
|
||||
/** groupId is deliberately not patchable here — moving a subgroup to a different group is not part of this MVP; create a new subgroup under the target group instead. */
|
||||
export class UpdateProductSubgroupDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
4
apps/api/src/products/dto/update-product-type.dto.ts
Normal file
4
apps/api/src/products/dto/update-product-type.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateProductTypeDto } from "./create-product-type.dto.js";
|
||||
|
||||
export class UpdateProductTypeDto extends PartialType(CreateProductTypeDto) {}
|
||||
4
apps/api/src/products/dto/update-unit.dto.ts
Normal file
4
apps/api/src/products/dto/update-unit.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateUnitDto } from "./create-unit.dto.js";
|
||||
|
||||
export class UpdateUnitDto extends PartialType(CreateUnitDto) {}
|
||||
@@ -3,6 +3,16 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { ProductsService } from "./products.service.js";
|
||||
import { CreateProductDto } from "./dto/create-product.dto.js";
|
||||
import { UpdateProductDto } from "./dto/update-product.dto.js";
|
||||
import { CreateProductTypeDto } from "./dto/create-product-type.dto.js";
|
||||
import { UpdateProductTypeDto } from "./dto/update-product-type.dto.js";
|
||||
import { CreateProductGroupDto } from "./dto/create-product-group.dto.js";
|
||||
import { UpdateProductGroupDto } from "./dto/update-product-group.dto.js";
|
||||
import { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js";
|
||||
import { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js";
|
||||
import { CreateBrandDto } from "./dto/create-brand.dto.js";
|
||||
import { UpdateBrandDto } from "./dto/update-brand.dto.js";
|
||||
import { CreateUnitDto } from "./dto/create-unit.dto.js";
|
||||
import { UpdateUnitDto } from "./dto/update-unit.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
@@ -16,6 +26,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
* for companies. Write requires products:edit. Backend always
|
||||
* recalculates/validates: money fields are format-checked (non-negative)
|
||||
* in the DTO but no pricing logic runs here — that's Quotes, a later phase.
|
||||
*
|
||||
* Tipo/Grupo/Subgrupo/Marca/Unidade sub-catalogs (product_types/
|
||||
* product_groups/product_subgroups/brands/units) share this controller,
|
||||
* same style as FiscalController grouping fiscal_ncm/fiscal_cfop/etc. — no
|
||||
* physical delete for any of them in this MVP, `active=false` is the
|
||||
* "apagar".
|
||||
*/
|
||||
@ApiTags("products")
|
||||
@ApiBearerAuth()
|
||||
@@ -29,6 +45,126 @@ export class ProductsController {
|
||||
return this.products.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
// -- product_types --------------------------------------------------------
|
||||
|
||||
@Get("types")
|
||||
listTypes(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.products.listProductTypes(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Post("types")
|
||||
@RequirePermission("products", "edit")
|
||||
createType(@Body() dto: CreateProductTypeDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.products.createProductType(dto, user);
|
||||
}
|
||||
|
||||
@Patch("types/:id")
|
||||
@RequirePermission("products", "edit")
|
||||
updateType(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateProductTypeDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.products.updateProductType(id, dto, user);
|
||||
}
|
||||
|
||||
// -- product_groups ---------------------------------------------------------
|
||||
|
||||
@Get("groups")
|
||||
listGroups(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.products.listProductGroups(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Post("groups")
|
||||
@RequirePermission("products", "edit")
|
||||
createGroup(@Body() dto: CreateProductGroupDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.products.createProductGroup(dto, user);
|
||||
}
|
||||
|
||||
@Patch("groups/:id")
|
||||
@RequirePermission("products", "edit")
|
||||
updateGroup(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateProductGroupDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.products.updateProductGroup(id, dto, user);
|
||||
}
|
||||
|
||||
// -- product_subgroups --------------------------------------------------------
|
||||
|
||||
@Get("subgroups")
|
||||
listSubgroups(
|
||||
@Query("groupId") groupId?: string,
|
||||
@Query("includeInactive") includeInactive?: string,
|
||||
) {
|
||||
return this.products.listProductSubgroups(groupId, includeInactive === "true");
|
||||
}
|
||||
|
||||
@Post("subgroups")
|
||||
@RequirePermission("products", "edit")
|
||||
createSubgroup(@Body() dto: CreateProductSubgroupDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.products.createProductSubgroup(dto, user);
|
||||
}
|
||||
|
||||
@Patch("subgroups/:id")
|
||||
@RequirePermission("products", "edit")
|
||||
updateSubgroup(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateProductSubgroupDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.products.updateProductSubgroup(id, dto, user);
|
||||
}
|
||||
|
||||
// -- brands -----------------------------------------------------------------
|
||||
|
||||
@Get("brands")
|
||||
listBrands(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.products.listBrands(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Post("brands")
|
||||
@RequirePermission("products", "edit")
|
||||
createBrand(@Body() dto: CreateBrandDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.products.createBrand(dto, user);
|
||||
}
|
||||
|
||||
@Patch("brands/:id")
|
||||
@RequirePermission("products", "edit")
|
||||
updateBrand(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateBrandDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.products.updateBrand(id, dto, user);
|
||||
}
|
||||
|
||||
// -- units --------------------------------------------------------------------
|
||||
|
||||
@Get("units")
|
||||
listUnits(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.products.listUnits(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Post("units")
|
||||
@RequirePermission("products", "edit")
|
||||
createUnit(@Body() dto: CreateUnitDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.products.createUnit(dto, user);
|
||||
}
|
||||
|
||||
@Patch("units/:id")
|
||||
@RequirePermission("products", "edit")
|
||||
updateUnit(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateUnitDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.products.updateUnit(id, dto, user);
|
||||
}
|
||||
|
||||
// -- products (:id must come after every static sub-route above) --------------
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.products.get(id);
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { auditRepo, productsRepo, type Product } from "@eden/database";
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
auditRepo,
|
||||
productsRepo,
|
||||
productCatalogsRepo,
|
||||
type Product,
|
||||
type ProductType,
|
||||
type ProductGroup,
|
||||
type ProductSubgroup,
|
||||
type Brand,
|
||||
type Unit,
|
||||
} from "@eden/database";
|
||||
import { emptyTariffRates, type TariffRates } from "@eden/domain-shared";
|
||||
import type { CreateProductDto } from "./dto/create-product.dto.js";
|
||||
import type { UpdateProductDto } from "./dto/update-product.dto.js";
|
||||
import type { CreateProductTypeDto } from "./dto/create-product-type.dto.js";
|
||||
import type { UpdateProductTypeDto } from "./dto/update-product-type.dto.js";
|
||||
import type { CreateProductGroupDto } from "./dto/create-product-group.dto.js";
|
||||
import type { UpdateProductGroupDto } from "./dto/update-product-group.dto.js";
|
||||
import type { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js";
|
||||
import type { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js";
|
||||
import type { CreateBrandDto } from "./dto/create-brand.dto.js";
|
||||
import type { UpdateBrandDto } from "./dto/update-brand.dto.js";
|
||||
import type { CreateUnitDto } from "./dto/create-unit.dto.js";
|
||||
import type { UpdateUnitDto } from "./dto/update-unit.dto.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/** DTO fields are individually optional (partial override); persisted tariff_rates always carries all 6 keys — mirrors the legacy's normalizeTariffRates(), never a partial object at rest. */
|
||||
function normalizeTariffRates(partial: Partial<TariffRates> | undefined): TariffRates | undefined {
|
||||
@@ -10,6 +31,10 @@ function normalizeTariffRates(partial: Partial<TariffRates> | undefined): Tariff
|
||||
return { ...emptyTariffRates(), ...partial };
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505";
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
list(includeInactive: boolean): Promise<Product[]> {
|
||||
@@ -23,14 +48,17 @@ export class ProductsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto, actorId: string): Promise<Product> {
|
||||
await this.assertCatalogReferencesValid(dto);
|
||||
await this.assertSubgroupConsistent(dto, null);
|
||||
|
||||
const product = await productsRepo.createProduct({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
productType: dto.productType,
|
||||
productGroup: dto.productGroup,
|
||||
productSubgroup: dto.productSubgroup,
|
||||
brand: dto.brand,
|
||||
unit: dto.unit,
|
||||
productTypeId: dto.productTypeId,
|
||||
productGroupId: dto.productGroupId,
|
||||
productSubgroupId: dto.productSubgroupId,
|
||||
brandId: dto.brandId,
|
||||
unitId: dto.unitId,
|
||||
active: dto.active,
|
||||
discontinued: dto.discontinued,
|
||||
tracksStock: dto.tracksStock,
|
||||
@@ -63,16 +91,18 @@ export class ProductsService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateProductDto, actorId: string): Promise<Product> {
|
||||
await this.get(id); // 404 if missing
|
||||
const current = await this.get(id); // 404 if missing
|
||||
await this.assertCatalogReferencesValid(dto);
|
||||
await this.assertSubgroupConsistent(dto, current);
|
||||
|
||||
const product = await productsRepo.updateProduct(id, {
|
||||
let product = await productsRepo.updateProduct(id, {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
productType: dto.productType,
|
||||
productGroup: dto.productGroup,
|
||||
productSubgroup: dto.productSubgroup,
|
||||
brand: dto.brand,
|
||||
unit: dto.unit,
|
||||
productTypeId: dto.productTypeId,
|
||||
productGroupId: dto.productGroupId,
|
||||
productSubgroupId: dto.productSubgroupId,
|
||||
brandId: dto.brandId,
|
||||
unitId: dto.unitId,
|
||||
active: dto.active,
|
||||
discontinued: dto.discontinued,
|
||||
tracksStock: dto.tracksStock,
|
||||
@@ -93,6 +123,15 @@ export class ProductsService {
|
||||
});
|
||||
if (!product) throw new NotFoundException("Produto não encontrado");
|
||||
|
||||
// `productSubgroupId: null` is an explicit clear — the COALESCE-based
|
||||
// update above can't express "set to NULL" vs. "leave unchanged"
|
||||
// (both collapse to the same SQL NULL parameter), so it's a deliberate
|
||||
// no-op there and corrected here with a dedicated statement.
|
||||
if (dto.productSubgroupId === null) {
|
||||
await productsRepo.clearProductSubgroup(id);
|
||||
product = (await productsRepo.getProductById(id)) ?? product;
|
||||
}
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
@@ -104,4 +143,358 @@ export class ProductsService {
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the simple (existence-only) catalog references on
|
||||
* create/update: type/unit/group/brand must exist. Subgroup consistency
|
||||
* is handled separately by `assertSubgroupConsistent` — it needs the
|
||||
* product's current state on update, which this method doesn't have.
|
||||
*/
|
||||
private async assertCatalogReferencesValid(
|
||||
dto: Partial<Pick<CreateProductDto, "productTypeId" | "unitId" | "productGroupId" | "brandId">>,
|
||||
): Promise<void> {
|
||||
if (dto.productTypeId !== undefined) {
|
||||
const type = await productCatalogsRepo.getProductTypeById(dto.productTypeId);
|
||||
if (!type) throw new BadRequestException("Tipo de produto não encontrado");
|
||||
}
|
||||
if (dto.unitId !== undefined) {
|
||||
const unit = await productCatalogsRepo.getUnitById(dto.unitId);
|
||||
if (!unit) throw new BadRequestException("Unidade não encontrada");
|
||||
}
|
||||
if (dto.brandId !== undefined) {
|
||||
const brand = await productCatalogsRepo.getBrandById(dto.brandId);
|
||||
if (!brand) throw new BadRequestException("Marca não encontrada");
|
||||
}
|
||||
if (dto.productGroupId !== undefined) {
|
||||
const group = await productCatalogsRepo.getProductGroupById(dto.productGroupId);
|
||||
if (!group) throw new BadRequestException("Grupo de produto não encontrado");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `productSubgroupId === null` is an explicit "remove the subgroup"
|
||||
* instruction — always valid, never looked up. Anything else is
|
||||
* resolved against the EFFECTIVE resulting state (the DTO's value where
|
||||
* provided, falling back to the current product's value otherwise) so a
|
||||
* PATCH that only changes `productGroupId` can't silently leave behind a
|
||||
* subgroup that no longer belongs to the new group — it's rejected with
|
||||
* a clear message instead of a corrupted invariant.
|
||||
*/
|
||||
private async assertSubgroupConsistent(
|
||||
dto: Pick<UpdateProductDto, "productGroupId" | "productSubgroupId">,
|
||||
current: Pick<Product, "product_group_id" | "product_subgroup_id"> | null,
|
||||
): Promise<void> {
|
||||
if (dto.productSubgroupId === null) return;
|
||||
|
||||
const effectiveGroupId = dto.productGroupId !== undefined ? dto.productGroupId : (current?.product_group_id ?? undefined);
|
||||
const effectiveSubgroupId = dto.productSubgroupId !== undefined ? dto.productSubgroupId : (current?.product_subgroup_id ?? undefined);
|
||||
if (!effectiveSubgroupId) return;
|
||||
|
||||
const subgroup = await productCatalogsRepo.getProductSubgroupById(effectiveSubgroupId);
|
||||
if (!subgroup) throw new BadRequestException("Subgrupo de produto não encontrado");
|
||||
if (!effectiveGroupId) {
|
||||
throw new BadRequestException("Grupo de produto é obrigatório quando um subgrupo é informado");
|
||||
}
|
||||
if (subgroup.group_id !== effectiveGroupId) {
|
||||
throw new BadRequestException(
|
||||
dto.productGroupId !== undefined && dto.productSubgroupId === undefined
|
||||
? "O novo grupo é incompatível com o subgrupo atual do produto — informe também um subgrupo válido para o novo grupo, ou envie productSubgroupId: null para removê-lo."
|
||||
: "Subgrupo não pertence ao grupo informado",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// product_types
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listProductTypes(includeInactive: boolean): Promise<ProductType[]> {
|
||||
return productCatalogsRepo.listProductTypes(includeInactive);
|
||||
}
|
||||
|
||||
async createProductType(dto: CreateProductTypeDto, user: EdenAuthenticatedUser): Promise<ProductType> {
|
||||
let row: ProductType;
|
||||
try {
|
||||
row = await productCatalogsRepo.createProductType({
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
createdBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe um tipo de produto com esse nome");
|
||||
throw err;
|
||||
}
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "product_type.created",
|
||||
resourceType: "product_type",
|
||||
resourceId: row.id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateProductType(
|
||||
id: string,
|
||||
dto: UpdateProductTypeDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ProductType> {
|
||||
let row: ProductType | null;
|
||||
try {
|
||||
row = await productCatalogsRepo.updateProductType(id, {
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe um tipo de produto com esse nome");
|
||||
throw err;
|
||||
}
|
||||
if (!row) throw new NotFoundException("Tipo de produto não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "product_type.updated",
|
||||
resourceType: "product_type",
|
||||
resourceId: id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// product_groups
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listProductGroups(includeInactive: boolean): Promise<ProductGroup[]> {
|
||||
return productCatalogsRepo.listProductGroups(includeInactive);
|
||||
}
|
||||
|
||||
async createProductGroup(dto: CreateProductGroupDto, user: EdenAuthenticatedUser): Promise<ProductGroup> {
|
||||
let row: ProductGroup;
|
||||
try {
|
||||
row = await productCatalogsRepo.createProductGroup({
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
createdBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe um grupo de produto com esse nome");
|
||||
throw err;
|
||||
}
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "product_group.created",
|
||||
resourceType: "product_group",
|
||||
resourceId: row.id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateProductGroup(
|
||||
id: string,
|
||||
dto: UpdateProductGroupDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ProductGroup> {
|
||||
let row: ProductGroup | null;
|
||||
try {
|
||||
row = await productCatalogsRepo.updateProductGroup(id, {
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe um grupo de produto com esse nome");
|
||||
throw err;
|
||||
}
|
||||
if (!row) throw new NotFoundException("Grupo de produto não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "product_group.updated",
|
||||
resourceType: "product_group",
|
||||
resourceId: id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// product_subgroups
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listProductSubgroups(groupId: string | undefined, includeInactive: boolean): Promise<ProductSubgroup[]> {
|
||||
return productCatalogsRepo.listProductSubgroups(groupId, includeInactive);
|
||||
}
|
||||
|
||||
async createProductSubgroup(
|
||||
dto: CreateProductSubgroupDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ProductSubgroup> {
|
||||
const group = await productCatalogsRepo.getProductGroupById(dto.groupId);
|
||||
if (!group) throw new BadRequestException("Grupo de produto não encontrado");
|
||||
|
||||
let row: ProductSubgroup;
|
||||
try {
|
||||
row = await productCatalogsRepo.createProductSubgroup({
|
||||
groupId: dto.groupId,
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
createdBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
throw new ConflictException("Já existe um subgrupo com esse nome nesse grupo");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "product_subgroup.created",
|
||||
resourceType: "product_subgroup",
|
||||
resourceId: row.id,
|
||||
metadata: { groupId: dto.groupId },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateProductSubgroup(
|
||||
id: string,
|
||||
dto: UpdateProductSubgroupDto,
|
||||
user: EdenAuthenticatedUser,
|
||||
): Promise<ProductSubgroup> {
|
||||
let row: ProductSubgroup | null;
|
||||
try {
|
||||
row = await productCatalogsRepo.updateProductSubgroup(id, {
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
throw new ConflictException("Já existe um subgrupo com esse nome nesse grupo");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!row) throw new NotFoundException("Subgrupo de produto não encontrado");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "product_subgroup.updated",
|
||||
resourceType: "product_subgroup",
|
||||
resourceId: id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// brands
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listBrands(includeInactive: boolean): Promise<Brand[]> {
|
||||
return productCatalogsRepo.listBrands(includeInactive);
|
||||
}
|
||||
|
||||
async createBrand(dto: CreateBrandDto, user: EdenAuthenticatedUser): Promise<Brand> {
|
||||
let row: Brand;
|
||||
try {
|
||||
row = await productCatalogsRepo.createBrand({
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
createdBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma marca com esse nome");
|
||||
throw err;
|
||||
}
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "brand.created",
|
||||
resourceType: "brand",
|
||||
resourceId: row.id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateBrand(id: string, dto: UpdateBrandDto, user: EdenAuthenticatedUser): Promise<Brand> {
|
||||
let row: Brand | null;
|
||||
try {
|
||||
row = await productCatalogsRepo.updateBrand(id, {
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma marca com esse nome");
|
||||
throw err;
|
||||
}
|
||||
if (!row) throw new NotFoundException("Marca não encontrada");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "brand.updated",
|
||||
resourceType: "brand",
|
||||
resourceId: id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// units
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
listUnits(includeInactive: boolean): Promise<Unit[]> {
|
||||
return productCatalogsRepo.listUnits(includeInactive);
|
||||
}
|
||||
|
||||
async createUnit(dto: CreateUnitDto, user: EdenAuthenticatedUser): Promise<Unit> {
|
||||
let row: Unit;
|
||||
try {
|
||||
row = await productCatalogsRepo.createUnit({
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
createdBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma unidade com esse nome");
|
||||
throw err;
|
||||
}
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "unit.created",
|
||||
resourceType: "unit",
|
||||
resourceId: row.id,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateUnit(id: string, dto: UpdateUnitDto, user: EdenAuthenticatedUser): Promise<Unit> {
|
||||
let row: Unit | null;
|
||||
try {
|
||||
row = await productCatalogsRepo.updateUnit(id, {
|
||||
name: dto.name,
|
||||
active: dto.active,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma unidade com esse nome");
|
||||
throw err;
|
||||
}
|
||||
if (!row) throw new NotFoundException("Unidade não encontrada");
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "unit.updated",
|
||||
resourceType: "unit",
|
||||
resourceId: id,
|
||||
metadata: { ...dto },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaName, type AuthedTokens } from "./setup.js";
|
||||
|
||||
/**
|
||||
* `renderPdf` is mocked (real call path through quotes.service.ts still
|
||||
@@ -22,6 +22,8 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
|
||||
let resellerId: string;
|
||||
let stfcProductId: string;
|
||||
let pabxProductId: string;
|
||||
let productTypeId: string;
|
||||
let unitId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -34,12 +36,27 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
|
||||
.expect(201);
|
||||
resellerId = reseller.body.id;
|
||||
|
||||
const productType = await agent(app)
|
||||
.post("/api/v1/products/types")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: qaName("Tipo ANATEL") })
|
||||
.expect(201);
|
||||
productTypeId = productType.body.id;
|
||||
|
||||
const unit = await agent(app)
|
||||
.post("/api/v1/products/units")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: qaName("Unidade ANATEL") })
|
||||
.expect(201);
|
||||
unitId = unit.body.id;
|
||||
|
||||
const stfcProduct = await agent(app)
|
||||
.post("/api/v1/products")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({
|
||||
name: "qa-test- Linha STFC",
|
||||
productType: "service",
|
||||
productTypeId,
|
||||
unitId,
|
||||
price0: 200,
|
||||
price12: 150,
|
||||
serviceType: "STFC",
|
||||
@@ -63,7 +80,7 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
|
||||
const pabxProduct = await agent(app)
|
||||
.post("/api/v1/products")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: "qa-test- Ramal PABX", productType: "service", price0: 50, serviceType: "Serviço PABX" })
|
||||
.send({ name: "qa-test- Ramal PABX", productTypeId, unitId, price0: 50, serviceType: "Serviço PABX" })
|
||||
.expect(201);
|
||||
pabxProductId = pabxProduct.body.id;
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loginSuperAdmin,
|
||||
qaEmail,
|
||||
qaKey,
|
||||
qaName,
|
||||
type AuthedTokens,
|
||||
} from "./setup.js";
|
||||
|
||||
@@ -45,10 +46,27 @@ describe("contracts — edit lock past 'draft'", () => {
|
||||
.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", productType: "service", price0: 100 })
|
||||
.send({
|
||||
name: "qa-test- Produto Contract Lock",
|
||||
productTypeId: productType.body.id,
|
||||
unitId: unit.body.id,
|
||||
price0: 100,
|
||||
})
|
||||
.expect(201);
|
||||
productId = product.body.id;
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ describe("mass-assignment — unexpected fields are rejected, not silently dropp
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({
|
||||
name: "qa-test- mass assignment product",
|
||||
productType: "service",
|
||||
productTypeId: "11111111-1111-1111-1111-111111111111",
|
||||
unitId: "11111111-1111-1111-1111-111111111111",
|
||||
createdBy: "11111111-1111-1111-1111-111111111111",
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
444
apps/api/test/product-catalogs.spec.ts
Normal file
444
apps/api/test/product-catalogs.spec.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -118,3 +118,14 @@ export function qaKey(label: string): string {
|
||||
// apps/api/src/roles/dto/create-role.dto.ts).
|
||||
return `qa_${label}_${rand}`.slice(0, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random-suffixed display name, for fixtures backed by a case-insensitive
|
||||
* UNIQUE index with no delete endpoint (e.g. product_types/product_groups/
|
||||
* brands/units) — a hardcoded name would collide with a row left behind by
|
||||
* a previous run of this suite against the same shared dev database.
|
||||
*/
|
||||
export function qaName(label: string): string {
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `${QA_PREFIX}${label} ${rand}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user