Files
2026-08-14 17:35:24 -03:00

552 lines
20 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;
}
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<typeof request>[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<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}`),
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<Record<string, unknown>> = {}) {
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<Record<string, unknown>> = {}) {
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<typeof request>[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<string, number>;
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);
});
});
});