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 — Vendas, Parcelas e Dashboards (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let httpServer: Parameters[0]; const senha = 'SenhaForteDemo#26'; const suffix = randomUUID().slice(0, 8); const masterEmail = `master-etapa6-${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 { 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}`), patch: (url: string) => request(httpServer).patch(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() { const plano = await criarPlano(); const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`; const res = await asMaster() .post('/master/tenants') .send({ razao_social: `Tenant Etapa6 ${suffix} ${randomUUID().slice(0, 6)}`, email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`, plano_id: plano.id, 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, token: (login.body as TokenPair).access_token }; } async function criarCustomer(token: string, overrides: Partial> = {}) { const res = await asTenant(token) .post('/tenant/customers') .send({ razao_social: `Cliente ${randomUUID().slice(0, 8)}`, ...overrides }) .expect(201); return res.body as { id: string }; } async function criarProduto(token: string, overrides: Partial> = {}) { const res = await asTenant(token) .post('/tenant/produtos') .send({ tipo: 'produto', nome: `Produto ${randomUUID().slice(0, 8)}`, valor_venda: 100, ...overrides, }) .expect(201); return res.body as { id: string }; } 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 6', email: masterEmail, senha_hash }, }); masterId = master.id; masterToken = await authMaster(); }); afterAll(async () => { // `sale_items.product_service_id`/`sale_payments` não têm CASCADE de // `products_services`/`sales` no schema (de propósito — histórico de // vendas não pode sumir só porque o produto foi excluído). Sem apagar // `sales` primeiro, o cascade de `tenants` → `products_services` pode // rodar antes do cascade `tenants` → `sales` → `sale_items` e violar a // FK. Apagar `sales` explicitamente já cascateia pra `sale_items`/ // `sale_payments` (essas duas têm `ON DELETE CASCADE` em `sale_id`). await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } }); 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('POST /tenant/vendas — criação com itens e parcelas em transação', () => { let token: string; let customerId: string; let produtoId: string; beforeAll(async () => { const tenant = await criarTenantComAdmin(); token = tenant.token; customerId = (await criarCustomer(token)).id; produtoId = (await criarProduto(token, { valor_venda: 50 })).id; }); it('cria venda com numero_parcelas — divide o valor_total igualmente', async () => { const res = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 3 }], numero_parcelas: 3, }) .expect(201); const venda = res.body as { valor_total: string; itens: Array<{ valor_total: string }>; parcelas: Array<{ valor: string; numero_parcela: number }>; }; expect(Number(venda.valor_total)).toBe(150); expect(venda.parcelas).toHaveLength(3); expect(venda.parcelas.reduce((acc, p) => acc + Number(p.valor), 0)).toBeCloseTo(150, 2); }); it('cria venda com parcelas explícitas cuja soma bate com valor_total', async () => { const res = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 2, valor_unitario: 40 }], parcelas: [ { valor: 40, vencimento: '2026-09-01' }, { valor: 40, vencimento: '2026-10-01' }, ], }) .expect(201); expect(Number((res.body as { valor_total: string }).valor_total)).toBe(80); }); it('desconto aplicado reduz o valor_total', async () => { const res = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 2, valor_unitario: 50 }], desconto: 20, numero_parcelas: 1, }) .expect(201); expect(Number((res.body as { valor_total: string }).valor_total)).toBe(80); }); it('soma das parcelas não bate com valor_total retorna 400', async () => { await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }], parcelas: [{ valor: 50, vencimento: '2026-09-01' }], }) .expect(400); }); it('desconto maior que a soma dos itens retorna 400', async () => { await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }], desconto: 200, numero_parcelas: 1, }) .expect(400); }); it('numero_parcelas e parcelas juntos retorna 400', async () => { await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }], numero_parcelas: 2, parcelas: [{ valor: 100, vencimento: '2026-09-01' }], }) .expect(400); }); it('nem numero_parcelas nem parcelas retorna 400', async () => { await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }], }) .expect(400); }); it('customer_id inexistente retorna 400', async () => { await asTenant(token) .post('/tenant/vendas') .send({ customer_id: randomUUID(), itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }], numero_parcelas: 1, }) .expect(400); }); it('product_service_id inexistente retorna 400 e não cria nada (transação revertida)', async () => { const antes = await asTenant(token).get('/tenant/vendas?limit=1').expect(200); const totalAntes = (antes.body as { meta: { total: number } }).meta.total; await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [ { product_service_id: produtoId, quantidade: 1, valor_unitario: 10 }, { product_service_id: randomUUID(), quantidade: 1, valor_unitario: 10 }, ], numero_parcelas: 1, }) .expect(400); const depois = await asTenant(token).get('/tenant/vendas?limit=1').expect(200); expect((depois.body as { meta: { total: number } }).meta.total).toBe(totalAntes); }); it('produto inativo retorna 400', async () => { const produtoInativoId = (await criarProduto(token)).id; await asTenant(token).delete(`/tenant/produtos/${produtoInativoId}`).expect(200); await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoInativoId, quantidade: 1, valor_unitario: 10 }], numero_parcelas: 1, }) .expect(400); }); }); describe('GET /tenant/vendas — listagem e detalhe', () => { let token: string; let customerId: string; let produtoId: string; let vendaId: string; beforeAll(async () => { const tenant = await criarTenantComAdmin(); token = tenant.token; customerId = (await criarCustomer(token)).id; produtoId = (await criarProduto(token, { valor_venda: 30 })).id; const venda = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, }) .expect(201); vendaId = (venda.body as { id: string }).id; }); it('lista com filtro por customer_id e status', async () => { const res = await asTenant(token) .get(`/tenant/vendas?customer_id=${customerId}&status=confirmada`) .expect(200); const body = res.body as { data: Array<{ id: string }> }; expect(body.data.some((v) => v.id === vendaId)).toBe(true); }); it('detalhe traz itens e parcelas', async () => { const res = await asTenant(token).get(`/tenant/vendas/${vendaId}`).expect(200); const venda = res.body as { itens: unknown[]; parcelas: unknown[] }; expect(venda.itens.length).toBe(1); expect(venda.parcelas.length).toBe(1); }); it('venda inexistente retorna 404', async () => { await asTenant(token).get(`/tenant/vendas/${randomUUID()}`).expect(404); }); }); describe('PATCH /tenant/vendas/:id/status — transições e cancelamento em cascata', () => { let token: string; let customerId: string; let produtoId: string; beforeAll(async () => { const tenant = await criarTenantComAdmin(); token = tenant.token; customerId = (await criarCustomer(token)).id; produtoId = (await criarProduto(token, { valor_venda: 60 })).id; }); async function criarVenda(status?: string) { const res = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 2, ...(status ? { status } : {}), }) .expect(201); return res.body as { id: string }; } it('orcamento → confirmada → concluida funciona', async () => { const venda = await criarVenda('orcamento'); await asTenant(token) .patch(`/tenant/vendas/${venda.id}/status`) .send({ status: 'confirmada' }) .expect(200); await asTenant(token) .patch(`/tenant/vendas/${venda.id}/status`) .send({ status: 'concluida' }) .expect(200); }); it('orcamento → concluida direto é transição inválida (409)', async () => { const venda = await criarVenda('orcamento'); await asTenant(token) .patch(`/tenant/vendas/${venda.id}/status`) .send({ status: 'concluida' }) .expect(409); }); it('cancelar venda cancela as parcelas pendentes associadas', async () => { const venda = await criarVenda(); await asTenant(token) .patch(`/tenant/vendas/${venda.id}/status`) .send({ status: 'cancelada' }) .expect(200); const detalhe = await asTenant(token).get(`/tenant/vendas/${venda.id}`).expect(200); const parcelas = (detalhe.body as { parcelas: Array<{ status: string }> }).parcelas; expect(parcelas.every((p) => p.status === 'cancelado')).toBe(true); }); it('venda já cancelada não aceita nova transição (409)', async () => { const venda = await criarVenda(); await asTenant(token) .patch(`/tenant/vendas/${venda.id}/status`) .send({ status: 'cancelada' }) .expect(200); await asTenant(token) .patch(`/tenant/vendas/${venda.id}/status`) .send({ status: 'confirmada' }) .expect(409); }); }); describe('PUT /tenant/vendas/:id — só edita venda em orcamento', () => { let token: string; let customerId: string; let produtoId: string; beforeAll(async () => { const tenant = await criarTenantComAdmin(); token = tenant.token; customerId = (await criarCustomer(token)).id; produtoId = (await criarProduto(token, { valor_venda: 25 })).id; }); it('edita itens/valores de uma venda em orcamento', async () => { const criada = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, status: 'orcamento', }) .expect(201); const id = (criada.body as { id: string }).id; const editada = await asTenant(token) .put(`/tenant/vendas/${id}`) .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 4 }], numero_parcelas: 2, }) .expect(200); expect(Number((editada.body as { valor_total: string }).valor_total)).toBe(100); expect((editada.body as { parcelas: unknown[] }).parcelas).toHaveLength(2); }); it('venda confirmada não pode ser editada via PUT (409)', async () => { const criada = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, }) .expect(201); const id = (criada.body as { id: string }).id; await asTenant(token) .put(`/tenant/vendas/${id}`) .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 2 }], numero_parcelas: 1, }) .expect(409); }); }); describe('recebimentos — listagem, marcar-pago e atualizar-atrasadas', () => { let token: string; let customerId: string; let produtoId: string; beforeAll(async () => { const tenant = await criarTenantComAdmin(); token = tenant.token; customerId = (await criarCustomer(token)).id; produtoId = (await criarProduto(token, { valor_venda: 90 })).id; }); it('marca parcela como paga — pago_em e forma_pagamento preenchidos', async () => { const venda = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, }) .expect(201); const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id; const pago = await asTenant(token) .patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`) .send({ forma_pagamento: 'pix' }) .expect(200); const body = pago.body as { status: string; pago_em: string | null; forma_pagamento: string }; expect(body.status).toBe('pago'); expect(body.pago_em).not.toBeNull(); expect(body.forma_pagamento).toBe('pix'); }); it('marcar como paga uma parcela já paga retorna 409', async () => { const venda = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, }) .expect(201); const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id; await asTenant(token) .patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`) .send({ forma_pagamento: 'pix' }) .expect(200); await asTenant(token) .patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`) .send({ forma_pagamento: 'pix' }) .expect(409); }); it('atualizar-atrasadas muda parcela pendente vencida para atrasado', async () => { const venda = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 90 }], parcelas: [{ valor: 90, vencimento: '2020-01-01' }], }) .expect(201); const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id; await asTenant(token).post('/tenant/recebimentos/atualizar-atrasadas').expect(201); const lista = await asTenant(token).get(`/tenant/recebimentos?status=atrasado`).expect(200); const ids = (lista.body as { data: Array<{ id: string }> }).data.map((p) => p.id); expect(ids).toContain(parcelaId); }); it('lista recebimentos filtrando por customer_id', async () => { const res = await asTenant(token) .get(`/tenant/recebimentos?customer_id=${customerId}`) .expect(200); const body = res.body as { data: Array<{ sale: { customer_id: string } }> }; expect(body.data.every((p) => p.sale.customer_id === customerId)).toBe(true); }); }); describe('dashboard do tenant', () => { let token: string; let customerId: string; let produtoId: string; beforeAll(async () => { const tenant = await criarTenantComAdmin(); token = tenant.token; customerId = (await criarCustomer(token)).id; produtoId = (await criarProduto(token, { valor_venda: 200 })).id; // venda confirmada (conta nos resumos) e uma em orçamento (não deve contar) await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, }) .expect(201); await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1 }], numero_parcelas: 1, status: 'orcamento', }) .expect(201); }); it('resumo-mensal inclui a venda confirmada do mês corrente', async () => { const res = await asTenant(token).get('/tenant/dashboard/resumo-mensal').expect(200); const linhas = res.body as Array<{ qtd_vendas: number; total_vendido: number }>; expect(linhas.length).toBe(1); expect(linhas[0].qtd_vendas).toBe(1); expect(linhas[0].total_vendido).toBe(200); }); it('resumo-semanal inclui a venda confirmada da semana corrente', async () => { const res = await asTenant(token).get('/tenant/dashboard/resumo-semanal').expect(200); const linhas = res.body as Array<{ qtd_vendas: number }>; expect(linhas.length).toBe(1); expect(linhas[0].qtd_vendas).toBe(1); }); it('financeiro reflete parcela paga no total recebido do mês', async () => { const antes = await asTenant(token).get('/tenant/dashboard/financeiro').expect(200); const totalAntes = Number((antes.body as { total_recebido_mes: string }).total_recebido_mes); const vendaComPendencia = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 200 }], numero_parcelas: 1, }) .expect(201); const parcelaId = (vendaComPendencia.body as { parcelas: Array<{ id: string }> }).parcelas[0] .id; await asTenant(token) .patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`) .send({ forma_pagamento: 'dinheiro' }) .expect(200); const depois = await asTenant(token).get('/tenant/dashboard/financeiro').expect(200); const totalDepois = Number( (depois.body as { total_recebido_mes: string }).total_recebido_mes, ); expect(totalDepois - totalAntes).toBeCloseTo(200, 2); }); }); describe('isolamento entre tenants (RLS) — reforçado nesta etapa', () => { let tokenA: string; let tokenB: string; let vendaIdA: string; let parcelaIdA: string; beforeAll(async () => { const tenantA = await criarTenantComAdmin(); tokenA = tenantA.token; const tenantB = await criarTenantComAdmin(); tokenB = tenantB.token; const customerA = await criarCustomer(tokenA); const produtoA = await criarProduto(tokenA, { valor_venda: 333 }); const venda = await asTenant(tokenA) .post('/tenant/vendas') .send({ customer_id: customerA.id, itens: [{ product_service_id: produtoA.id, quantidade: 1 }], numero_parcelas: 1, }) .expect(201); vendaIdA = (venda.body as { id: string }).id; parcelaIdA = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id; }); it('tenant B não enxerga venda do tenant A (404, não 403)', async () => { await asTenant(tokenB).get(`/tenant/vendas/${vendaIdA}`).expect(404); await asTenant(tokenB) .patch(`/tenant/vendas/${vendaIdA}/status`) .send({ status: 'cancelada' }) .expect(404); const lista = await asTenant(tokenB).get('/tenant/vendas').expect(200); const ids = (lista.body as { data: Array<{ id: string }> }).data.map((v) => v.id); expect(ids).not.toContain(vendaIdA); }); it('tenant B não enxerga nem consegue marcar como paga a parcela do tenant A', async () => { const lista = await asTenant(tokenB).get('/tenant/recebimentos').expect(200); const ids = (lista.body as { data: Array<{ id: string }> }).data.map((p) => p.id); expect(ids).not.toContain(parcelaIdA); await asTenant(tokenB) .patch(`/tenant/recebimentos/${parcelaIdA}/marcar-pago`) .send({ forma_pagamento: 'pix' }) .expect(404); }); it('dashboard do tenant B não inclui a venda do tenant A (confirma o fix de security_invoker das views)', async () => { const res = await asTenant(tokenB).get('/tenant/dashboard/resumo-mensal').expect(200); const linhas = res.body as Array<{ total_vendido: number }>; expect(linhas.some((l) => l.total_vendido === 333)).toBe(false); }); }); describe('acesso master sem contexto de tenant', () => { it('master "puro" recebe 403 nos endpoints desta etapa', async () => { await request(httpServer) .get('/tenant/vendas') .set('Authorization', `Bearer ${masterToken}`) .expect(403); await request(httpServer) .get('/tenant/recebimentos') .set('Authorization', `Bearer ${masterToken}`) .expect(403); await request(httpServer) .get('/tenant/dashboard/financeiro') .set('Authorization', `Bearer ${masterToken}`) .expect(403); }); }); });