@ecdt/server-common 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -196,6 +196,51 @@ consiga dar `await` e acoplar a latência do request ao motor.
196
196
 
197
197
  ---
198
198
 
199
+ ### `publicarEventoDaRequisicao(req, options)`
200
+
201
+ O mesmo publish, montado a partir da requisição que gerou o evento. Resolve o token pelo header
202
+ `Authorization` e o identificador anônimo pelo corpo (`id_visitante`) ou pelo cookie, nessa ordem.
203
+
204
+ ```js
205
+ const { publicarEventoDaRequisicao } = require('@ecdt/server-common');
206
+
207
+ publicarEventoDaRequisicao(req, {
208
+ tipo: 'meu_evento',
209
+ payload: { campo: 'valor' },
210
+ });
211
+ ```
212
+
213
+ | Opção | Default | Descrição |
214
+ |---|---|---|
215
+ | `tipo`, `payload`, `url`, `timeoutMs`, `aoFalhar` | — | Idênticos ao `publicarEvento` |
216
+ | `cookieVisitante` | `process.env.MOTOR_EVENTOS_COOKIE_VISITANTE` | Nome do cookie que guarda o identificador anônimo |
217
+
218
+ Sem `cookieVisitante` configurado, só o corpo alimenta o `id_visitante`. Também não lança em
219
+ nenhuma hipótese, inclusive com `req` sem `headers` nem `body`.
220
+
221
+ ---
222
+
223
+ ### `lerCookie`, `idDoCookie` e `sanitizarId`
224
+
225
+ Leitura do header `Cookie` no servidor, sem dependência de framework.
226
+
227
+ ```js
228
+ const { idDoCookie } = require('@ecdt/server-common');
229
+
230
+ const idVisitante = idDoCookie(req.headers.cookie, 'nome_do_cookie');
231
+ ```
232
+
233
+ | Função | Devolve |
234
+ |---|---|
235
+ | `lerCookie(cabecalho, nome)` | Valor cru do cookie, ou `null` |
236
+ | `idDoCookie(cabecalho, nome, { maxLen })` | O identificador: aceita o cookie como string simples ou como JSON com campo `id`, decodifica o valor e corta em `maxLen` (100 por padrão) |
237
+ | `sanitizarId(valor, maxLen)` | O mesmo corte e validação, para identificador que veio de outro lugar |
238
+
239
+ O match do nome é exato, então `nome_antigo` não casa com `nome`. Nenhuma das três lança: entrada
240
+ que não é string vira `null`.
241
+
242
+ ---
243
+
199
244
  ## Instalação
200
245
 
201
246
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecdt/server-common",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Conjunto de ferramentas e configurações comuns nos servidores da Econodata",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/index.d.ts CHANGED
@@ -147,3 +147,120 @@ export interface PublicarEventoOptions {
147
147
  }
148
148
 
149
149
  export declare function publicarEvento(options?: PublicarEventoOptions): void;
150
+
151
+ export interface PublicarEventoDaRequisicaoOptions
152
+ extends Omit<PublicarEventoOptions, 'autorizacao' | 'idVisitante'> {
153
+ cookieVisitante?: string;
154
+ }
155
+
156
+ export declare function publicarEventoDaRequisicao(
157
+ req: unknown,
158
+ options?: PublicarEventoDaRequisicaoOptions
159
+ ): void;
160
+
161
+ /**
162
+ * Avaliação de gatilhos do motor — pura. As cinco leituras chegam em `fonte`, então a mesma função
163
+ * serve a Function (consultas reais) e o simulador do backoffice (eventos de mentira).
164
+ */
165
+ export interface FonteDeLeitura {
166
+ levantouMaoRecentemente(email: string, desde: string): Promise<unknown>;
167
+ contagemAgrupada(
168
+ idUsuario: number | null,
169
+ tipos: string[],
170
+ campo: string,
171
+ valor: string,
172
+ desde: string,
173
+ ate: string
174
+ ): Promise<{ total: string | number } | undefined>;
175
+ contagemDeTipos(
176
+ idUsuario: number | null,
177
+ tipos: string[],
178
+ desde: string,
179
+ ate: string
180
+ ): Promise<{ total: string | number } | undefined>;
181
+ ultimoDisparo(
182
+ idUsuario: number | null,
183
+ gatilho: string
184
+ ): Promise<{ dt_gatilho: Date | string } | undefined>;
185
+ contagensNaJanela(
186
+ idUsuario: number | null,
187
+ desde: string,
188
+ ate: string
189
+ ): Promise<{ tipo: string; total: string | number }[]>;
190
+ }
191
+
192
+ export interface EventoAvaliado {
193
+ eventoId?: string;
194
+ tipo: string;
195
+ idUsuario: number | null;
196
+ dtEvento: Date;
197
+ payload: Record<string, unknown>;
198
+ }
199
+
200
+ export interface ParamsDoTipo {
201
+ peso?: number;
202
+ bucket?: number;
203
+ }
204
+
205
+ export interface GatilhoNoCatalogo {
206
+ nome: string;
207
+ disparo: 'imediato' | 'contagem' | 'pontuacao';
208
+ condicao: Record<string, unknown>;
209
+ acoes: { acao: string; ref?: string; usa?: Record<string, string>; params?: unknown }[];
210
+ cooldownH?: number | null;
211
+ }
212
+
213
+ export interface CatalogoDoMotor<G extends GatilhoNoCatalogo = GatilhoNoCatalogo> {
214
+ tipos: Record<string, ParamsDoTipo>;
215
+ acoes: Record<string, { canal: string; ativo: boolean; params: Record<string, unknown> }>;
216
+ gatilhos: G[];
217
+ janelaEmDias: number;
218
+ }
219
+
220
+ export interface PerfilDaConta {
221
+ temConta: boolean;
222
+ cargo: string | null;
223
+ porte: string | null;
224
+ }
225
+
226
+ export interface ResultadoDaAvaliacao<G extends GatilhoNoCatalogo = GatilhoNoCatalogo> {
227
+ /** Sem `gatilhoId`: a identidade determinística é do chamador, não da decisão. */
228
+ disparos: { gatilho: G; contexto: Record<string, unknown> }[];
229
+ recusas: { gatilho: string; motivo: string }[];
230
+ }
231
+
232
+ /**
233
+ * Genérica no tipo do gatilho para devolver ao chamador o MESMO tipo que ele passou. O catálogo de
234
+ * cada consumidor é mais rico que o mínimo exigido aqui, e um retorno fixo no tipo mínimo obrigaria
235
+ * a recontruir ou a fazer cast do que já se sabia.
236
+ */
237
+ export declare function avaliarGatilhos<G extends GatilhoNoCatalogo>(
238
+ evento: EventoAvaliado,
239
+ catalogo: CatalogoDoMotor<G>,
240
+ perfil: PerfilDaConta,
241
+ emailDaConta: string | null,
242
+ fonte: FonteDeLeitura
243
+ ): Promise<ResultadoDaAvaliacao<G>>;
244
+
245
+ /** Normaliza o porte às faixas canônicas do cadastro; valor irreconhecível volta vazio. */
246
+ export declare function normalizarPorte(bruto: unknown): string;
247
+
248
+ export declare function pontuar(
249
+ contagens: Record<string, number>,
250
+ tipos: Record<string, ParamsDoTipo>,
251
+ pesosDoGatilho?: Record<string, number>
252
+ ): { total: number; porTipo: Record<string, number> };
253
+
254
+ export interface IdDoCookieOptions {
255
+ maxLen?: number;
256
+ }
257
+
258
+ export declare function lerCookie(cabecalho: unknown, nome: string): string | null;
259
+
260
+ export declare function idDoCookie(
261
+ cabecalho: unknown,
262
+ nome: string,
263
+ options?: IdDoCookieOptions
264
+ ): string | null;
265
+
266
+ export declare function sanitizarId(valor: unknown, maxLen?: number): string | null;
package/src/index.js CHANGED
@@ -3,7 +3,9 @@ const { devMktTokenSanitaze } = require("./services/tokenService.js");
3
3
  const { expressCors } = require("./services/corsService.js");
