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; } function referenciaAtual(): string { const agora = new Date(); return `${agora.getUTCFullYear()}-${String(agora.getUTCMonth() + 1).padStart(2, '0')}`; } describe('Master — Planos, Tenants e Faturas (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let httpServer: Parameters[0]; const senha = 'SenhaForteDemo#26'; const suffix = randomUUID().slice(0, 8); const masterEmail = `master-etapa3-${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}`), put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${masterToken}`), patch: (url: string) => request(httpServer).patch(url).set('Authorization', `Bearer ${masterToken}`), delete: (url: string) => request(httpServer).delete(url).set('Authorization', `Bearer ${masterToken}`), }; } 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_customers: 100, limite_usuarios: 5, limite_instancias_whatsapp: 1, ...overrides, }) .expect(201); planoIds.push((res.body as { id: string }).id); return res.body as { id: string; valor_mensal: string; ativo: boolean }; } async function criarTenant(planoId: string, overrides: Partial> = {}) { const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`; const res = await asMaster() .post('/master/tenants') .send({ razao_social: `Tenant ${suffix} ${randomUUID().slice(0, 6)}`, email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`, plano_id: planoId, admin: { nome: 'Admin de Teste', email: emailAdmin }, ...overrides, }) .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); return body; } 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 3', email: masterEmail, senha_hash }, }); masterId = master.id; masterToken = await authMaster(); }); afterAll(async () => { // tenants em cascata levam tenant_users e tenant_faturas junto // (onDelete: Cascade no schema); planos só depois, pois tenants tem FK // pra planos. 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('CRUD de planos', () => { it('cria plano válido', async () => { const plano = await criarPlano({ valor_mensal: 249.5 }); expect(plano.valor_mensal).toBe('249.5'); expect(plano.ativo).toBe(true); }); it('rejeita valor_mensal <= 0', async () => { await asMaster() .post('/master/planos') .send({ nome: 'Plano inválido', valor_mensal: 0 }) .expect(400); }); it('rejeita limite <= 0 quando informado', async () => { await asMaster() .post('/master/planos') .send({ nome: 'Plano inválido', valor_mensal: 10, limite_customers: 0 }) .expect(400); }); it('lista, detalha e atualiza', async () => { const plano = await criarPlano(); const lista = await asMaster().get('/master/planos').expect(200); expect((lista.body as Array<{ id: string }>).some((p) => p.id === plano.id)).toBe(true); const detalhe = await asMaster().get(`/master/planos/${plano.id}`).expect(200); expect((detalhe.body as { id: string }).id).toBe(plano.id); const atualizado = await asMaster() .put(`/master/planos/${plano.id}`) .send({ nome: 'Plano Atualizado', valor_mensal: 299.9 }) .expect(200); expect((atualizado.body as { nome: string }).nome).toBe('Plano Atualizado'); }); it('exclui plano não usado por nenhum tenant', async () => { const plano = await criarPlano(); const res = await asMaster().delete(`/master/planos/${plano.id}`).expect(200); expect(res.body).toMatchObject({ deletado: true, desativado: false }); await asMaster().get(`/master/planos/${plano.id}`).expect(404); // já foi removido do array de cleanup implicitamente — não afeta o afterAll // (deleteMany ignora ids que não existem mais). }); it('não permite excluir plano em uso por tenant ativo — desativa em vez disso', async () => { const plano = await criarPlano(); await criarTenant(plano.id); const res = await asMaster().delete(`/master/planos/${plano.id}`).expect(200); expect(res.body).toMatchObject({ deletado: false, desativado: true }); const detalhe = await asMaster().get(`/master/planos/${plano.id}`).expect(200); expect((detalhe.body as { ativo: boolean }).ativo).toBe(false); }); it('todos os endpoints de planos são bloqueados com token de tenant_user', async () => { const plano = await criarPlano(); const { admin } = await criarTenant(plano.id); const loginTenant = await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(200); const tenantToken = (loginTenant.body as TokenPair).access_token; await request(httpServer) .get('/master/planos') .set('Authorization', `Bearer ${tenantToken}`) .expect(403); }); }); describe('Tenants', () => { it('cria tenant com o primeiro tenant_admin funcional (login testado) e grava auditoria', async () => { const plano = await criarPlano(); const { tenant, admin } = await criarTenant(plano.id); expect(tenant).toMatchObject({ status: 'trial' }); expect(admin.senha_provisoria).toEqual(expect.any(String)); expect(admin.senha_provisoria.length).toBeGreaterThanOrEqual(10); const login = await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(200); expect((login.body as TokenPair).access_token).toEqual(expect.any(String)); const log = await prisma.audit_logs.findFirst({ where: { acao: 'criar_tenant', entidade_id: tenant.id }, }); expect(log).not.toBeNull(); expect(log?.usuario_id).toBe(masterId); }); it('rejeita criação com plano inexistente', async () => { await asMaster() .post('/master/tenants') .send({ razao_social: 'Tenant Plano Inválido', email_contato: `x-${randomUUID()}@teste.com`, plano_id: randomUUID(), admin: { nome: 'Admin', email: `y-${randomUUID()}@teste.com` }, }) .expect(400); }); it('listagem com filtro por status e busca funciona', async () => { const plano = await criarPlano(); const marcador = `buscamarcador${suffix}`; const { tenant } = await criarTenant(plano.id, { razao_social: `Empresa ${marcador} Ltda` }); const porBusca = await asMaster().get(`/master/tenants?busca=${marcador}`).expect(200); const idsBusca = (porBusca.body as { data: Array<{ id: string }> }).data.map((t) => t.id); expect(idsBusca).toEqual([tenant.id]); const porStatus = await asMaster() .get(`/master/tenants?busca=${marcador}&status=trial`) .expect(200); expect((porStatus.body as { data: Array<{ id: string }> }).data.map((t) => t.id)).toEqual([ tenant.id, ]); const statusErrado = await asMaster() .get(`/master/tenants?busca=${marcador}&status=cancelado`) .expect(200); expect((statusErrado.body as { data: unknown[] }).data).toEqual([]); }); it('detalhe do tenant traz plano, faturas e contadores agregados', async () => { const plano = await criarPlano(); const { tenant } = await criarTenant(plano.id); await asMaster().post('/master/faturas/gerar-mes').send({}).expect(200); const detalhe = await asMaster().get(`/master/tenants/${tenant.id}`).expect(200); const body = detalhe.body as { plano: { id: string }; faturas: unknown[]; _count: { usuarios: number; customers: number; vendas: number }; }; expect(body.plano.id).toBe(plano.id); expect(body._count).toMatchObject({ usuarios: 1, customers: 0, vendas: 0 }); // o tenant começa como `trial`; gerar-mes só gera fatura pra quem já // está `ativo`, então aqui a lista de faturas fica vazia mesmo. expect(Array.isArray(body.faturas)).toBe(true); }); it('atualiza dados cadastrais via PUT', async () => { const plano = await criarPlano(); const { tenant } = await criarTenant(plano.id); const atualizado = await asMaster() .put(`/master/tenants/${tenant.id}`) .send({ telefone: '11999998888' }) .expect(200); expect((atualizado.body as { telefone: string }).telefone).toBe('11999998888'); }); it('muda status para suspenso: bloqueia login novo e revoga acesso de token já emitido (TenantGuard) + audita', async () => { const plano = await criarPlano(); const { tenant, admin } = await criarTenant(plano.id); const login = await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(200); const tenantToken = (login.body as TokenPair).access_token; // antes de suspender, o token funciona normalmente numa rota com TenantGuard await request(httpServer) .get('/auth/tenant/users') .set('Authorization', `Bearer ${tenantToken}`) .expect(200); await asMaster() .patch(`/master/tenants/${tenant.id}/status`) .send({ status: 'suspenso' }) .expect(200); // login novo passa a ser rejeitado await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(401); // o token de acesso emitido antes da suspensão, ainda não expirado, // é bloqueado pelo TenantGuard (checa o status do tenant a cada request) await request(httpServer) .get('/auth/tenant/users') .set('Authorization', `Bearer ${tenantToken}`) .expect(403); const log = await prisma.audit_logs.findFirst({ where: { acao: 'mudar_status_tenant', entidade_id: tenant.id }, }); expect(log?.detalhes).toMatchObject({ de: 'trial', para: 'suspenso' }); }); it('muda status para cancelado também bloqueia o acesso', async () => { const plano = await criarPlano(); const { tenant, admin } = await criarTenant(plano.id); await asMaster() .patch(`/master/tenants/${tenant.id}/status`) .send({ status: 'cancelado' }) .expect(200); await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(401); }); it('todos os endpoints de tenants são bloqueados com token de tenant_user', async () => { const plano = await criarPlano(); const { admin } = await criarTenant(plano.id); const loginTenant = await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(200); const tenantToken = (loginTenant.body as TokenPair).access_token; await request(httpServer) .get('/master/tenants') .set('Authorization', `Bearer ${tenantToken}`) .expect(403); }); }); describe('Faturas', () => { it('gerar-mes não duplica fatura já existente para o mesmo tenant/referência', async () => { const plano = await criarPlano({ valor_mensal: 150 }); const { tenant } = await criarTenant(plano.id); await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } }); const referencia = referenciaAtual(); await asMaster().post('/master/faturas/gerar-mes').send({ referencia }).expect(200); await asMaster().post('/master/faturas/gerar-mes').send({ referencia }).expect(200); const faturas = await prisma.tenant_faturas.findMany({ where: { tenant_id: tenant.id, referencia }, }); expect(faturas).toHaveLength(1); expect(faturas[0].valor.toString()).toBe('150'); }); it('marcar fatura como paga reverte tenant inadimplente -> ativo e audita', async () => { const plano = await criarPlano(); const { tenant } = await criarTenant(plano.id); await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'inadimplente' } }); const fatura = await prisma.tenant_faturas.create({ data: { tenant_id: tenant.id, referencia: '2020-01', valor: 100, vencimento: new Date('2020-01-10T00:00:00Z'), }, }); const res = await asMaster().patch(`/master/faturas/${fatura.id}/marcar-pago`).expect(200); expect((res.body as { status: string; pago_em: string }).status).toBe('pago'); const tenantAtualizado = await prisma.tenants.findUnique({ where: { id: tenant.id } }); expect(tenantAtualizado?.status).toBe('ativo'); // idempotência: marcar de novo uma fatura já paga é rejeitado await asMaster().patch(`/master/faturas/${fatura.id}/marcar-pago`).expect(409); const log = await prisma.audit_logs.findFirst({ where: { acao: 'marcar_fatura_paga', entidade_id: fatura.id }, }); expect(log).not.toBeNull(); }); it('atualizar-atrasadas marca fatura vencida como atrasada e tenant como inadimplente', async () => { const plano = await criarPlano(); const { tenant } = await criarTenant(plano.id); await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } }); const fatura = await prisma.tenant_faturas.create({ data: { tenant_id: tenant.id, referencia: '2020-02', valor: 100, vencimento: new Date('2020-02-10T00:00:00Z'), }, }); await asMaster().post('/master/faturas/atualizar-atrasadas').send({}).expect(200); const faturaAtualizada = await prisma.tenant_faturas.findUnique({ where: { id: fatura.id } }); expect(faturaAtualizada?.status).toBe('atrasado'); const tenantAtualizado = await prisma.tenants.findUnique({ where: { id: tenant.id } }); expect(tenantAtualizado?.status).toBe('inadimplente'); const log = await prisma.audit_logs.findFirst({ where: { acao: 'atualizar_faturas_atrasadas' }, orderBy: { criado_em: 'desc' }, }); expect((log?.detalhes as { tenant_ids: string[] })?.tenant_ids).toContain(tenant.id); }); it('listagem de faturas filtra por tenant e status', async () => { const plano = await criarPlano(); const { tenant } = await criarTenant(plano.id); await prisma.tenant_faturas.create({ data: { tenant_id: tenant.id, referencia: '2020-03', valor: 50, vencimento: new Date('2020-03-10'), }, }); const res = await asMaster() .get(`/master/faturas?tenant_id=${tenant.id}&status=pendente`) .expect(200); const data = (res.body as { data: Array<{ tenant_id: string; status: string }> }).data; expect(data.length).toBeGreaterThanOrEqual(1); expect(data.every((f) => f.tenant_id === tenant.id && f.status === 'pendente')).toBe(true); }); it('todos os endpoints de faturas são bloqueados com token de tenant_user', async () => { const plano = await criarPlano(); const { admin } = await criarTenant(plano.id); const loginTenant = await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(200); const tenantToken = (loginTenant.body as TokenPair).access_token; await request(httpServer) .get('/master/faturas') .set('Authorization', `Bearer ${tenantToken}`) .expect(403); }); }); describe('Dashboard', () => { it('retorna números corretos (conferidos por delta antes/depois)', async () => { const antes = await asMaster().get('/master/dashboard').expect(200); const bodyAntes = antes.body as { tenants_por_status: Record; mrr: string; total_recebido_mes: string; total_em_aberto: string; }; const plano = await criarPlano({ valor_mensal: 321.5 }); const { tenant } = await criarTenant(plano.id); await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } }); await prisma.tenant_faturas.create({ data: { tenant_id: tenant.id, referencia: '2020-04', valor: 77.25, status: 'pago', pago_em: new Date(), vencimento: new Date('2020-04-10'), }, }); await prisma.tenant_faturas.create({ data: { tenant_id: tenant.id, referencia: '2020-05', valor: 33.1, status: 'pendente', vencimento: new Date('2020-05-10'), }, }); const depois = await asMaster().get('/master/dashboard').expect(200); const bodyDepois = depois.body as typeof bodyAntes; expect(bodyDepois.tenants_por_status.ativo).toBeGreaterThanOrEqual( (bodyAntes.tenants_por_status.ativo ?? 0) + 1, ); const deltaMrr = Number(bodyDepois.mrr) - Number(bodyAntes.mrr); expect(deltaMrr).toBeCloseTo(321.5, 2); const deltaRecebido = Number(bodyDepois.total_recebido_mes) - Number(bodyAntes.total_recebido_mes); expect(deltaRecebido).toBeCloseTo(77.25, 2); const deltaAberto = Number(bodyDepois.total_em_aberto) - Number(bodyAntes.total_em_aberto); expect(deltaAberto).toBeCloseTo(33.1, 2); }); it('bloqueado com token de tenant_user', async () => { const plano = await criarPlano(); const { admin } = await criarTenant(plano.id); const loginTenant = await request(httpServer) .post('/auth/tenant/login') .send({ email: admin.email, senha: admin.senha_provisoria }) .expect(200); const tenantToken = (loginTenant.body as TokenPair).access_token; await request(httpServer) .get('/master/dashboard') .set('Authorization', `Bearer ${tenantToken}`) .expect(403); }); }); });