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[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 { 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> = {}) { 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[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); }); }); });