4
4
  const { setupGracefulShutdown } = require("./services/shutdownService.js");
5
5
  const { setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics } = require("./services/redMetricsService.js");
6
- const { publicarEvento } = require("./services/motorEventosService.js");
6
+ const { publicarEvento, publicarEventoDaRequisicao } = require("./services/motorEventosService.js");
7
+ const { avaliarGatilhos, pontuar, normalizarPorte } = require("./services/motorAvaliadorService.js");
8
+ const { lerCookie, idDoCookie, sanitizarId } = require("./services/cookieService.js");
7
9
 
8
10
  function expressCommonMiddlewares({ cookieName } = {}){
9
11
  return [bodyParser.text(), bodyParser.json(), bodyParser.urlencoded({extended: false}), devMktTokenSanitaze({ cookieName })];
@@ -12,4 +14,4 @@ function expressCommonMiddlewares({ cookieName } = {}){
12
14
 
13
15
 
14
16
 
15
- module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics, publicarEvento }
17
+ module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics, publicarEvento, publicarEventoDaRequisicao, avaliarGatilhos, pontuar, normalizarPorte, lerCookie, idDoCookie, sanitizarId }
@@ -0,0 +1,43 @@
1
+ const TAMANHO_MAXIMO_PADRAO = 100;
2
+
3
+ function sanitizarId(valor, maxLen = TAMANHO_MAXIMO_PADRAO) {
4
+ if (typeof valor !== "string") return null;
5
+
6
+ const limpo = valor.trim();
7
+ return limpo ? limpo.slice(0, maxLen) : null;
8
+ }
9
+
10
+ function lerCookie(cabecalho, nome) {
11
+ if (typeof cabecalho !== "string" || typeof nome !== "string") return null;
12
+
13
+ for (const parte of cabecalho.split(";")) {
14
+ const separador = parte.indexOf("=");
15
+ if (separador === -1) continue;
16
+ if (parte.slice(0, separador).trim() !== nome) continue;
17
+ return parte.slice(separador + 1).trim();
18
+ }
19
+
20
+ return null;
21
+ }
22
+
23
+ function idDoCookie(cabecalho, nome, { maxLen = TAMANHO_MAXIMO_PADRAO } = {}) {
24
+ const bruto = lerCookie(cabecalho, nome);
25
+ if (!bruto) return null;
26
+
27
+ let valor = bruto;
28
+ try {
29
+ valor = decodeURIComponent(bruto);
30
+ } catch (_erroDeEncoding) {
31
+ valor = bruto;
32
+ }
33
+
34
+ try {
35
+ const conteudo = JSON.parse(valor);
36
+ const id = conteudo && typeof conteudo === "object" ? conteudo.id : conteudo;
37
+ return sanitizarId(typeof id === "string" ? id : String(id ?? ""), maxLen);
38
+ } catch (_naoEhJson) {
39
+ return sanitizarId(valor, maxLen);
40
+ }
41
+ }
42
+
43
+ module.exports = { lerCookie, idDoCookie, sanitizarId };
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Avaliação de gatilhos do motor de eventos — PURA.
3
+ *
4
+ * Nada aqui toca banco, fila ou variável de ambiente: as cinco leituras que a avaliação precisa
5
+ * chegam no parâmetro `fonte`. É isso que permite a mesma função decidir em dois lugares — a
6
+ * `events-engine`, que passa as consultas reais, e o simulador do backoffice, que passa uma
7
+ * implementação em memória sobre eventos de mentira.
8
+ *
9
+ * Mora aqui, e não num pacote novo, porque o lado PRODUTOR do mesmo contrato já mora: o
10
+ * `motorEventosService.js`, ao lado, é quem publica o evento. Produtor e avaliador do mesmo motor
11
+ * no mesmo pacote é coerência; um repositório novo para duas dezenas de funções puras, não.
12
+ */
13
+
14
+ // ── porte ─────────────────────────────────────────────────────────────────────────────────────
15
+
16
+ // Faixas canônicas dos formulários de cadastro do site (site-vue3/utils/normalizeQtdFuncionarios.ts).
17
+ const FAIXAS = ["1-4", "5-10", "11-20", "21-50", "51-200", "201-500", "Mais que 500"];
18
+
19
+ /**
20
+ * O porte chega do banco em mais de uma grafia: `1-4` convive com o legado `01-04`, e são 60.861
21
+ * usuários na segunda forma. Comparar a string crua faz o gate de perfil deixar passar exatamente a
22
+ * coorte que ele existe para barrar.
23
+ *
24
+ * Valor irreconhecível volta vazio, e não a primeira faixa: público é filtro, e chutar a faixa
25
+ * incluiria quem não deveria entrar.
26
+ */
27
+ const normalizarPorte = (bruto) => {
28
+ if (!bruto) return "";
29
+ const texto = String(bruto).trim();
30
+ if (FAIXAS.includes(texto)) return texto;
31
+
32
+ const faixa = /^0*(\d+)\s*-\s*0*(\d+)$/.exec(texto);
33
+ if (faixa) {
34
+ const candidato = `${Number(faixa[1])}-${Number(faixa[2])}`;
35
+ if (FAIXAS.includes(candidato)) return candidato;
36
+ }
37
+
38
+ if (/(mais|acima).*500|500\s*\+|>\s*500/i.test(texto)) return "Mais que 500";
39
+ return "";
40
+ };
41
+
42
+ // ── público ───────────────────────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Casa o público do gatilho com o perfil da conta e devolve a ESPECIFICIDADE — quantos critérios o
46
+ * público declara. É ela que decide o A24: quem casa dois gatilhos por pontuação dispara só o mais
47
+ * específico, e o sem critério é fallback.
48
+ *
49
+ * `plano` na v1: todo usuário do site é gratuito (A28/A39). Um público só de `premium` não casa
50
+ * ninguém, e dizer isso no motivo evita que alguém procure o defeito na pontuação.
51
+ */
52
+ const casaPublico = (publico, perfil) => {
53
+ if (!publico || Object.keys(publico).length === 0) {
54
+ return { casa: true, especificidade: 0, motivo: "sem critério (fallback)" };
55
+ }
56
+
57
+ let especificidade = 0;
58
+
59
+ if (publico.com_conta !== undefined) {
60
+ if (publico.com_conta !== perfil.temConta) {
61
+ return { casa: false, motivo: `com_conta=${publico.com_conta} não casa` };
62
+ }
63
+ especificidade += 1;
64
+ }
65
+
66
+ if (publico.cargo) {
67
+ if (!perfil.cargo || !publico.cargo.includes(perfil.cargo)) {
68
+ return { casa: false, motivo: `cargo "${perfil.cargo ?? "vazio"}" fora da lista` };
69
+ }
70
+ especificidade += 1;
71
+ }
72
+
73
+ if (publico.porte) {
74
+ const porte = normalizarPorte(perfil.porte);
75
+ if (!porte || !publico.porte.map(normalizarPorte).includes(porte)) {
76
+ return { casa: false, motivo: `porte "${perfil.porte ?? "vazio"}" fora da lista` };
77
+ }
78
+ especificidade += 1;
79
+ }
80
+
81
+ if (publico.plano) {
82
+ if (!publico.plano.includes("gratuito")) {
83
+ return {
84
+ casa: false,
85
+ motivo: "plano premium não casa ninguém na v1: o usuário do site é gratuito",
86
+ };
87
+ }
88
+ especificidade += 1;
89
+ }
90
+
91
+ if (publico.excluir) {
92
+ if (perfil.cargo && publico.excluir.cargo?.includes(perfil.cargo)) {
93
+ return { casa: false, motivo: `excluído pelo cargo "${perfil.cargo}"` };
94
+ }
95
+ const porte = normalizarPorte(perfil.porte);
96
+ if (porte && publico.excluir.porte?.map(normalizarPorte).includes(porte)) {
97
+ return { casa: false, motivo: `excluído pelo porte "${perfil.porte}"` };
98
+ }
99
+ especificidade += 1;
100
+ }
101
+
102
+ return { casa: true, especificidade, motivo: `casa ${especificidade} critério(s)` };
103
+ };
104
+
105
+ // ── horário comercial ─────────────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * 9h–17h, segunda a sexta, sem feriado nacional, em `America/Sao_Paulo`.
109
+ *
110
+ * É a QUARTA cópia da regra no ecossistema (`site-vue3`, `lps-site` e `ecdt-lead-segmentation`) —
111
+ * veio do `dev-mkt-functions`, não é uma quinta. Mudar a janela exige mudar as quatro, e não há gate
112
+ * que cruze os repositórios.
113
+ *
114
+ * A janela é a do widget (fim exclusivo às 17h), não a do outbound de WhatsApp do
115
+ * `ecdt-lead-segmentation`, que é inclusiva até 17h00 de propósito.
116
+ *
117
+ * O dia e a hora saem do MESMO `Intl`: ler o dia do relógio do processo e a hora do fuso faz o
118
+ * feriado cair no dia errado num contêiner em UTC depois das 21h BRT.
119
+ */
120
+ const FUSO = "America/Sao_Paulo";
121
+ const HORA_INICIO = 9;
122
+ const HORA_FIM = 17;
123
+ const FERIADOS_FIXOS = ["1-1", "4-21", "5-1", "9-7", "10-12", "11-2", "11-15", "11-20", "12-25"];
124
+
125
+ const partesEmSaoPaulo = (quando) => {
126
+ const formatador = new Intl.DateTimeFormat("en-US", {
127
+ timeZone: FUSO,
128
+ year: "numeric",
129
+ month: "numeric",
130
+ day: "numeric",
131
+ hour: "numeric",
132
+ weekday: "short",
133
+ hour12: false,
134
+ });
135
+ const partes = {};
136
+ for (const parte of formatador.formatToParts(quando)) partes[parte.type] = parte.value;
137
+
138
+ return {
139
+ ano: Number(partes.year),
140
+ mes: Number(partes.month),
141
+ dia: Number(partes.day),
142
+ // `hour12: false` devolve 24 para a meia-noite em algumas versões do ICU.
143
+ hora: Number(partes.hour) % 24,
144
+ semana: String(partes.weekday),
145
+ };
146
+ };
147
+
148
+ // Meeus/Jones/Butcher.
149
+ const pascoa = (ano) => {
150
+ const a = ano % 19;
151
+ const b = Math.floor(ano / 100);
152
+ const c = ano % 100;
153
+ const d = Math.floor(b / 4);
154
+ const e = b % 4;
155
+ const f = Math.floor((b + 8) / 25);
156
+ const g = Math.floor((b - f + 1) / 3);
157
+ const h = (19 * a + b - d - g + 15) % 30;
158
+ const i = Math.floor(c / 4);
159
+ const k = c % 4;
160
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
161
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
162
+ const mes = Math.floor((h + l - 7 * m + 114) / 31);
163
+ const dia = ((h + l - 7 * m + 114) % 31) + 1;
164
+ return new Date(Date.UTC(ano, mes - 1, dia));
165
+ };
166
+
167
+ const ehFeriadoNacional = (ano, mes, dia) => {
168
+ if (FERIADOS_FIXOS.includes(`${mes}-${dia}`)) return true;
169
+
170
+ const base = pascoa(ano);
171
+ const deslocado = (dias) => {
172
+ const data = new Date(base);
173
+ data.setUTCDate(data.getUTCDate() + dias);
174
+ return `${data.getUTCMonth() + 1}-${data.getUTCDate()}`;
175
+ };
176
+ // Terça de Carnaval, Sexta-feira Santa, Corpus Christi.
177
+ return [deslocado(-47), deslocado(-2), deslocado(60)].includes(`${mes}-${dia}`);
178
+ };
179
+
180
+ const horarioComercial = (quando) => {
181
+ const { ano, mes, dia, hora, semana } = partesEmSaoPaulo(quando);
182
+ if (semana === "Sat" || semana === "Sun") return { dentro: false, motivo: "fim de semana" };
183
+ if (ehFeriadoNacional(ano, mes, dia)) return { dentro: false, motivo: "feriado nacional" };
184
+ if (hora < HORA_INICIO || hora >= HORA_FIM)
185
+ return { dentro: false, motivo: `${hora}h BRT, fora de 9h-17h` };
186
+ return { dentro: true };
187
+ };
188
+
189
+ // ── pontuação ─────────────────────────────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Pontuação da janela: para cada tipo, `floor(ocorrências / bucket) × peso`.
193
+ *
194
+ * O bucket é o que reproduz o widget — 25 pontos a cada 3 listas, não 25 por lista. Peso vem do
195
+ * catálogo e pode ser sobrescrito pelo gatilho (A25); tipo sem peso conta zero, e é assim que o
196
+ * `fishing_score` fica gravado sem mexer em pontuação nenhuma.
197
+ */
198
+ const pontuar = (contagens, tipos, pesosDoGatilho = {}) => {
199
+ const porTipo = {};
200
+ let total = 0;
201
+
202
+ for (const [tipo, ocorrencias] of Object.entries(contagens)) {
203
+ const params = tipos[tipo];
204
+ if (!params) continue;
205
+
206
+ const peso = pesosDoGatilho[tipo] ?? params.peso ?? 0;
207
+ const bucket = params.bucket && params.bucket >= 1 ? params.bucket : 1;
208
+ const soma = Math.floor(ocorrencias / bucket) * peso;
209
+ if (soma > 0) {
210
+ porTipo[tipo] = soma;
211
+ total += soma;
212
+ }
213
+ }
214
+
215
+ return { total, porTipo };
216
+ };
217
+
218
+ /**
219
+ * Começo da janela de pontuação: a global de N dias, ou o último disparo por pontuação deste gatilho
220
+ * para esta conta, o que for mais recente. É o que substitui o "score zera ao exibir" do widget por um
221
+ * marco derivado — sem coluna de estado, e correto mesmo se o evento for reprocessado (P9).
222
+ */
223
+ const inicioDaJanela = (agora, dias, ultimoDisparo) => {
224
+ const janela = new Date(agora.getTime() - dias * 86400000);
225
+ if (ultimoDisparo && ultimoDisparo > janela) return ultimoDisparo;
226
+ return janela;
227
+ };
228
+
229
+ // ── avaliador ─────────────────────────────────────────────────────────────────────────────────
230
+
231
+ const DIA_EM_MS = 86400000;
232
+
233
+ const duracaoEmMs = (texto) => {
234
+ const casado = /^([1-9][0-9]*)(h|d)$/.exec(texto ?? "");
235
+ if (!casado) return null;
236
+ return Number(casado[1]) * (casado[2] === "h" ? 3600000 : DIA_EM_MS);
237
+ };
238
+
239
+ const temAcaoInPage = (gatilho, catalogo) =>
240
+ gatilho.acoes.some((item) => catalogo.acoes[item.acao]?.canal === "in_page");
241
+
242
+ const tipoConta = (tipo, catalogo) => catalogo.tipos[tipo] !== undefined;
243
+
244
+ const METODOS_DA_FONTE = [
245
+ "levantouMaoRecentemente",
246
+ "contagemAgrupada",
247
+ "contagemDeTipos",
248
+ "ultimoDisparo",
249
+ "contagensNaJanela",
250
+ ];
251
+
252
+ /**
253
+ * Avalia os gatilhos publicados para a conta do evento.
254
+ *
255
+ * A ordem das verificações é por custo: público e horário são memória, e só quem passa por eles chega
256
+ * a consultar a janela. O gate pós-levantada vem antes da contagem pelo mesmo motivo.
257
+ *
258
+ * `fonte` traz as cinco leituras. Num simulador elas respondem sobre eventos de mentira; note que
259
+ * `ultimoDisparo` e `levantouMaoRecentemente` não têm equivalente numa simulação sem passado, e
260
+ * devolver vazio ali faz o intervalo entre disparos e o gate pós-levantada NUNCA barrarem — quem
261
+ * apresenta o resultado precisa dizer isso, senão "não barrou" é lido como "o gate está quebrado".
262
+ */
263
+ const avaliarGatilhos = async (evento, catalogo, perfil, emailDaConta, fonte) => {
264
+ const faltando = METODOS_DA_FONTE.filter((m) => typeof fonte?.[m] !== "function");
265
+ if (faltando.length) {
266
+ throw new TypeError(`avaliarGatilhos: fonte sem os métodos ${faltando.join(", ")}`);
267
+ }
268
+
269
+ const disparos = [];
270
+ const recusas = [];
271
+ const janela = horarioComercial(evento.dtEvento);
272
+ let levantouMao = null;
273
+
274
+ // Candidato: para `imediato` e `contagem`, o tipo do evento tem de estar na condição. Para
275
+ // `pontuacao`, qualquer evento que pontue pode fechar o limiar.
276
+ const candidatos = catalogo.gatilhos.filter((g) =>
277
+ g.disparo === "pontuacao" ? true : (g.condicao.tipos ?? []).includes(evento.tipo),
278
+ );
279
+
280
+ const porPontuacao = [];
281
+
282
+ for (const gatilho of candidatos) {
283
+ const publico = casaPublico(gatilho.condicao.publico, perfil);
284
+ if (!publico.casa) {
285
+ recusas.push({ gatilho: gatilho.nome, motivo: publico.motivo });
286
+ continue;
287
+ }
288
+
289
+ // Gate pós-levantada (D26): quem levantou a mão nos últimos 30 dias não recebe convite de página.
290
+ if (temAcaoInPage(gatilho, catalogo) && emailDaConta) {
291
+ if (levantouMao === null) {
292
+ const desde = new Date(evento.dtEvento.getTime() - 30 * DIA_EM_MS).toISOString();
293
+ levantouMao = Boolean(await fonte.levantouMaoRecentemente(emailDaConta, desde));
294
+ }
295
+ if (levantouMao) {
296
+ recusas.push({ gatilho: gatilho.nome, motivo: "levantou a mão nos últimos 30 dias" });
297
+ continue;
298
+ }
299
+ }
300
+
301
+ // Os três ramos abaixo sempre atribuem os dois; inicializar aqui seria valor morto.
302
+ let fecha;
303
+ let detalhe;
304
+
305
+ if (gatilho.disparo === "imediato") {
306
+ fecha = tipoConta(evento.tipo, catalogo);
307
+ detalhe = { evento: evento.tipo };
308
+ } else if (gatilho.disparo === "contagem") {
309
+ const duracao = duracaoEmMs(gatilho.condicao.janela);
310
+ const quantidade = gatilho.condicao.quantidade ?? Number.POSITIVE_INFINITY;
311
+ if (duracao === null) {
312
+ recusas.push({ gatilho: gatilho.nome, motivo: "janela inválida no cadastro" });
313
+ continue;
314
+ }
315
+ const desde = new Date(evento.dtEvento.getTime() - duracao).toISOString();
316
+ const ate = evento.dtEvento.toISOString();
317
+ const tipos = gatilho.condicao.tipos ?? [];
318
+ const campo = gatilho.condicao.agrupar_por?.replace("payload.", "");
319
+ const valor = campo ? evento.payload[campo] : undefined;
320
+
321
+ if (campo && (valor === undefined || valor === null)) {
322
+ recusas.push({
323
+ gatilho: gatilho.nome,
324
+ motivo: `evento sem o campo ${campo} do agrupamento`,
325
+ });
326
+ continue;
327
+ }
328
+
329
+ const linha = campo
330
+ ? await fonte.contagemAgrupada(evento.idUsuario, tipos, campo, String(valor), desde, ate)
331
+ : await fonte.contagemDeTipos(evento.idUsuario, tipos, desde, ate);
332
+ const total = Number(linha?.total ?? 0);
333
+ fecha = total >= quantidade;
334
+ detalhe = { contagem: total, quantidade, janela: gatilho.condicao.janela };
335
+ if (campo) detalhe.agrupamento = { [campo]: valor };
336
+ } else {
337
+ const ultimo = await fonte.ultimoDisparo(evento.idUsuario, gatilho.nome);
338
+ const desde = inicioDaJanela(
339
+ evento.dtEvento,
340
+ catalogo.janelaEmDias,
341
+ ultimo?.dt_gatilho ? new Date(ultimo.dt_gatilho) : null,
342
+ );
343
+ const contagens = await fonte.contagensNaJanela(
344
+ evento.idUsuario,
345
+ desde.toISOString(),
346
+ evento.dtEvento.toISOString(),
347
+ );
348
+ const porTipo = {};
349
+ for (const linha of contagens) porTipo[linha.tipo] = Number(linha.total);
350
+
351
+ const { total, porTipo: decomposicao } = pontuar(
352
+ porTipo,
353
+ catalogo.tipos,
354
+ gatilho.condicao.pesos ?? {},
355
+ );
356
+ const limiar = gatilho.condicao.limiar ?? Number.POSITIVE_INFINITY;
357
+ fecha = total >= limiar;
358
+ detalhe = {
359
+ pontuacao: total,
360
+ limiar,
361
+ pesos_aplicados: decomposicao,
362
+ janela_desde: desde.toISOString(),
363
+ };
364
+ }
365
+
366
+ if (!fecha) {
367
+ recusas.push({
368
+ gatilho: gatilho.nome,
369
+ motivo: `condição não fechou: ${JSON.stringify(detalhe)}`,
370
+ });
371
+ continue;
372
+ }
373
+
374
+ // A32: fora do horário o gatilho não dispara, e a pontuação continua contando — o próximo evento
375
+ // dentro da janela dispara com o acumulado.
376
+ if (gatilho.condicao.horario_comercial && !janela.dentro) {
377
+ recusas.push({
378
+ gatilho: gatilho.nome,
379
+ motivo: `barrado pelo horário comercial: ${janela.motivo}`,
380
+ });
381
+ continue;
382
+ }
383
+
384
+ if (gatilho.cooldownH) {
385
+ const ultimo = await fonte.ultimoDisparo(evento.idUsuario, gatilho.nome);
386
+ if (ultimo?.dt_gatilho) {
387
+ const desdeOUltimo = evento.dtEvento.getTime() - new Date(ultimo.dt_gatilho).getTime();
388
+ if (desdeOUltimo < gatilho.cooldownH * 3600000) {
389
+ recusas.push({
390
+ gatilho: gatilho.nome,
391
+ motivo: `em intervalo de ${gatilho.cooldownH}h desde o último disparo`,
392
+ });
393
+ continue;
394
+ }
395
+ }
396
+ }
397
+
398
+ const contexto = {
399
+ ...detalhe,
400
+ publico_casado: publico.motivo,
401
+ especificidade: publico.especificidade,
402
+ };
403
+
404
+ if (gatilho.disparo === "pontuacao") {
405
+ porPontuacao.push({ gatilho, especificidade: publico.especificidade, contexto });
406
+ } else {
407
+ disparos.push({ gatilho, contexto });
408
+ }
409
+ }
410
+
411
+ // A24: entre os gatilhos por pontuação que fecharam, vence o de público mais específico; empate
412
+ // resolve por prioridade e, por fim, pelo nome, para ser determinístico.
413
+ if (porPontuacao.length > 0) {
414
+ const ordenados = [...porPontuacao].sort(
415
+ (a, b) =>
416
+ b.especificidade - a.especificidade ||
417
+ (b.gatilho.condicao.prioridade ?? 0) - (a.gatilho.condicao.prioridade ?? 0) ||
418
+ a.gatilho.nome.localeCompare(b.gatilho.nome),
419
+ );
420
+ const vencedor = ordenados[0];
421
+ const preteridos = ordenados.slice(1).map((p) => p.gatilho.nome);
422
+ for (const p of preteridos) {
423
+ recusas.push({
424
+ gatilho: p,
425
+ motivo: `preterido por ${vencedor.gatilho.nome}, de público mais específico`,
426
+ });
427
+ }
428
+ disparos.push({
429
+ gatilho: vencedor.gatilho,
430
+ contexto: {
431
+ ...vencedor.contexto,
432
+ ...(preteridos.length ? { gatilhos_preteridos: preteridos } : {}),
433
+ },
434
+ });
435
+ }
436
+
437
+ return { disparos, recusas };
438
+ };
439
+
440
+ module.exports = {
441
+ avaliarGatilhos,
442
+ pontuar,
443
+ inicioDaJanela,
444
+ casaPublico,
445
+ normalizarPorte,
446
+ horarioComercial,
447
+ };
@@ -1,3 +1,5 @@
1
+ const { idDoCookie, sanitizarId } = require("./cookieService.js");
2
+
1
3
  const TIMEOUT_PADRAO_MS = 2000;
