@danhachuel/leadmaster 0.1.28 → 0.1.29
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/CHANGELOG.md +30 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +27 -5
- package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
- package/backend/services/agents.py +103 -0
- package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_disparos_worker.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_extractors_real.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_lead_edit_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_leads_export.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/test_disparos_worker.py +154 -0
- package/frontend/dist/assets/{index-BUIOmUBy.js → index-DTsFXKLu.js} +17 -17
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,36 @@ normaliza para a forma canônica (ex.: `0.1.0`).
|
|
|
37
37
|
falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
|
|
38
38
|
ciclo de vida vinculado for removido ou se os handlers globais sumirem.
|
|
39
39
|
|
|
40
|
+
## [0.1.29] — 2026-11-09
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Motor de disparos em background (`agents.processar_campanha_em_background`)** —
|
|
45
|
+
ativar uma campanha agora executa de verdade (simulação de envio):
|
|
46
|
+
- Rota `PUT /api/campaigns/{id}/status` com `"status": "ativa"` muda o status
|
|
47
|
+
para `em_andamento` e dispara o worker via **FastAPI `BackgroundTasks`** — a
|
|
48
|
+
resposta volta na hora, os envios sobem depois em `campaigns.json`.
|
|
49
|
+
- O worker percorre os `lead_ids` (filtro por etapa já aplicado na criação),
|
|
50
|
+
com `asyncio.sleep(1)` entre envios (tempo real de visualização na UI) e
|
|
51
|
+
**1 erro simulado a cada 10** leads; grava contadores e eventos a cada ciclo
|
|
52
|
+
via `storage.upsert_campaign` (lock de threading + escrita atômica).
|
|
53
|
+
- **Pausa no meio:** o registro é RELIDO do storage a cada ciclo e os
|
|
54
|
+
contadores fazem merge sobre a cópia fresca — pausar/parar (ou uma pausa
|
|
55
|
+
gravada durante o `sleep`) nunca é sobrescrita; o loop para no ciclo
|
|
56
|
+
seguinte. Reativar uma **concluída** zera os contadores (nova rodada);
|
|
57
|
+
reativar uma **pausada** continua de onde parou (leads já enviados são
|
|
58
|
+
pulados). Ao final, status → `concluida` (+ webhook `campanha.concluida`).
|
|
59
|
+
- **Frontend Disparos:** polling baixado de 4s → **2s** (barra de progresso
|
|
60
|
+
acompanha os contadores em tempo real), bloco de campanha ativa ganhou selo
|
|
61
|
+
**"Processando..."**, contagem de restantes e barra mais destacada
|
|
62
|
+
(Zeigarnik); **Pausar/Iniciar agora é otimista** — a UI atualiza
|
|
63
|
+
instantaneamente e o próximo polling reverte se o backend falhar.
|
|
64
|
+
- **5 novos testes pytest** (`backend/tests/test_disparos_worker.py`): worker
|
|
65
|
+
processa e conclui (contadores exatos, 1 erro a cada 10), **pausa gravada
|
|
66
|
+
durante o `sleep` interrompe o loop** (corrida tratada — pegou e fixou um bug
|
|
67
|
+
real onde o upsert sobrescrevia a pausa), retomada não reenvia, rota "ativa"
|
|
68
|
+
dispara o background e rota "pausada" não dispara. Suíte: **88 passed**.
|
|
69
|
+
|
|
40
70
|
## [0.1.28] — 2026-11-09
|
|
41
71
|
|
|
42
72
|
### Corrigido
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -13,7 +13,7 @@ import threading
|
|
|
13
13
|
from contextlib import asynccontextmanager
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
from fastapi import FastAPI, HTTPException, Response
|
|
16
|
+
from fastapi import BackgroundTasks, FastAPI, HTTPException, Response
|
|
17
17
|
from fastapi.middleware.cors import CORSMiddleware
|
|
18
18
|
from fastapi.responses import FileResponse
|
|
19
19
|
from fastapi.staticfiles import StaticFiles
|
|
@@ -435,8 +435,15 @@ def criar_campanha(campaign_in: CampaignIn):
|
|
|
435
435
|
|
|
436
436
|
|
|
437
437
|
@app.put("/api/campaigns/{campaign_id}/status")
|
|
438
|
-
def alterar_status_campanha(campaign_id: str, dados: dict):
|
|
439
|
-
"""Altera o status de uma campanha (rascunho/ativa/pausada/concluida).
|
|
438
|
+
def alterar_status_campanha(campaign_id: str, dados: dict, background_tasks: BackgroundTasks):
|
|
439
|
+
"""Altera o status de uma campanha (rascunho/ativa/pausada/concluida).
|
|
440
|
+
|
|
441
|
+
"ativa" (alias de em_andamento) DISPARA o motor de simulação em background
|
|
442
|
+
— a cada envio os contadores sobem em campaigns.json e a UI reflete na
|
|
443
|
+
barra de progresso. Reativar uma campanha concluída zera os contadores
|
|
444
|
+
(nova rodada); reativar uma pausada continua de onde parou (leads já
|
|
445
|
+
enviados são pulados). Pausar/parar responde no próximo ciclo do worker.
|
|
446
|
+
"""
|
|
440
447
|
status = (dados.get("status") or "").strip().lower()
|
|
441
448
|
# Aliases aceitos: "ativa" equivale a "em_andamento" no ciclo interno.
|
|
442
449
|
aliases = {"ativa": "em_andamento", "em_andamento": "em_andamento", "pausada": "pausada", "pausado": "pausada", "rascunho": "rascunho", "concluida": "concluida"}
|
|
@@ -446,8 +453,23 @@ def alterar_status_campanha(campaign_id: str, dados: dict):
|
|
|
446
453
|
campaign = storage.get_campaign(campaign_id)
|
|
447
454
|
if not campaign:
|
|
448
455
|
raise HTTPException(404, "Campanha não encontrada.")
|
|
449
|
-
|
|
450
|
-
|
|
456
|
+
|
|
457
|
+
if interno == "em_andamento":
|
|
458
|
+
if campaign.get("status") == "concluida":
|
|
459
|
+
# Nova rodada: zera métricas e eventos da execução anterior.
|
|
460
|
+
campaign.update({"enviadas": 0, "erros": 0, "eventos": [], "concluida_em": None})
|
|
461
|
+
campaign["status"] = "em_andamento"
|
|
462
|
+
campaign.setdefault("iniciada_em", storage.now_iso())
|
|
463
|
+
campaign.setdefault("enviadas", 0)
|
|
464
|
+
campaign.setdefault("erros", 0)
|
|
465
|
+
campaign.setdefault("eventos", [])
|
|
466
|
+
campaign["total"] = len(campaign.get("lead_ids", []))
|
|
467
|
+
storage.upsert_campaign(campaign)
|
|
468
|
+
# Motor em background: a resposta volta na hora; os envios sobem depois.
|
|
469
|
+
background_tasks.add_task(agents.processar_campanha_em_background, campaign_id)
|
|
470
|
+
else:
|
|
471
|
+
campaign["status"] = interno
|
|
472
|
+
storage.upsert_campaign(campaign)
|
|
451
473
|
return campaign
|
|
452
474
|
|
|
453
475
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
- **Webhooks**: eventos locais disparados para a URL configurada.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import threading
|
|
9
10
|
import time
|
|
10
11
|
|
|
@@ -190,6 +191,108 @@ def _executar_campanha(campaign_id: str) -> None:
|
|
|
190
191
|
_CONTROLES.pop(campaign_id, None)
|
|
191
192
|
|
|
192
193
|
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Motor de disparos — execução em background (simulação de envio)
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# Delay entre "envios" simulados: 1s dá tempo real de ver a barra de progresso
|
|
199
|
+
# mover na tela (Efeito Zeigarnik). Testes injetam 0.
|
|
200
|
+
SIM_DELAY_SEGUNDOS = 1.0
|
|
201
|
+
|
|
202
|
+
# Um erro simulado a cada N envios — proporção realista para a UI.
|
|
203
|
+
SIM_ERRO_A_CADA = 10
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def processar_campanha_em_background(campaign_id: str, delay_segundos: float | None = None) -> None:
|
|
207
|
+
"""Processa os leads de uma campanha ATIVA em background (simulação).
|
|
208
|
+
|
|
209
|
+
- Itera pelos `lead_ids` da campanha (o filtro por etapa já foi aplicado
|
|
210
|
+
na criação); leads já enviados em rodadas anteriores são pulados, então
|
|
211
|
+
pausar e reativar CONTINUA de onde parou.
|
|
212
|
+
- A cada ciclo o registro é RELIDO do storage e os contadores são
|
|
213
|
+
mergeados sobre essa cópia fresca: uma pausa/parada gravada durante o
|
|
214
|
+
`sleep` (ou durante o envio) nunca é sobrescrita pelo worker — o loop é
|
|
215
|
+
interrompido no ciclo seguinte (controle instantâneo do usuário, sem
|
|
216
|
+
corromper campaigns.json: lock de threading + escrita atômica).
|
|
217
|
+
- `await asyncio.sleep(delay)` entre envios (1s padrão = tempo real de
|
|
218
|
+
visualização na barra de progresso); 1 em cada 10 leads gera erro
|
|
219
|
+
simulado.
|
|
220
|
+
- Ao final, status → "concluida" (webhook `campanha.concluida`).
|
|
221
|
+
"""
|
|
222
|
+
delay = SIM_DELAY_SEGUNDOS if delay_segundos is None else float(delay_segundos)
|
|
223
|
+
campanha = storage.get_campaign(campaign_id)
|
|
224
|
+
if not campanha:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if campanha.get("status") != "em_andamento":
|
|
228
|
+
campanha["status"] = "em_andamento"
|
|
229
|
+
campanha.setdefault("iniciada_em", storage.now_iso())
|
|
230
|
+
campanha.setdefault("enviadas", 0)
|
|
231
|
+
campanha.setdefault("erros", 0)
|
|
232
|
+
campanha.setdefault("eventos", [])
|
|
233
|
+
campanha["total"] = len(campanha.get("lead_ids", []))
|
|
234
|
+
storage.upsert_campaign(campanha)
|
|
235
|
+
|
|
236
|
+
leads = {l["id"]: l for l in storage.list_leads()}
|
|
237
|
+
eventos = list(campanha.get("eventos", []))
|
|
238
|
+
enviadas = int(campanha.get("enviadas", 0))
|
|
239
|
+
erros = int(campanha.get("erros", 0))
|
|
240
|
+
ja_enviados = {e.get("lead_id") for e in eventos if e.get("status") == "enviado"}
|
|
241
|
+
|
|
242
|
+
def _gravar(fresco: dict, status: str | None = None) -> dict:
|
|
243
|
+
"""Merge dos contadores sobre a cópia fresca do disco — nunca
|
|
244
|
+
sobrescreve pausa/parada gravadas por outra rota durante a escrita."""
|
|
245
|
+
novo = dict(fresco)
|
|
246
|
+
novo["enviadas"] = enviadas
|
|
247
|
+
novo["erros"] = erros
|
|
248
|
+
novo["eventos"] = eventos
|
|
249
|
+
if status:
|
|
250
|
+
novo["status"] = status
|
|
251
|
+
return storage.upsert_campaign(novo)
|
|
252
|
+
|
|
253
|
+
for indice, lead_id in enumerate(campanha.get("lead_ids", []), start=1):
|
|
254
|
+
# Releitura a cada ciclo: pausar/parar responde no próximo ciclo.
|
|
255
|
+
fresco = storage.get_campaign(campaign_id)
|
|
256
|
+
if not fresco:
|
|
257
|
+
return
|
|
258
|
+
if fresco.get("status") != "em_andamento":
|
|
259
|
+
_gravar(fresco)
|
|
260
|
+
return
|
|
261
|
+
if lead_id in ja_enviados:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
if delay > 0:
|
|
265
|
+
await asyncio.sleep(delay)
|
|
266
|
+
|
|
267
|
+
# A pausa pode ter chegado DURANTE o sleep: relê antes de gravar.
|
|
268
|
+
fresco = storage.get_campaign(campaign_id) or fresco
|
|
269
|
+
if fresco.get("status") != "em_andamento":
|
|
270
|
+
_gravar(fresco)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
momento = storage.now_iso()
|
|
274
|
+
lead = leads.get(lead_id)
|
|
275
|
+
if not lead:
|
|
276
|
+
erros += 1
|
|
277
|
+
eventos.append({"lead_id": lead_id, "status": "erro", "mensagem": "Lead removido da base.", "momento": momento})
|
|
278
|
+
elif indice % SIM_ERRO_A_CADA == 0:
|
|
279
|
+
erros += 1
|
|
280
|
+
eventos.append({"lead_id": lead_id, "status": "erro", "mensagem": "Falha simulada de envio (1 a cada 10).", "momento": momento})
|
|
281
|
+
else:
|
|
282
|
+
mensagem = render_template(campanha.get("mensagem") or "", lead)
|
|
283
|
+
enviadas += 1
|
|
284
|
+
eventos.append({"lead_id": lead_id, "status": "enviado", "mensagem": mensagem[:120], "momento": momento})
|
|
285
|
+
ja_enviados.add(lead_id)
|
|
286
|
+
_gravar(fresco)
|
|
287
|
+
|
|
288
|
+
# Fim: merge dos contadores + conclusão sobre o registro fresco do disco.
|
|
289
|
+
fresco_final = storage.get_campaign(campaign_id) or campanha
|
|
290
|
+
final = _gravar(fresco_final, status="concluida")
|
|
291
|
+
final["concluida_em"] = storage.now_iso()
|
|
292
|
+
storage.upsert_campaign(final)
|
|
293
|
+
disparar_webhook("campanha.concluida", {"campanha_id": campaign_id, "nome": final.get("nome", "")})
|
|
294
|
+
|
|
295
|
+
|
|
193
296
|
# ---------------------------------------------------------------------------
|
|
194
297
|
# Agente de atendimento (texto via LLM / voz via pipecat)
|
|
195
298
|
# ---------------------------------------------------------------------------
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Testes do motor de disparos em background — agents.processar_campanha_em_background.
|
|
2
|
+
|
|
3
|
+
Cobrem: contadores enviadas/erros atualizados em campaigns.json, erro
|
|
4
|
+
simulado (1 a cada 10), conclusão automática, interrupção por pausa no meio
|
|
5
|
+
do loop e o gatilho via PUT /api/campaigns/{id}/status com status "ativa".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
import main
|
|
13
|
+
from services import agents, storage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _lead(telefone: str) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"id": storage._novo_id(),
|
|
19
|
+
"nome": f"Lead {telefone}",
|
|
20
|
+
"telefone": telefone,
|
|
21
|
+
"email": "",
|
|
22
|
+
"fonte": "teste",
|
|
23
|
+
"etapa": "leads",
|
|
24
|
+
"empresa": "",
|
|
25
|
+
"cidade": "",
|
|
26
|
+
"endereco": "",
|
|
27
|
+
"score": 50,
|
|
28
|
+
"tags": [],
|
|
29
|
+
"observacoes": "",
|
|
30
|
+
"extras": {},
|
|
31
|
+
"criado_em": storage.now_iso(),
|
|
32
|
+
"atualizado_em": storage.now_iso(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _campanha(quantidade: int) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"id": storage._novo_id(),
|
|
39
|
+
"nome": "Campanha QA",
|
|
40
|
+
"canal": "texto",
|
|
41
|
+
"template": "texto",
|
|
42
|
+
"mensagem": "Olá {{nome}}!",
|
|
43
|
+
"lead_ids": [storage._novo_id() for _ in range(quantidade)],
|
|
44
|
+
"status": "rascunho",
|
|
45
|
+
"total": quantidade,
|
|
46
|
+
"enviadas": 0,
|
|
47
|
+
"erros": 0,
|
|
48
|
+
"eventos": [],
|
|
49
|
+
"criado_em": storage.now_iso(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _preparar(tmp_path, monkeypatch, quantidade):
|
|
54
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
55
|
+
leads = [_lead(f"(11) 90000-{i:04d}") for i in range(quantidade)]
|
|
56
|
+
campanha = _campanha(quantidade)
|
|
57
|
+
campanha["lead_ids"] = [l["id"] for l in leads]
|
|
58
|
+
storage.save_leads(leads)
|
|
59
|
+
storage.upsert_campaign(campanha)
|
|
60
|
+
return campanha
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_background_processa_e_conclui(tmp_path, monkeypatch):
|
|
64
|
+
campanha = _preparar(tmp_path, monkeypatch, 12)
|
|
65
|
+
asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0))
|
|
66
|
+
|
|
67
|
+
gravada = storage.get_campaign(campanha["id"])
|
|
68
|
+
assert gravada["status"] == "concluida"
|
|
69
|
+
assert gravada["enviadas"] + gravada["erros"] == 12 # todos processados
|
|
70
|
+
assert gravada["enviadas"] == 11 # 1 erro simulado a cada 10
|
|
71
|
+
assert gravada["erros"] == 1
|
|
72
|
+
assert len(gravada["eventos"]) == 12
|
|
73
|
+
assert any(e["status"] == "erro" for e in gravada["eventos"])
|
|
74
|
+
assert "concluida_em" in gravada
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_pausa_interrompe_no_proximo_ciclo(tmp_path, monkeypatch):
|
|
78
|
+
campanha = _preparar(tmp_path, monkeypatch, 6)
|
|
79
|
+
|
|
80
|
+
real_sleep = asyncio.sleep
|
|
81
|
+
|
|
82
|
+
async def sleep_pausante(delay, *args, **kwargs):
|
|
83
|
+
# Na 3ª "espera" o usuário pausa a campanha (como a rota faria).
|
|
84
|
+
sleep_pausante.chamadas += 1
|
|
85
|
+
if sleep_pausante.chamadas == 3:
|
|
86
|
+
atual = storage.get_campaign(campanha["id"])
|
|
87
|
+
atual["status"] = "pausada"
|
|
88
|
+
storage.upsert_campaign(atual)
|
|
89
|
+
await real_sleep(0)
|
|
90
|
+
|
|
91
|
+
sleep_pausante.chamadas = 0
|
|
92
|
+
monkeypatch.setattr(agents.asyncio, "sleep", sleep_pausante)
|
|
93
|
+
asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0.01))
|
|
94
|
+
|
|
95
|
+
gravada = storage.get_campaign(campanha["id"])
|
|
96
|
+
assert gravada["status"] == "pausada"
|
|
97
|
+
# Processou menos que o total: o loop foi interrompido no meio.
|
|
98
|
+
assert gravada["enviadas"] + gravada["erros"] < 6
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_retomar_nao_reenvia(tmp_path, monkeypatch):
|
|
102
|
+
campanha = _preparar(tmp_path, monkeypatch, 5)
|
|
103
|
+
asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0))
|
|
104
|
+
concluida = storage.get_campaign(campanha["id"])
|
|
105
|
+
assert concluida["enviadas"] == 5
|
|
106
|
+
|
|
107
|
+
# Reativar uma concluída zera os contadores (nova rodada).
|
|
108
|
+
concluida.update({"enviadas": 0, "erros": 0, "eventos": [], "concluida_em": None, "status": "em_andamento"})
|
|
109
|
+
storage.upsert_campaign(concluida)
|
|
110
|
+
asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0))
|
|
111
|
+
assert storage.get_campaign(campanha["id"])["enviadas"] == 5
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_rota_ativa_dispara_background(tmp_path, monkeypatch):
|
|
115
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
116
|
+
monkeypatch.setattr(agents, "SIM_DELAY_SEGUNDOS", 0)
|
|
117
|
+
chamadas = []
|
|
118
|
+
original = agents.processar_campanha_em_background
|
|
119
|
+
|
|
120
|
+
async def espiao(campaign_id, delay_segundos=None):
|
|
121
|
+
chamadas.append(campaign_id)
|
|
122
|
+
await original(campaign_id, delay_segundos=0)
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(agents, "processar_campanha_em_background", espiao)
|
|
125
|
+
|
|
126
|
+
leads = [_lead(f"(11) 91000-{i:04d}") for i in range(3)]
|
|
127
|
+
campanha = _campanha(3)
|
|
128
|
+
campanha["lead_ids"] = [l["id"] for l in leads]
|
|
129
|
+
storage.save_leads(leads)
|
|
130
|
+
storage.upsert_campaign(campanha)
|
|
131
|
+
|
|
132
|
+
with TestClient(main.app) as client:
|
|
133
|
+
r = client.put(f"/api/campaigns/{campanha['id']}/status", json={"status": "ativa"})
|
|
134
|
+
assert r.status_code == 200
|
|
135
|
+
assert r.json()["status"] == "em_andamento"
|
|
136
|
+
|
|
137
|
+
# O BackgroundTask roda dentro do TestClient antes de retornar.
|
|
138
|
+
assert chamadas == [campanha["id"]]
|
|
139
|
+
gravada = storage.get_campaign(campanha["id"])
|
|
140
|
+
assert gravada["enviadas"] + gravada["erros"] == 3
|
|
141
|
+
assert gravada["status"] == "concluida"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_rota_pausa_nao_dispara(tmp_path, monkeypatch):
|
|
145
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
146
|
+
campanha = _campanha(2)
|
|
147
|
+
storage.upsert_campaign(campanha)
|
|
148
|
+
chamadas = []
|
|
149
|
+
monkeypatch.setattr(agents, "processar_campanha_em_background", lambda *a, **k: chamadas.append(a))
|
|
150
|
+
with TestClient(main.app) as client:
|
|
151
|
+
r = client.put(f"/api/campaigns/{campanha['id']}/status", json={"status": "pausada"})
|
|
152
|
+
assert r.status_code == 200
|
|
153
|
+
assert r.json()["status"] == "pausada"
|
|
154
|
+
assert chamadas == []
|