Etapa 7 — Motor de alertas (BullMQ)

Adiciona um processo worker separado do processo web (src/worker.ts,
WorkerModule) com dois crons diários e processors do BullMQ para alertas
de mensalidade (master→tenant) e cobrança (tenant→customer): marca
faturas/parcelas atrasadas, resolve template com fallback tenant→master
por canal, renderiza placeholders, dispara WhatsApp/e-mail com retry e
backoff exponencial, e grava alert_logs com idempotência por dia.

Módulo alerts (web) traz o CRUD de alert_templates (master e tenant) e a
consulta de alert_logs com filtros. alert_templates/alert_logs não têm
RLS no schema, então o isolamento entre tenants é feito manualmente
(PrismaMasterService + filtro por tenant_id), coberto por teste e2e
dedicado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:50:59 -03:00
co-authored by Claude Sonnet 5
parent 7daaa16935
commit d6d7d5fa61
37 changed files with 2661 additions and 19 deletions
+601
View File
@@ -0,0 +1,601 @@
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<void> {
this.server = createServer((req, res) => this.handle(req, res));
await new Promise<void>((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<void> {
await new Promise<void>((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<typeof request>[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<void> {
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<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}`),
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<Record<string, unknown>> = {}) {
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<Record<string, unknown>>) {
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<typeof request>[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<Queue>(getQueueToken(QUEUE_ALERTAS_MENSALIDADE));
filaCobranca = workerApp.get<Queue>(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);
});
});
});