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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user