import { randomUUID } from 'node:crypto'; import { IncomingMessage, Server, ServerResponse, createServer } from 'node:http'; import { AddressInfo } from 'node:net'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getQueueToken } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { WorkerModule } from '../src/worker/worker.module'; import { PrismaService } from '../src/config/prisma.service'; import { hashPassword } from '../src/modules/auth/password.util'; import { AlertSchedulerService } from '../src/worker/alert-scheduler.service'; import { QUEUE_ALERTAS_COBRANCA, QUEUE_ALERTAS_MENSALIDADE, } from '../src/modules/alerts/queue.constants'; const mockSendMail = jest .fn() .mockImplementation((opts: { to: string }) => Promise.resolve({ messageId: `ok-${opts.to}` })); jest.mock('nodemailer', () => ({ createTransport: jest.fn(() => ({ sendMail: mockSendMail })), })); interface TokenPair { access_token: string; refresh_token: string; } /** Subset mínimo da Evolution API v2 usado por esta etapa (criar instância, status, enviar mensagem) — mesmo padrão do `FakeEvolutionServer` de `integracoes.e2e-spec.ts`, sem os endpoints não usados aqui. */ class FakeEvolutionServer { server: Server; baseUrl = ''; async start(): Promise { this.server = createServer((req, res) => this.handle(req, res)); await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve)); const { port } = this.server.address() as AddressInfo; this.baseUrl = `http://127.0.0.1:${port}`; } async stop(): Promise { await new Promise((resolve) => this.server.close(() => resolve())); } private handle(req: IncomingMessage, res: ServerResponse): void { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { const url = req.url ?? ''; const method = req.method ?? 'GET'; const json = (status: number, payload: unknown) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(payload)); }; if (method === 'POST' && url === '/instance/create') { return json(201, { instance: {} }); } if (method === 'GET' && url.startsWith('/instance/connectionState/')) { return json(200, { instance: { state: 'open' } }); } if (method === 'POST' && url.startsWith('/message/sendText/')) { return json(201, { status: 'PENDING' }); } json(404, { message: 'rota não simulada' }); }); } } describe('Motor de Alertas — Templates, Jobs Diários e Logs (e2e)', () => { let httpApp: INestApplication; let workerApp: INestApplication; let prisma: PrismaService; let httpServer: Parameters[0]; let evolution: FakeEvolutionServer; let alertScheduler: AlertSchedulerService; let filaMensalidade: Queue; let filaCobranca: Queue; const senha = 'SenhaForteDemo#26'; const suffix = randomUUID().slice(0, 8); const masterEmail = `master-etapa7-${suffix}@teste.com`; let masterId: string; let masterToken: string; const tenantIds: string[] = []; const planoIds: string[] = []; const tenantUserIds: string[] = []; const evolutionConfigIds: string[] = []; const masterEmailConfigIds: string[] = []; const masterTemplateIds: string[] = []; async function aguardarFilasOciosas(timeoutMs = 15000): Promise { const inicio = Date.now(); while (Date.now() - inicio < timeoutMs) { const [m, c] = await Promise.all([ filaMensalidade.getJobCounts('waiting', 'active', 'delayed'), filaCobranca.getJobCounts('waiting', 'active', 'delayed'), ]); const pendentes = (m.waiting ?? 0) + (m.active ?? 0) + (m.delayed ?? 0) + (c.waiting ?? 0) + (c.active ?? 0) + (c.delayed ?? 0); if (pendentes === 0) return; await new Promise((resolve) => setTimeout(resolve, 150)); } throw new Error('Timeout esperando as filas de alerta ficarem ociosas'); } 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}`), delete: (url: string) => request(httpServer).delete(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() { const res = await asMaster() .post('/master/planos') .send({ nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`, valor_mensal: 199.9, limite_instancias_whatsapp: 1, }) .expect(201); planoIds.push((res.body as { id: string }).id); return res.body as { id: string }; } async function criarTenantComAdmin(telefone?: string) { const plano = await criarPlano(); const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`; const emailContato = `contato-${randomUUID().slice(0, 6)}-${suffix}@teste.com`; const res = await asMaster() .post('/master/tenants') .send({ razao_social: `Tenant Etapa7 ${suffix} ${randomUUID().slice(0, 6)}`, email_contato: emailContato, telefone, 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 { tenantId: body.tenant.id, emailContato, token: (login.body as TokenPair).access_token, }; } async function criarEvolutionConfigAtiva() { const res = await asMaster() .post('/master/evolution-config') .send({ nome: `Evolution ${suffix}`, base_url: evolution.baseUrl, api_key: 'chave-evolution-secreta-1234', }) .expect(201); evolutionConfigIds.push((res.body as { id: string }).id); } async function criarMasterEmailConfigAtiva() { const res = await asMaster() .post('/master/email-config') .send({ smtp_host: 'smtp.exemplo.com', smtp_porta: 587, smtp_usuario: 'no-reply', smtp_senha: 'senha-smtp-secreta', email_remetente: 'no-reply@seconectai.com.br', }) .expect(201); masterEmailConfigIds.push((res.body as { id: string }).id); } async function criarInstanciaConectada(token: string) { const res = await asTenant(token).post('/tenant/whatsapp/instancias').expect(201); const instanceId = (res.body as { id: string }).id; await asTenant(token).get(`/tenant/whatsapp/instancias/${instanceId}/status`).expect(200); return instanceId; } 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) { const res = await asTenant(token) .post('/tenant/produtos') .send({ tipo: 'produto', nome: `Produto ${randomUUID().slice(0, 8)}`, valor_venda: 150 }) .expect(201); return res.body as { id: string }; } /** Cria uma venda com uma única parcela já vencida (útil pro job de cobrança não precisar esperar `ALERT_DIAS_ANTECEDENCIA_COBRANCA`). */ async function criarVendaComParcelaVencida(token: string, customerId: string, produtoId: string) { const res = await asTenant(token) .post('/tenant/vendas') .send({ customer_id: customerId, itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 150 }], parcelas: [{ valor: 150, vencimento: '2020-01-01' }], }) .expect(201); return res.body as { id: string; parcelas: Array<{ id: string }> }; } async function criarMasterTemplate(overrides: Partial>) { const res = await asMaster().post('/master/alertas/templates').send(overrides).expect(201); masterTemplateIds.push((res.body as { id: string }).id); return res.body as { id: string }; } beforeAll(async () => { evolution = new FakeEvolutionServer(); await evolution.start(); const httpFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); httpApp = httpFixture.createNestApplication(); httpApp.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), ); await httpApp.init(); httpServer = httpApp.getHttpServer() as Parameters[0]; // O processo worker de verdade (`src/worker.ts`) roda separado do processo // web — aqui, no teste, os dois são inicializados como duas aplicações // Nest independentes no mesmo processo de teste (mesma técnica usada por // toda a suíte pra testar contra Postgres/Redis reais), só pra poder // aguardar o processamento das filas de forma síncrona no teste. const workerFixture: TestingModule = await Test.createTestingModule({ imports: [WorkerModule], }).compile(); workerApp = workerFixture.createNestApplication(); await workerApp.init(); alertScheduler = workerApp.get(AlertSchedulerService); filaMensalidade = workerApp.get(getQueueToken(QUEUE_ALERTAS_MENSALIDADE)); filaCobranca = workerApp.get(getQueueToken(QUEUE_ALERTAS_COBRANCA)); prisma = httpApp.get(PrismaService); const senha_hash = await hashPassword(senha); const master = await prisma.master_admins.create({ data: { nome: 'Master Etapa 7', email: masterEmail, senha_hash }, }); masterId = master.id; masterToken = await authMaster(); await criarEvolutionConfigAtiva(); await criarMasterEmailConfigAtiva(); }); afterAll(async () => { await filaMensalidade.obliterate({ force: true }).catch(() => undefined); await filaCobranca.obliterate({ force: true }).catch(() => undefined); await prisma.alert_logs.deleteMany({ where: { tenant_id: { in: tenantIds } } }); await prisma.alert_templates.deleteMany({ where: { id: { in: masterTemplateIds }, tenant_id: null }, }); await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } }); await prisma.tenant_whatsapp_instances.deleteMany({ where: { tenant_id: { in: tenantIds } } }); await prisma.tenant_faturas.deleteMany({ where: { tenant_id: { in: tenantIds } } }); await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } }); await prisma.planos.deleteMany({ where: { id: { in: planoIds } } }); await prisma.evolution_api_config.deleteMany({ where: { id: { in: evolutionConfigIds } } }); await prisma.master_email_config.deleteMany({ where: { id: { in: masterEmailConfigIds } } }); 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 workerApp.close(); await httpApp.close(); await evolution.stop(); }); describe('alert_templates — CRUD master e tenant', () => { it('master cria template padrão de mensalidade_tenant', async () => { const res = await asMaster() .post('/master/alertas/templates') .send({ tipo: 'mensalidade_tenant', canal: 'whatsapp', corpo: 'Olá {{nome}}, sua fatura de {{valor}} vence em {{vencimento}}.', }) .expect(201); masterTemplateIds.push((res.body as { id: string }).id); expect(res.body).toMatchObject({ tenant_id: null, tipo: 'mensalidade_tenant', ativo: true }); }); it('master cria template de email exige assunto', async () => { await asMaster() .post('/master/alertas/templates') .send({ tipo: 'mensalidade_tenant', canal: 'email', corpo: 'Corpo sem assunto' }) .expect(400); }); it('tenant não pode criar template tipo mensalidade_tenant', async () => { const tenant = await criarTenantComAdmin(); await asTenant(tenant.token) .post('/tenant/alertas/templates') .send({ tipo: 'mensalidade_tenant', canal: 'whatsapp', corpo: 'Tentativa inválida' }) .expect(400); }); it('tenant cria template próprio de cobranca_cliente', async () => { const tenant = await criarTenantComAdmin(); const res = await asTenant(tenant.token) .post('/tenant/alertas/templates') .send({ tipo: 'cobranca_cliente', canal: 'whatsapp', corpo: 'Olá {{nome}}, parcela {{numero_parcela}} vence em {{vencimento}}.', }) .expect(201); expect(res.body).toMatchObject({ tenant_id: tenant.tenantId, tipo: 'cobranca_cliente' }); }); it('tenant B não enxerga/edita template do tenant A (404, não 403)', async () => { const tenantA = await criarTenantComAdmin(); const tenantB = await criarTenantComAdmin(); const criado = await asTenant(tenantA.token) .post('/tenant/alertas/templates') .send({ tipo: 'cobranca_cliente', canal: 'email', assunto: 'Assunto', corpo: 'Corpo' }) .expect(201); const templateId = (criado.body as { id: string }).id; await asTenant(tenantB.token).get(`/tenant/alertas/templates/${templateId}`).expect(404); await asTenant(tenantB.token) .put(`/tenant/alertas/templates/${templateId}`) .send({ corpo: 'Tentativa de editar' }) .expect(404); await asTenant(tenantB.token).delete(`/tenant/alertas/templates/${templateId}`).expect(404); }); it('DELETE faz soft delete (ativo=false, continua existindo)', async () => { const tenant = await criarTenantComAdmin(); const criado = await asTenant(tenant.token) .post('/tenant/alertas/templates') .send({ tipo: 'lembrete_vencimento', canal: 'whatsapp', corpo: 'Lembrete' }) .expect(201); const id = (criado.body as { id: string }).id; await asTenant(tenant.token).delete(`/tenant/alertas/templates/${id}`).expect(200); const naBase = await prisma.alert_templates.findUnique({ where: { id } }); expect(naBase?.ativo).toBe(false); }); }); describe('job diário — mensalidade_tenant', () => { let tenantSucesso: { tenantId: string; emailContato: string; token: string }; let tenantFalha: { tenantId: string; emailContato: string; token: string }; beforeAll(async () => { await criarMasterTemplate({ tipo: 'mensalidade_tenant', canal: 'whatsapp', corpo: 'Olá {{nome}}, sua mensalidade de {{valor}} (ref. {{referencia}}) vence em {{vencimento}}.', }); await criarMasterTemplate({ tipo: 'mensalidade_tenant', canal: 'email', assunto: 'Mensalidade {{referencia}}', corpo: 'Olá {{nome}}, sua mensalidade de {{valor}} vence em {{vencimento}}.', }); tenantSucesso = await criarTenantComAdmin('11999990000'); await criarInstanciaConectada(tenantSucesso.token); tenantFalha = await criarTenantComAdmin(); // sem instância de WhatsApp conectada mockSendMail.mockImplementation((opts: { to: string }) => opts.to === tenantFalha.emailContato ? Promise.reject(new Error('falha simulada de SMTP')) : Promise.resolve({ messageId: `ok-${opts.to}` }), ); }); it('fatura vencida recebe alerta por whatsapp e email; marca alerta_enviado_em e atrasado', async () => { const fatura = await prisma.tenant_faturas.create({ data: { tenant_id: tenantSucesso.tenantId, referencia: '2026-01', valor: 199.9, vencimento: new Date('2020-01-05'), }, }); await alertScheduler.jobMensalidade(); await aguardarFilasOciosas(); const atualizada = await prisma.tenant_faturas.findUnique({ where: { id: fatura.id } }); expect(atualizada?.status).toBe('atrasado'); expect(atualizada?.alerta_enviado_em).not.toBeNull(); const logs = await prisma.alert_logs.findMany({ where: { tenant_id: tenantSucesso.tenantId, destinatario_id: tenantSucesso.tenantId }, }); expect(logs).toHaveLength(2); expect(logs.every((l) => l.status === 'enviado')).toBe(true); expect(logs.find((l) => l.canal === 'whatsapp')?.conteudo_enviado).toContain('R$ 199,90'); expect(logs.find((l) => l.canal === 'email')?.conteudo_enviado).toContain('05/01/2020'); }); it('rodar o job de novo no mesmo dia não duplica o alerta', async () => { await alertScheduler.jobMensalidade(); await aguardarFilasOciosas(); const logs = await prisma.alert_logs.findMany({ where: { tenant_id: tenantSucesso.tenantId, destinatario_id: tenantSucesso.tenantId }, }); expect(logs).toHaveLength(2); // continua 2, não duplicou }); it('falha em todos os canais é registrada em alert_logs só após esgotar as tentativas', async () => { const fatura = await prisma.tenant_faturas.create({ data: { tenant_id: tenantFalha.tenantId, referencia: '2026-01', valor: 50, vencimento: new Date('2020-01-05'), }, }); await alertScheduler.jobMensalidade(); await aguardarFilasOciosas(); const atualizada = await prisma.tenant_faturas.findUnique({ where: { id: fatura.id } }); expect(atualizada?.alerta_enviado_em).toBeNull(); // nunca "conseguiu" enviar const logs = await prisma.alert_logs.findMany({ where: { tenant_id: tenantFalha.tenantId, destinatario_id: tenantFalha.tenantId }, }); expect(logs).toHaveLength(2); // um por canal, não um por tentativa de retry expect(logs.every((l) => l.status === 'falhou' && !!l.erro)).toBe(true); }); }); describe('job diário — cobranca_cliente', () => { let tenantComTemplateProprio: { tenantId: string; token: string }; let tenantComFallback: { tenantId: string; token: string }; beforeAll(async () => { await criarMasterTemplate({ tipo: 'cobranca_cliente', canal: 'email', assunto: 'Cobrança — parcela {{numero_parcela}}', corpo: 'Olá {{nome}}, a parcela {{numero_parcela}} da venda #{{numero_venda}} no valor de {{valor}} venceu em {{vencimento}}.', }); tenantComTemplateProprio = await criarTenantComAdmin('11988880000'); await criarInstanciaConectada(tenantComTemplateProprio.token); await asTenant(tenantComTemplateProprio.token) .post('/tenant/alertas/templates') .send({ tipo: 'cobranca_cliente', canal: 'whatsapp', corpo: 'Template PRÓPRIO do tenant: {{nome}}, parcela {{numero_parcela}} venceu.', }) .expect(201); tenantComFallback = await criarTenantComAdmin(); // sem instância, sem template próprio }); it('usa o template e o canal (whatsapp) PRÓPRIO do tenant quando existe, sobrepondo o padrão do master', async () => { const customer = await criarCustomer(tenantComTemplateProprio.token, { telefone_whatsapp: '11977770000', }); const produto = await criarProduto(tenantComTemplateProprio.token); const venda = await criarVendaComParcelaVencida( tenantComTemplateProprio.token, customer.id, produto.id, ); await alertScheduler.jobCobranca(); await aguardarFilasOciosas(); const parcela = await prisma.sale_payments.findUnique({ where: { id: venda.parcelas[0].id }, include: { sale: true }, }); expect(parcela?.status).toBe('atrasado'); expect(parcela?.alerta_enviado_em).not.toBeNull(); const log = await prisma.alert_logs.findFirst({ where: { tenant_id: tenantComTemplateProprio.tenantId, destinatario_id: customer.id }, include: { template: true }, }); expect(log?.canal).toBe('whatsapp'); expect(log?.conteudo_enviado).toContain('Template PRÓPRIO do tenant'); expect(log?.template?.tenant_id).toBe(tenantComTemplateProprio.tenantId); }); it('usa o template PADRÃO do master (fallback) quando o tenant não tem o próprio, por email', async () => { const customer = await criarCustomer(tenantComFallback.token); const produto = await criarProduto(tenantComFallback.token); const venda = await criarVendaComParcelaVencida( tenantComFallback.token, customer.id, produto.id, ); await alertScheduler.jobCobranca(); await aguardarFilasOciosas(); const parcela = await prisma.sale_payments.findUnique({ where: { id: venda.parcelas[0].id }, }); expect(parcela?.alerta_enviado_em).not.toBeNull(); const log = await prisma.alert_logs.findFirst({ where: { tenant_id: tenantComFallback.tenantId, destinatario_id: customer.id }, include: { template: true }, }); expect(log?.canal).toBe('email'); expect(log?.template?.tenant_id).toBeNull(); // template do master, não do tenant }); }); describe('GET /master/alertas/logs e /tenant/alertas/logs — filtros', () => { it('master lista logs com filtro por tenant_id e canal', async () => { const res = await asMaster().get('/master/alertas/logs?canal=email').expect(200); const body = res.body as { data: Array<{ canal: string }> }; expect(body.data.every((l) => l.canal === 'email')).toBe(true); }); it('tenant só enxerga os próprios logs', async () => { const outroTenant = await criarTenantComAdmin(); const res = await asTenant(outroTenant.token).get('/tenant/alertas/logs').expect(200); const body = res.body as { data: unknown[] }; expect(body.data).toHaveLength(0); }); }); describe('acesso master sem contexto de tenant', () => { it('master "puro" recebe 403 nos endpoints de tenant desta etapa', async () => { await asMaster().get('/tenant/alertas/templates').expect(403); await asMaster().get('/tenant/alertas/logs').expect(403); }); }); });