2
4
 
3
5
  function urlConfigurada(url) {
@@ -69,4 +71,33 @@ function publicarEvento({
69
71
  }
70
72
  }
71
73
 
72
- module.exports = { publicarEvento };
74
+ /**
75
+ * Publica o evento a partir da requisicao que o gerou: tira o token do header
76
+ * Authorization e o identificador anonimo do corpo ou do cookie, nessa ordem.
77
+ * Nunca lanca, nem quando `req` nao tem headers nem corpo — o evento e
78
+ * secundario ao fluxo, e boa parte das chamadas acontece depois de uma escrita
79
+ * ja efetivada.
80
+ */
81
+ function publicarEventoDaRequisicao(req, {
82
+ tipo,
83
+ payload,
84
+ cookieVisitante = process.env.MOTOR_EVENTOS_COOKIE_VISITANTE,
85
+ ...resto
86
+ } = {}) {
87
+ try {
88
+ const doCorpo = sanitizarId(req?.body?.id_visitante);
89
+ const doCookie = cookieVisitante ? idDoCookie(req?.headers?.cookie, cookieVisitante) : null;
90
+
91
+ publicarEvento({
92
+ tipo,
93
+ payload,
94
+ autorizacao: req?.headers?.authorization,
95
+ idVisitante: doCorpo ?? doCookie,
96
+ ...resto,
97
+ });
98
+ } catch (erro) {
99
+ console.error(`motorEventos: ${tipo} nao publicado (${erro?.message})`);
100
+ }
101
+ }
102
+
103
+ module.exports = { publicarEvento, publicarEventoDaRequisicao };
@@ -0,0 +1,59 @@
1
+ const { test, describe } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { lerCookie, idDoCookie, sanitizarId } = require('../src/services/cookieService.js');
5
+
6
+ const comCookie = (nome, valor) => `_ga=GA1.1.9; ${nome}=${encodeURIComponent(valor)}; outro=x`;
7
+
8
+ describe('sanitizarId', () => {
9
+ test('devolve null para vazio, espaco em branco ou valor que nao e string', () => {
10
+ assert.equal(sanitizarId(''), null);
11
+ assert.equal(sanitizarId(' '), null);
12
+ assert.equal(sanitizarId(undefined), null);
13
+ assert.equal(sanitizarId(42), null);
14
+ });
15
+
16
+ test('corta no tamanho maximo', () => {
17
+ assert.equal(sanitizarId('a'.repeat(300)).length, 100);
18
+ assert.equal(sanitizarId('a'.repeat(300), 10).length, 10);
19
+ });
20
+ });
21
+
22
+ describe('lerCookie', () => {
23
+ test('acha o cookie no meio do header', () => {
24
+ assert.equal(lerCookie('a=1; alvo=valor; b=2', 'alvo'), 'valor');
25
+ });
26
+
27
+ test('nao casa com cookie de nome parecido', () => {
28
+ assert.equal(lerCookie('alvo_antigo=valor', 'alvo'), null);
29
+ });
30
+
31
+ test('devolve null sem header, sem o cookie ou com header que nao e string', () => {
32
+ assert.equal(lerCookie(undefined, 'alvo'), null);
33
+ assert.equal(lerCookie('a=1', 'alvo'), null);
34
+ assert.equal(lerCookie({}, 'alvo'), null);
35
+ assert.equal(lerCookie('a=1', undefined), null);
36
+ });
37
+ });
38
+
39
+ describe('idDoCookie', () => {
40
+ test('extrai o id quando o cookie guarda JSON', () => {
41
+ assert.equal(idDoCookie(comCookie('trk', '{"id":"visitante-1"}'), 'trk'), 'visitante-1');
42
+ });
43
+
44
+ test('aceita o cookie como string simples', () => {
45
+ assert.equal(idDoCookie(comCookie('trk', 'visitante-2'), 'trk'), 'visitante-2');
46
+ });
47
+
48
+ test('devolve null quando o JSON nao tem id', () => {
49
+ assert.equal(idDoCookie(comCookie('trk', '{"outro":"x"}'), 'trk'), null);
50
+ });
51
+
52
+ test('devolve null quando o cookie nao existe', () => {
53
+ assert.equal(idDoCookie('a=1', 'trk'), null);
54
+ });
55
+
56
+ test('respeita o maxLen', () => {
57
+ assert.equal(idDoCookie(comCookie('trk', 'a'.repeat(300)), 'trk', { maxLen: 5 }).length, 5);
58
+ });
59
+ });
@@ -0,0 +1,222 @@
1
+ const { test, describe } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const {
5
+ avaliarGatilhos,
6
+ pontuar,
7
+ casaPublico,
8
+ normalizarPorte,
9
+ horarioComercial,
10
+ } = require('../src/services/motorAvaliadorService.js');
11
+
12
+ // Fonte de leitura dublada. Sem os cinco metodos a avaliacao recusa a chamada, entao o dublê
13
+ // declara todos e o teste sobrescreve o que interessa.
14
+ const fonteDe = (respostas = {}) => ({
15
+ levantouMaoRecentemente: async () => respostas.levantouMao ?? null,
16
+ contagemAgrupada: async () => respostas.contagem ?? { total: 0 },
17
+ contagemDeTipos: async () => respostas.contagem ?? { total: 0 },
18
+ ultimoDisparo: async () => respostas.ultimoDisparo ?? undefined,
19
+ contagensNaJanela: async () => respostas.contagens ?? [],
20
+ });
21
+
22
+ const EVENTO = {
23
+ eventoId: 'e1',
24
+ tipo: 'desbloqueou_empresa',
25
+ idUsuario: 1,
26
+ // Terca-feira, 14h BRT: dentro do horario comercial e fora de feriado.
27
+ dtEvento: new Date('2026-09-22T17:00:00.000Z'),
28
+ payload: { setor: 'farmacias' },
29
+ };
30
+
31
+ const PERFIL = { temConta: true, cargo: null, porte: null };
32
+
33
+ const catalogoCom = (gatilhos, tipos = { desbloqueou_empresa: { peso: 10 } }) => ({
34
+ tipos,
35
+ acoes: { acao_x: { canal: 'n8n', ativo: true, params: {} } },
36
+ gatilhos,
37
+ janelaEmDias: 30,
38
+ });
39
+
40
+ const gatilho = (extra) => ({
41
+ nome: 'g',
42
+ disparo: 'imediato',
43
+ condicao: { tipos: ['desbloqueou_empresa'] },
44
+ acoes: [{ acao: 'acao_x' }],
45
+ cooldownH: null,
46
+ ...extra,
47
+ });
48
+
49
+ describe('a fonte e contrato, nao sugestao', () => {
50
+ test('fonte sem um dos cinco metodos e recusada, dizendo qual falta', async () => {
51
+ const incompleta = { ...fonteDe() };
52
+ delete incompleta.contagensNaJanela;
53
+ await assert.rejects(
54
+ () => avaliarGatilhos(EVENTO, catalogoCom([gatilho()]), PERFIL, null, incompleta),
55
+ /fonte sem os métodos contagensNaJanela/
56
+ );
57
+ });
58
+
59
+ test('fonte ausente tambem e recusada, e nao estoura no meio da avaliacao', async () => {
60
+ await assert.rejects(
61
+ () => avaliarGatilhos(EVENTO, catalogoCom([gatilho()]), PERFIL, null, undefined),
62
+ /fonte sem os métodos/
63
+ );
64
+ });
65
+ });
66
+
67
+ describe('catalogo e casos de borda', () => {
68
+ test('catalogo vazio nao dispara e nao lanca', async () => {
69
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([]), PERFIL, null, fonteDe());
70
+ assert.deepEqual(r, { disparos: [], recusas: [] });
71
+ });
72
+
73
+ test('tipo fora do catalogo nao fecha o imediato', async () => {
74
+ const catalogo = catalogoCom([gatilho()], {});
75
+ const r = await avaliarGatilhos(EVENTO, catalogo, PERFIL, null, fonteDe());
76
+ assert.equal(r.disparos.length, 0);
77
+ assert.match(r.recusas[0].motivo, /condição não fechou/);
78
+ });
79
+
80
+ test('contagem com janela invalida recusa com motivo, sem consultar', async () => {
81
+ const g = gatilho({ disparo: 'contagem', condicao: { tipos: ['desbloqueou_empresa'], quantidade: 2, janela: 'semana' } });
82
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonteDe());
83
+ assert.equal(r.recusas[0].motivo, 'janela inválida no cadastro');
84
+ });
85
+
86
+ test('agrupamento sem o campo no payload recusa dizendo qual campo', async () => {
87
+ const g = gatilho({
88
+ disparo: 'contagem',
89
+ condicao: { tipos: ['desbloqueou_empresa'], quantidade: 2, janela: '7d', agrupar_por: 'payload.regiao' },
90
+ });
91
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonteDe());
92
+ assert.equal(r.recusas[0].motivo, 'evento sem o campo regiao do agrupamento');
93
+ });
94
+
95
+ test('pontuacao sem pesos no gatilho cai no peso do catalogo', async () => {
96
+ const g = gatilho({ disparo: 'pontuacao', condicao: { limiar: 30 } });
97
+ const fonte = fonteDe({ contagens: [{ tipo: 'desbloqueou_empresa', total: '3' }] });
98
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonte);
99
+ assert.equal(r.disparos.length, 1);
100
+ assert.equal(r.disparos[0].contexto.pontuacao, 30);
101
+ });
102
+ });
103
+
104
+ describe('as tres formas de disparo', () => {
105
+ test('imediato fecha com o tipo no catalogo', async () => {
106
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([gatilho()]), PERFIL, null, fonteDe());
107
+ assert.equal(r.disparos.length, 1);
108
+ assert.equal(r.disparos[0].contexto.evento, 'desbloqueou_empresa');
109
+ });
110
+
111
+ test('contagem fecha no limite e nao abaixo', async () => {
112
+ const g = gatilho({ disparo: 'contagem', condicao: { tipos: ['desbloqueou_empresa'], quantidade: 3, janela: '24h' } });
113
+ const abaixo = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonteDe({ contagem: { total: '2' } }));
114
+ assert.equal(abaixo.disparos.length, 0);
115
+ const noLimite = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonteDe({ contagem: { total: '3' } }));
116
+ assert.equal(noLimite.disparos.length, 1);
117
+ assert.equal(noLimite.disparos[0].contexto.contagem, 3);
118
+ });
119
+
120
+ test('o disparo NAO carrega identificador: a identidade e do chamador', async () => {
121
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([gatilho()]), PERFIL, null, fonteDe());
122
+ assert.deepEqual(Object.keys(r.disparos[0]).sort(), ['contexto', 'gatilho']);
123
+ });
124
+ });
125
+
126
+ describe('gates', () => {
127
+ test('horario comercial barra fora da janela e a pontuacao segue contando', async () => {
128
+ const g = gatilho({ condicao: { tipos: ['desbloqueou_empresa'], horario_comercial: true } });
129
+ const domingo = { ...EVENTO, dtEvento: new Date('2026-09-20T17:00:00.000Z') };
130
+ const r = await avaliarGatilhos(domingo, catalogoCom([g]), PERFIL, null, fonteDe());
131
+ assert.match(r.recusas[0].motivo, /horário comercial: fim de semana/);
132
+ });
133
+
134
+ test('intervalo entre disparos barra dentro da janela', async () => {
135
+ const g = gatilho({ cooldownH: 24 });
136
+ const fonte = fonteDe({ ultimoDisparo: { dt_gatilho: new Date('2026-09-22T10:00:00.000Z') } });
137
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonte);
138
+ assert.match(r.recusas[0].motivo, /em intervalo de 24h/);
139
+ });
140
+
141
+ test('sem ultimo disparo, o intervalo nao barra — e o caso da simulacao', async () => {
142
+ const g = gatilho({ cooldownH: 24 });
143
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([g]), PERFIL, null, fonteDe());
144
+ assert.equal(r.disparos.length, 1);
145
+ });
146
+
147
+ test('gate pos-levantada so consulta quando ha acao in_page e e-mail', async () => {
148
+ let consultas = 0;
149
+ const fonte = { ...fonteDe(), levantouMaoRecentemente: async () => { consultas += 1; return true; } };
150
+ const catalogo = catalogoCom([gatilho()]);
151
+ catalogo.acoes.acao_x.canal = 'in_page';
152
+ const comEmail = await avaliarGatilhos(EVENTO, catalogo, PERFIL, 'a@b.c', fonte);
153
+ assert.equal(consultas, 1);
154
+ assert.match(comEmail.recusas[0].motivo, /levantou a mão/);
155
+ await avaliarGatilhos(EVENTO, catalogo, PERFIL, null, fonte);
156
+ assert.equal(consultas, 1, 'sem e-mail nao consulta');
157
+ });
158
+ });
159
+
160
+ describe('A24 — o mais especifico vence', () => {
161
+ test('o preterido aparece como preterido, dizendo por quem perdeu', async () => {
162
+ const geral = gatilho({ nome: 'geral', disparo: 'pontuacao', condicao: { limiar: 10 } });
163
+ const especifico = gatilho({
164
+ nome: 'especifico', disparo: 'pontuacao',
165
+ condicao: { limiar: 10, publico: { com_conta: true } },
166
+ });
167
+ const fonte = fonteDe({ contagens: [{ tipo: 'desbloqueou_empresa', total: '3' }] });
168
+ const r = await avaliarGatilhos(EVENTO, catalogoCom([geral, especifico]), PERFIL, null, fonte);
169
+ assert.equal(r.disparos.length, 1);
170
+ assert.equal(r.disparos[0].gatilho.nome, 'especifico');
171
+ assert.deepEqual(r.disparos[0].contexto.gatilhos_preteridos, ['geral']);
172
+ });
173
+
174
+ test('empate de especificidade desempata por nome, e e deterministico', async () => {
175
+ const a = gatilho({ nome: 'aaa', disparo: 'pontuacao', condicao: { limiar: 10 } });
176
+ const b = gatilho({ nome: 'bbb', disparo: 'pontuacao', condicao: { limiar: 10 } });
177
+ const fonte = fonteDe({ contagens: [{ tipo: 'desbloqueou_empresa', total: '3' }] });
178
+ for (const ordem of [[a, b], [b, a]]) {
179
+ const r = await avaliarGatilhos(EVENTO, catalogoCom(ordem), PERFIL, null, fonte);
180
+ assert.equal(r.disparos[0].gatilho.nome, 'aaa');
181
+ }
182
+ });
183
+ });
184
+
185
+ describe('as funcoes puras que o avaliador usa', () => {
186
+ test('bucket agrupa antes de pontuar, e o resto e descartado', () => {
187
+ const tipos = { lista: { peso: 25, bucket: 3 } };
188
+ assert.equal(pontuar({ lista: 2 }, tipos).total, 0);
189
+ assert.equal(pontuar({ lista: 3 }, tipos).total, 25);
190
+ assert.equal(pontuar({ lista: 5 }, tipos).total, 25);
191
+ assert.equal(pontuar({ lista: 6 }, tipos).total, 50);
192
+ });
193
+
194
+ test('peso do gatilho sobrescreve o do catalogo (A25)', () => {
195
+ assert.equal(pontuar({ a: 1 }, { a: { peso: 5 } }, { a: 50 }).total, 50);
196
+ });
197
+
198
+ test('porte legado com zero a esquerda casa a faixa canonica', () => {
199
+ assert.equal(normalizarPorte('01-04'), '1-4');
200
+ assert.equal(normalizarPorte('acima de 500'), 'Mais que 500');
201
+ assert.equal(normalizarPorte('sei la'), '', 'irreconhecivel volta vazio, nao a primeira faixa');
202
+ });
203
+
204
+ test('publico sem criterio e fallback com especificidade zero', () => {
205
+ assert.deepEqual(casaPublico(undefined, PERFIL), {
206
+ casa: true, especificidade: 0, motivo: 'sem criterio (fallback)'.replace('criterio', 'critério'),
207
+ });
208
+ });
209
+
210
+ test('plano premium nao casa ninguem na v1, e o motivo diz por que', () => {
211
+ const r = casaPublico({ plano: ['premium'] }, PERFIL);
212
+ assert.equal(r.casa, false);
213
+ assert.match(r.motivo, /usuário do site é gratuito/);
214
+ });
215
+
216
+ test('feriado nacional fecha a janela mesmo em dia util', () => {
217
+ // 7 de setembro de 2026 e uma segunda-feira.
218
+ assert.deepEqual(horarioComercial(new Date('2026-09-07T17:00:00.000Z')), {
219
+ dentro: false, motivo: 'feriado nacional',
220
+ });
221
+ });
222
+ });
@@ -1,7 +1,10 @@
1
1
  const { test, describe, beforeEach, afterEach } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
