Files
SeConectai_Vendas/test/tenant-customers-produtos.e2e-spec.ts
T
tiagoandClaude Sonnet 5 24b3bb9ae4 Etapa 5 — Painel Tenant: clientes, contatos e produtos/serviços
Adiciona os módulos customers (com contatos aninhados e regra de contato
principal único) e products, ambos com TenantGuard/RLS, soft delete,
limite de clientes do plano e SKU único por tenant (409 amigável).
Atualiza o controle de progresso e cobre os critérios de aceite com
test/tenant-customers-produtos.e2e-spec.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:44:01 -03:00

485 lines
18 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util';
interface TokenPair {
access_token: string;
refresh_token: string;
}
describe('Painel Tenant — Customers, Contatos e Produtos/Serviços (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let httpServer: Parameters<typeof request>[0];
const senha = 'SenhaForteDemo#26';
const suffix = randomUUID().slice(0, 8);
const masterEmail = `master-etapa5-${suffix}@teste.com`;
let masterId: string;
let masterToken: string;
const tenantIds: string[] = [];
const planoIds: string[] = [];
const tenantUserIds: string[] = [];
async function authMaster(): Promise<string> {
const res = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
return (res.body as TokenPair).access_token;
}
function asMaster() {
return {
get: (url: string) =>
request(httpServer).get(url).set('Authorization', `Bearer ${masterToken}`),
post: (url: string) =>
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
};
}
function asTenant(token: string) {
return {
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`),
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) =>
request(httpServer).delete(url).set('Authorization', `Bearer ${token}`),
};
}
async function criarPlano(overrides: Partial<Record<string, unknown>> = {}) {
const res = await asMaster()
.post('/master/planos')
.send({
nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`,
valor_mensal: 199.9,
limite_instancias_whatsapp: 1,
...overrides,
})
.expect(201);
planoIds.push((res.body as { id: string }).id);
return res.body as { id: string };
}
async function criarTenantComAdmin(planoId: string) {
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
const res = await asMaster()
.post('/master/tenants')
.send({
razao_social: `Tenant Etapa5 ${suffix} ${randomUUID().slice(0, 6)}`,
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
plano_id: planoId,
admin: { nome: 'Admin de Teste', email: emailAdmin },
})
.expect(201);
const body = res.body as {
tenant: { id: string };
admin: { id: string; email: string; senha_provisoria: string };
};
tenantIds.push(body.tenant.id);
tenantUserIds.push(body.admin.id);
await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } });
const login = await request(httpServer)
.post('/auth/tenant/login')
.send({ email: body.admin.email, senha: body.admin.senha_provisoria })
.expect(200);
return {
tenant: body.tenant,
admin: body.admin,
token: (login.body as TokenPair).access_token,
};
}
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
httpServer = app.getHttpServer() as Parameters<typeof request>[0];
prisma = app.get(PrismaService);
const senha_hash = await hashPassword(senha);
const master = await prisma.master_admins.create({
data: { nome: 'Master Etapa 5', email: masterEmail, senha_hash },
});
masterId = master.id;
masterToken = await authMaster();
});
afterAll(async () => {
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
await prisma.auth_refresh_tokens.deleteMany({
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
});
await prisma.login_attempts.deleteMany({
where: { usuario_tipo: 'master', email: masterEmail },
});
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close();
});
describe('customers — CRUD, busca e soft delete', () => {
let token: string;
beforeAll(async () => {
const plano = await criarPlano();
const tenant = await criarTenantComAdmin(plano.id);
token = tenant.token;
});
it('cria cliente só com o campo obrigatório (razao_social)', async () => {
const res = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Mínimo Ltda' })
.expect(201);
expect(res.body).toMatchObject({ razao_social: 'Cliente Mínimo Ltda', ativo: true });
});
it('razao_social ausente retorna 400', async () => {
await asTenant(token).post('/tenant/customers').send({}).expect(400);
});
it('aceita documento em formato CPF ou CNPJ válido e rejeita formato inválido', async () => {
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente CPF', documento: '123.456.789-01' })
.expect(201);
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente CNPJ', documento: '12.345.678/0001-99' })
.expect(201);
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Documento Inválido', documento: '123' })
.expect(400);
});
it('lista com busca por nome/documento, filtro por ativo e paginação', async () => {
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Empresa Buscável Xyz' })
.expect(201);
const porNome = await asTenant(token).get('/tenant/customers?busca=Buscável').expect(200);
const body = porNome.body as { data: Array<{ razao_social: string }>; meta: unknown };
expect(body.data.length).toBeGreaterThanOrEqual(1);
expect(body.data.every((c) => c.razao_social.includes('Buscável'))).toBe(true);
const paginada = await asTenant(token).get('/tenant/customers?page=1&limit=1').expect(200);
const paginadaBody = paginada.body as { data: unknown[]; meta: { limit: number } };
expect(paginadaBody.data.length).toBe(1);
expect(paginadaBody.meta.limit).toBe(1);
});
it('atualiza cliente', async () => {
const criado = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Para Editar' })
.expect(201);
const id = (criado.body as { id: string }).id;
const atualizado = await asTenant(token)
.put(`/tenant/customers/${id}`)
.send({ razao_social: 'Cliente Editado', cidade: 'São Paulo', uf: 'SP' })
.expect(200);
expect(atualizado.body).toMatchObject({
razao_social: 'Cliente Editado',
cidade: 'São Paulo',
uf: 'SP',
});
});
it('DELETE faz soft delete — linha continua existindo com ativo=false', async () => {
const criado = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Para Desativar' })
.expect(201);
const id = (criado.body as { id: string }).id;
await asTenant(token).delete(`/tenant/customers/${id}`).expect(200);
const naBase = await prisma.customers.findUnique({ where: { id } });
expect(naBase).not.toBeNull();
expect(naBase?.ativo).toBe(false);
const viaApi = await asTenant(token).get(`/tenant/customers/${id}`).expect(200);
expect((viaApi.body as { ativo: boolean }).ativo).toBe(false);
const listaAtivos = await asTenant(token).get('/tenant/customers?ativo=true').expect(200);
const idsAtivos = (listaAtivos.body as { data: Array<{ id: string }> }).data.map((c) => c.id);
expect(idsAtivos).not.toContain(id);
});
});
describe('customers — limite do plano', () => {
it('criar cliente além do limite_customers do plano retorna 400 com mensagem clara', async () => {
const plano = await criarPlano({ limite_customers: 1 });
const tenant = await criarTenantComAdmin(plano.id);
await asTenant(tenant.token)
.post('/tenant/customers')
.send({ razao_social: 'Primeiro Cliente (dentro do limite)' })
.expect(201);
const bloqueado = await asTenant(tenant.token)
.post('/tenant/customers')
.send({ razao_social: 'Segundo Cliente (estoura o limite)' })
.expect(400);
expect((bloqueado.body as { message: string }).message).toMatch(/limite/i);
});
});
describe('customer_contacts — CRUD aninhado e contato principal único', () => {
let token: string;
let customerId: string;
beforeAll(async () => {
const plano = await criarPlano();
const tenant = await criarTenantComAdmin(plano.id);
token = tenant.token;
const customer = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Com Contatos' })
.expect(201);
customerId = (customer.body as { id: string }).id;
});
it('cria contato principal e, ao marcar outro como principal, desmarca o anterior', async () => {
const primeiro = await asTenant(token)
.post(`/tenant/customers/${customerId}/contatos`)
.send({ nome: 'Contato Um', principal: true })
.expect(201);
const primeiroId = (primeiro.body as { id: string; principal: boolean }).id;
expect((primeiro.body as { principal: boolean }).principal).toBe(true);
const segundo = await asTenant(token)
.post(`/tenant/customers/${customerId}/contatos`)
.send({ nome: 'Contato Dois', principal: true })
.expect(201);
expect((segundo.body as { principal: boolean }).principal).toBe(true);
const listagem = await asTenant(token)
.get(`/tenant/customers/${customerId}/contatos`)
.expect(200);
const contatos = listagem.body as Array<{ id: string; principal: boolean }>;
const contatoPrimeiroAtualizado = contatos.find((c) => c.id === primeiroId);
expect(contatoPrimeiroAtualizado?.principal).toBe(false);
expect(contatos.filter((c) => c.principal).length).toBe(1);
});
it('atualiza e remove contato', async () => {
const criado = await asTenant(token)
.post(`/tenant/customers/${customerId}/contatos`)
.send({ nome: 'Contato Temporário' })
.expect(201);
const contactId = (criado.body as { id: string }).id;
await asTenant(token)
.put(`/tenant/customers/${customerId}/contatos/${contactId}`)
.send({ cargo: 'Financeiro' })
.expect(200);
await asTenant(token)
.delete(`/tenant/customers/${customerId}/contatos/${contactId}`)
.expect(200);
await asTenant(token)
.get(`/tenant/customers/${customerId}/contatos/${contactId}`)
.expect(404);
});
it('contato de customer inexistente retorna 404', async () => {
await asTenant(token)
.post(`/tenant/customers/${randomUUID()}/contatos`)
.send({ nome: 'Não Deveria Criar' })
.expect(404);
});
});
describe('produtos/serviços — CRUD, busca, soft delete e SKU único por tenant', () => {
let token: string;
beforeAll(async () => {
const plano = await criarPlano();
const tenant = await criarTenantComAdmin(plano.id);
token = tenant.token;
});
it('cria produto e serviço válidos', async () => {
const produto = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Cabo HDMI', valor_venda: 39.9 })
.expect(201);
expect(produto.body).toMatchObject({ tipo: 'produto', nome: 'Cabo HDMI', ativo: true });
const servico = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'servico', nome: 'Instalação', valor_venda: 150, recorrente: false })
.expect(201);
expect(servico.body).toMatchObject({ tipo: 'servico', nome: 'Instalação' });
});
it('tipo ausente ou valor_venda <= 0 retornam 400', async () => {
await asTenant(token)
.post('/tenant/produtos')
.send({ nome: 'Sem Tipo', valor_venda: 10 })
.expect(400);
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Valor Zero', valor_venda: 0 })
.expect(400);
});
it('SKU duplicado no mesmo tenant retorna 409 amigável', async () => {
const sku = `SKU-${randomUUID().slice(0, 8)}`;
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto A', valor_venda: 10, sku })
.expect(201);
const conflito = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto B', valor_venda: 20, sku })
.expect(409);
expect((conflito.body as { message: string }).message).toMatch(/sku/i);
});
it('SKU igual é permitido em tenants diferentes', async () => {
const plano = await criarPlano();
const outroTenant = await criarTenantComAdmin(plano.id);
const sku = `SKU-COMPARTILHADO-${randomUUID().slice(0, 8)}`;
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Tenant A', valor_venda: 10, sku })
.expect(201);
await asTenant(outroTenant.token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Tenant B', valor_venda: 10, sku })
.expect(201);
});
it('lista com filtro por tipo/ativo e busca por nome/sku', async () => {
const sku = `SKU-BUSCA-${randomUUID().slice(0, 8)}`;
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'servico', nome: 'Serviço Filtrável', valor_venda: 50, sku })
.expect(201);
const porTipo = await asTenant(token).get('/tenant/produtos?tipo=servico').expect(200);
const bodyTipo = porTipo.body as { data: Array<{ tipo: string }> };
expect(bodyTipo.data.every((p) => p.tipo === 'servico')).toBe(true);
const porSku = await asTenant(token).get(`/tenant/produtos?busca=${sku}`).expect(200);
const bodySku = porSku.body as { data: Array<{ sku: string | null }> };
expect(bodySku.data.some((p) => p.sku === sku)).toBe(true);
});
it('DELETE faz soft delete — linha continua existindo com ativo=false', async () => {
const criado = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Para Desativar', valor_venda: 10 })
.expect(201);
const id = (criado.body as { id: string }).id;
await asTenant(token).delete(`/tenant/produtos/${id}`).expect(200);
const naBase = await prisma.products_services.findUnique({ where: { id } });
expect(naBase).not.toBeNull();
expect(naBase?.ativo).toBe(false);
});
});
describe('isolamento entre tenants (RLS)', () => {
let tokenA: string;
let tokenB: string;
let customerIdA: string;
let productIdA: string;
beforeAll(async () => {
const planoA = await criarPlano();
const tenantA = await criarTenantComAdmin(planoA.id);
tokenA = tenantA.token;
const planoB = await criarPlano();
const tenantB = await criarTenantComAdmin(planoB.id);
tokenB = tenantB.token;
const customer = await asTenant(tokenA)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Só do Tenant A' })
.expect(201);
customerIdA = (customer.body as { id: string }).id;
const produto = await asTenant(tokenA)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Só do Tenant A', valor_venda: 10 })
.expect(201);
productIdA = (produto.body as { id: string }).id;
});
it('tenant B não enxerga customer do tenant A mesmo sabendo o UUID exato (404, não 403)', async () => {
await asTenant(tokenB).get(`/tenant/customers/${customerIdA}`).expect(404);
await asTenant(tokenB)
.put(`/tenant/customers/${customerIdA}`)
.send({ razao_social: 'Tentativa de Editar' })
.expect(404);
await asTenant(tokenB).delete(`/tenant/customers/${customerIdA}`).expect(404);
const lista = await asTenant(tokenB).get('/tenant/customers').expect(200);
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((c) => c.id);
expect(ids).not.toContain(customerIdA);
});
it('tenant B não enxerga produto do tenant A mesmo sabendo o UUID exato (404, não 403)', async () => {
await asTenant(tokenB).get(`/tenant/produtos/${productIdA}`).expect(404);
await asTenant(tokenB)
.put(`/tenant/produtos/${productIdA}`)
.send({ nome: 'Tentativa de Editar' })
.expect(404);
await asTenant(tokenB).delete(`/tenant/produtos/${productIdA}`).expect(404);
});
it('tenant B não enxerga contatos do customer do tenant A', async () => {
await asTenant(tokenB).get(`/tenant/customers/${customerIdA}/contatos`).expect(404);
});
});
describe('acesso master sem contexto de tenant', () => {
it('master "puro" recebe 403 em todos os endpoints desta etapa', async () => {
await asMaster().get('/tenant/customers').expect(403);
await asMaster().get('/tenant/produtos').expect(403);
});
});
});