3
 
4
- const { publicarEvento } = require('../src/services/motorEventosService.js');
4
+ const {
5
+ publicarEvento,
6
+ publicarEventoDaRequisicao,
7
+ } = require('../src/services/motorEventosService.js');
5
8
 
6
9
  const URL = 'http://coletor.interno/eventos';
7
10
 
@@ -194,3 +197,114 @@ describe('publicarEvento', () => {
194
197
  assert.equal(retorno, undefined);
195
198
  });
196
199
  });
200
+
201
+ describe('publicarEventoDaRequisicao', () => {
202
+ let fetchOriginal;
203
+ let warnOriginal;
204
+ let errorOriginal;
205
+ let urlOriginal;
206
+ let cookieOriginal;
207
+ let chamadas;
208
+
209
+ beforeEach(() => {
210
+ fetchOriginal = global.fetch;
211
+ warnOriginal = console.warn;
212
+ errorOriginal = console.error;
213
+ urlOriginal = process.env.MOTOR_EVENTOS_URL;
214
+ cookieOriginal = process.env.MOTOR_EVENTOS_COOKIE_VISITANTE;
215
+ chamadas = [];
216
+ console.warn = () => {};
217
+ console.error = () => {};
218
+ process.env.MOTOR_EVENTOS_URL = URL;
219
+ global.fetch = (url, opcoes) => {
220
+ chamadas.push({ url, opcoes });
221
+ return Promise.resolve({ ok: true, status: 201 });
222
+ };
223
+ });
224
+
225
+ afterEach(() => {
226
+ global.fetch = fetchOriginal;
227
+ console.warn = warnOriginal;
228
+ console.error = errorOriginal;
229
+ for (const [chave, valor] of [
230
+ ['MOTOR_EVENTOS_URL', urlOriginal],
231
+ ['MOTOR_EVENTOS_COOKIE_VISITANTE', cookieOriginal],
232
+ ]) {
233
+ if (valor === undefined) delete process.env[chave];
234
+ else process.env[chave] = valor;
235
+ }
236
+ });
237
+
238
+ const corpoEnviado = () => JSON.parse(chamadas[0].opcoes.body);
239
+
240
+ test('tira o token do header da requisicao', async () => {
241
+ publicarEventoDaRequisicao(
242
+ { headers: { authorization: 'Bearer jwt' }, body: {} },
243
+ { tipo: 'meu_evento', payload: { campo: 'valor' } }
244
+ );
245
+ await esperarMicrotasks();
246
+
247
+ assert.equal(chamadas[0].opcoes.headers.Authorization, 'Bearer jwt');
248
+ assert.deepEqual(corpoEnviado().payload, { campo: 'valor' });
249
+ });
250
+
251
+ test('tira o visitante do cookie quando nao ha token', async () => {
252
+ publicarEventoDaRequisicao(
253
+ { headers: { cookie: 'trk=%7B%22id%22%3A%22visitante-1%22%7D' }, body: {} },
254
+ { tipo: 'meu_evento', cookieVisitante: 'trk' }
255
+ );
256
+ await esperarMicrotasks();
257
+
258
+ assert.equal(corpoEnviado().id_visitante, 'visitante-1');
259
+ assert.equal(chamadas[0].opcoes.headers.Authorization, undefined);
260
+ });
261
+
262
+ test('o corpo tem precedencia sobre o cookie', async () => {
263
+ publicarEventoDaRequisicao(
264
+ {
265
+ headers: { cookie: 'trk=%7B%22id%22%3A%22do-cookie%22%7D' },
266
+ body: { id_visitante: 'do-corpo' },
267
+ },
268
+ { tipo: 'meu_evento', cookieVisitante: 'trk' }
269
+ );
270
+ await esperarMicrotasks();
271
+
272
+ assert.equal(corpoEnviado().id_visitante, 'do-corpo');
273
+ });
274
+
275
+ test('cai na MOTOR_EVENTOS_COOKIE_VISITANTE quando o nome nao e passado', async () => {
276
+ process.env.MOTOR_EVENTOS_COOKIE_VISITANTE = 'trk';
277
+
278
+ publicarEventoDaRequisicao(
279
+ { headers: { cookie: 'trk=visitante-2' }, body: {} },
280
+ { tipo: 'meu_evento' }
281
+ );
282
+ await esperarMicrotasks();
283
+
284
+ assert.equal(corpoEnviado().id_visitante, 'visitante-2');
285
+ });
286
+
287
+ test('nao publica quando a requisicao nao traz identidade nenhuma', async () => {
288
+ delete process.env.MOTOR_EVENTOS_COOKIE_VISITANTE;
289
+
290
+ publicarEventoDaRequisicao({ headers: {}, body: {} }, { tipo: 'meu_evento' });
291
+ await esperarMicrotasks();
292
+
293
+ assert.equal(chamadas.length, 0);
294
+ });
295
+
296
+ test('nao quebra com requisicao sem headers, sem corpo, ou sem argumento nenhum', () => {
297
+ assert.doesNotThrow(() => publicarEventoDaRequisicao({}, { tipo: 'meu_evento' }));
298
+ assert.doesNotThrow(() => publicarEventoDaRequisicao());
299
+ });
300
+
301
+ test('repassa as demais opcoes para o publicarEvento', async () => {
302
+ publicarEventoDaRequisicao(
303
+ { headers: { authorization: 'Bearer jwt' }, body: {} },
304
+ { tipo: 'meu_evento', url: 'http://outro.interno/eventos' }
305
+ );
306
+ await esperarMicrotasks();
307
+
308
+ assert.equal(chamadas[0].url, 'http://outro.interno/eventos');
309
+ });
310
+ });