@danhachuel/leadmaster 0.1.21 → 0.1.23
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 +47 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +50 -13
- 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/extractors.py +49 -9
- package/backend/services/storage.py +4 -1
- 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_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_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_extractors_real.py +65 -0
- package/backend/tests/test_funnel_routes.py +65 -0
- package/frontend/dist/assets/{index-CC4fz5Tw.js → index-Bc1EFZVI.js} +17 -17
- package/frontend/dist/assets/{index-Bn20U7qZ.css → index-ViDiQGJH.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,53 @@ 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.23] — 2026-11-09
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Extratores conectados de verdade (fim da dependência de mock quando
|
|
45
|
+
configurado):**
|
|
46
|
+
- **Google Maps:** executa o binário real (`gosom/google-maps-scraper`) com as
|
|
47
|
+
flags de isolamento (`creationflags`, sem janelas de CMD) e **timeout de
|
|
48
|
+
segurança configurável** (`timeout_maps_segundos`, padrão 300s).
|
|
49
|
+
- **Instagram:** passa **URL + limite** ao CLI (`igmapper_args` agora inclui
|
|
50
|
+
`{limit}`) com timeout configurável (`timeout_instagram_segundos`, padrão
|
|
51
|
+
120s).
|
|
52
|
+
- **CNPJ:** URL base da **BrasilAPI** vem do `config.json`
|
|
53
|
+
(`cnpj_api_url`) — permite trocar o provedor sem alterar código.
|
|
54
|
+
- **Regra de erro:** scraper **configurado** que falha (código ≠ 0, timeout,
|
|
55
|
+
executável inválido) retorna **HTTP 500 com mensagem clara** e **não salva
|
|
56
|
+
nada** (sem mock disfarçado, sem dados parciais). Sem scraper configurado,
|
|
57
|
+
o modo mock segue protegendo os primeiros testes.
|
|
58
|
+
- Ferramentas → Extratores ganhou os campos **URL base da BrasilAPI** e
|
|
59
|
+
**timeouts de segurança** (Maps/Instagram).
|
|
60
|
+
- **4 novos testes pytest** (`backend/tests/test_extractors_real.py`): scraper
|
|
61
|
+
configurado falhando → 500 sem salvar, timeout → 500 "Timeout na extração",
|
|
62
|
+
falha do igmapper → 500 sem salvar, e mock preservado sem configuração.
|
|
63
|
+
|
|
64
|
+
## [0.1.22] — 2026-11-09
|
|
65
|
+
|
|
66
|
+
### Adicionado
|
|
67
|
+
|
|
68
|
+
- **Funil de Prospecção (visão macro de conversão):**
|
|
69
|
+
- Rota `GET /api/funnel`: conta os leads por etapa do CRM (`Leads → Em
|
|
70
|
+
Contato → Proposta → Fechado`), calcula a taxa de conversão de cada
|
|
71
|
+
transição (`para/de × 100`, 1 decimal) e a queda percentual, e identifica
|
|
72
|
+
o **gargalo** — a etapa *origem* da maior queda (onde os leads param).
|
|
73
|
+
Resposta: `{etapas, conversoes, gargalo}`.
|
|
74
|
+
- Página `frontend/src/pages/Funil.tsx` (rota `/funil`, link **"Funil de
|
|
75
|
+
Prospecção"** no menu): **funil em CSS puro** com fatias de largura
|
|
76
|
+
decrescente (nome + quantidade por etapa), **cards de conversão** por
|
|
77
|
+
transição (verde ≥50%, âmbar ≥25%, vermelho abaixo), **alerta de gargalo**
|
|
78
|
+
em vermelho/laranja com "⚠️ Gargalo: muitos leads param em 'X'" e fatia
|
|
79
|
+
do gargalo destacada (von Restorff), zero filtros na tela (Lei de Hick) e
|
|
80
|
+
funil vazio com barra zerada + "Comece a prospectar para ver seu funil"
|
|
81
|
+
(Zeigarnik). Acesso aos dados via `api.funil()` no cliente padronizado.
|
|
82
|
+
- **4 novos testes pytest** (`backend/tests/test_funnel_routes.py`):
|
|
83
|
+
conversões + gargalo com distribuição [10,5,2,1] (taxas 50/40/50 → gargalo
|
|
84
|
+
"Em Contato"), gargalo na primeira etapa, funil vazio (taxas `null`,
|
|
85
|
+
gargalo `null`) e funil sem gargalo (quedas zero).
|
|
86
|
+
|
|
40
87
|
## [0.1.21] — 2026-11-09
|
|
41
88
|
|
|
42
89
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -430,15 +430,16 @@ def extrair_instagram_endpoint(dados: InstagramExtractIn):
|
|
|
430
430
|
leads: list = []
|
|
431
431
|
|
|
432
432
|
if cli and Path(cli).exists():
|
|
433
|
+
# Scraper REAL configurado: executa e propaga erro amigavel (HTTP 500) se
|
|
434
|
+
# falhar — nunca salvamos dados parciais/corrompidos.
|
|
433
435
|
try:
|
|
434
|
-
resultado = extractors.extrair_instagram(dados.profile_url)
|
|
436
|
+
resultado = extractors.extrair_instagram(dados.profile_url, dados.limit)
|
|
435
437
|
leads = resultado.get("leads", [])
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
aviso = f"igmapper indisponível ({e}); usando dados mockados."
|
|
438
|
+
origem = "cli"
|
|
439
|
+
except extractors.ExtratorNaoConfigurado as e:
|
|
440
|
+
raise HTTPException(500, f"Scraper nao configurado corretamente: {e}")
|
|
441
|
+
except extractors.ExtratorErro as e:
|
|
442
|
+
raise HTTPException(500, str(e))
|
|
442
443
|
|
|
443
444
|
if not leads:
|
|
444
445
|
leads = extractors.gerar_mock_instagram(dados.profile_url, dados.limit)
|
|
@@ -513,15 +514,16 @@ def extrair_maps(dados: MapsExtractIn):
|
|
|
513
514
|
leads: list = []
|
|
514
515
|
|
|
515
516
|
if cli and Path(cli).exists():
|
|
517
|
+
# Scraper REAL configurado: executa e propaga erro amigavel (HTTP 500)
|
|
518
|
+
# se falhar — nunca salvamos dados parciais/corrompidos.
|
|
516
519
|
try:
|
|
517
520
|
resultado = extractors.extrair_google_maps(dados.keyword, dados.location, max_resultados)
|
|
518
521
|
leads = resultado.get("leads", [])
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
aviso = f"Scraper indisponível ({e}); usando dados mockados."
|
|
522
|
+
origem = "cli"
|
|
523
|
+
except extractors.ExtratorNaoConfigurado as e:
|
|
524
|
+
raise HTTPException(500, f"Scraper nao configurado corretamente: {e}")
|
|
525
|
+
except extractors.ExtratorErro as e:
|
|
526
|
+
raise HTTPException(500, str(e))
|
|
525
527
|
|
|
526
528
|
if not leads:
|
|
527
529
|
leads = extractors.gerar_mock_maps(dados.keyword, dados.location, dados.limit)
|
|
@@ -602,6 +604,41 @@ def salvar_prompt_agente(agent_id: str, dados: dict):
|
|
|
602
604
|
return {"ok": True, "agent_id": agent_id, "prompt": agente["prompt"]}
|
|
603
605
|
|
|
604
606
|
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Funil de Prospecção (métricas de conversão do CRM)
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
ETAPAS_FUNIL = ["Leads", "Em Contato", "Proposta", "Fechado"]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@app.get("/api/funnel")
|
|
615
|
+
def funil_prospeccao():
|
|
616
|
+
"""Métricas de conversão do funil, calculadas a partir do leads.json."""
|
|
617
|
+
leads = storage.list_leads()
|
|
618
|
+
quantidades = [
|
|
619
|
+
sum(1 for l in leads if l.get("etapa") == etapa)
|
|
620
|
+
for etapa in ("leads", "em_contato", "proposta", "fechado")
|
|
621
|
+
]
|
|
622
|
+
etapas = [{"nome": nome, "quantidade": qtd} for nome, qtd in zip(ETAPAS_FUNIL, quantidades)]
|
|
623
|
+
|
|
624
|
+
conversoes = []
|
|
625
|
+
for i in range(len(ETAPAS_FUNIL) - 1):
|
|
626
|
+
de, para = ETAPAS_FUNIL[i], ETAPAS_FUNIL[i + 1]
|
|
627
|
+
if quantidades[i] > 0:
|
|
628
|
+
taxa = round(quantidades[i + 1] / quantidades[i] * 100, 1)
|
|
629
|
+
queda = round(100 - taxa, 1)
|
|
630
|
+
else:
|
|
631
|
+
taxa, queda = None, None
|
|
632
|
+
conversoes.append({"de": de, "para": para, "taxa": taxa, "queda": queda})
|
|
633
|
+
|
|
634
|
+
# Gargalo: etapa ORIGEM da maior queda entre etapas consecutivas — é onde os
|
|
635
|
+
# leads param (ex.: muitos entram em "Em Contato" e não seguem).
|
|
636
|
+
quedas = [c for c in conversoes if c["queda"] is not None and c["queda"] > 0]
|
|
637
|
+
gargalo = max(quedas, key=lambda c: c["queda"])["de"] if quedas else None
|
|
638
|
+
|
|
639
|
+
return {"etapas": etapas, "conversoes": conversoes, "gargalo": gargalo}
|
|
640
|
+
|
|
641
|
+
|
|
605
642
|
# ---------------------------------------------------------------------------
|
|
606
643
|
# Configuração e integrações
|
|
607
644
|
# ---------------------------------------------------------------------------
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -32,6 +32,14 @@ def _conf() -> dict:
|
|
|
32
32
|
return storage.get_config()["extratores"]
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _timeout_extrator(chave: str, padrao: int) -> int:
|
|
36
|
+
"""Timeout de segurança configurável por extrator (Ferramentas → Extratores)."""
|
|
37
|
+
try:
|
|
38
|
+
return max(5, int(_conf().get(chave, padrao)))
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
return padrao
|
|
41
|
+
|
|
42
|
+
|
|
35
43
|
def _novo_lead_parcial() -> dict:
|
|
36
44
|
return {"tags": [], "observacoes": "", "score": 50}
|
|
37
45
|
|
|
@@ -62,7 +70,19 @@ def extrair_google_maps(palavra_chave: str, localizacao: str, max_resultados: in
|
|
|
62
70
|
outdir=str(outdir),
|
|
63
71
|
)
|
|
64
72
|
try:
|
|
65
|
-
resultado = subprocess.run(
|
|
73
|
+
resultado = subprocess.run(
|
|
74
|
+
cmd,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=_timeout_extrator("timeout_maps_segundos", 300),
|
|
78
|
+
shell=False,
|
|
79
|
+
creationflags=proc_flags.creationflags(),
|
|
80
|
+
)
|
|
81
|
+
except subprocess.TimeoutExpired as e:
|
|
82
|
+
raise ExtratorErro(
|
|
83
|
+
f"Timeout na extração do Google Maps após {_timeout_extrator('timeout_maps_segundos', 300)}s. "
|
|
84
|
+
"Aumente o timeout em Ferramentas → Extratores ou reduza a quantidade de resultados."
|
|
85
|
+
) from e
|
|
66
86
|
except FileNotFoundError as e:
|
|
67
87
|
raise ExtratorNaoConfigurado(f"Não foi possível executar o CLI do Google Maps Scraper: {e}") from e
|
|
68
88
|
if resultado.returncode != 0:
|
|
@@ -78,7 +98,7 @@ def extrair_google_maps(palavra_chave: str, localizacao: str, max_resultados: in
|
|
|
78
98
|
# Instagram (lucasoal/igmapper via CLI)
|
|
79
99
|
# ---------------------------------------------------------------------------
|
|
80
100
|
|
|
81
|
-
def extrair_instagram(profile_url: str) -> dict:
|
|
101
|
+
def extrair_instagram(profile_url: str, limit: int = 10) -> dict:
|
|
82
102
|
conf = _conf()
|
|
83
103
|
cli = conf.get("igmapper_cli", "")
|
|
84
104
|
if not cli or not Path(cli).exists():
|
|
@@ -88,10 +108,27 @@ def extrair_instagram(profile_url: str) -> dict:
|
|
|
88
108
|
)
|
|
89
109
|
with tempfile.TemporaryDirectory(prefix="leadmaster-ig-") as tmp:
|
|
90
110
|
outdir = Path(tmp)
|
|
91
|
-
args_template = conf.get("igmapper_args", "--profile {url} --outdir {outdir}")
|
|
92
|
-
cmd = [cli] + _formatar_args(
|
|
111
|
+
args_template = conf.get("igmapper_args", "--profile {url} --limit {limit} --outdir {outdir}")
|
|
112
|
+
cmd = [cli] + _formatar_args(
|
|
113
|
+
args_template,
|
|
114
|
+
url=profile_url,
|
|
115
|
+
limit=max(1, int(limit or 10)),
|
|
116
|
+
outdir=str(outdir),
|
|
117
|
+
)
|
|
93
118
|
try:
|
|
94
|
-
resultado = subprocess.run(
|
|
119
|
+
resultado = subprocess.run(
|
|
120
|
+
cmd,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
timeout=_timeout_extrator("timeout_instagram_segundos", 120),
|
|
124
|
+
shell=False,
|
|
125
|
+
creationflags=proc_flags.creationflags(),
|
|
126
|
+
)
|
|
127
|
+
except subprocess.TimeoutExpired as e:
|
|
128
|
+
raise ExtratorErro(
|
|
129
|
+
f"Timeout na extração do Instagram após {_timeout_extrator('timeout_instagram_segundos', 120)}s. "
|
|
130
|
+
"Aumente o timeout em Ferramentas → Extratores."
|
|
131
|
+
) from e
|
|
95
132
|
except FileNotFoundError as e:
|
|
96
133
|
raise ExtratorNaoConfigurado(f"Não foi possível executar o igmapper: {e}") from e
|
|
97
134
|
if resultado.returncode != 0:
|
|
@@ -113,6 +150,7 @@ def extrair_instagram(profile_url: str) -> dict:
|
|
|
113
150
|
# for este valor, o extrator trabalha em modo MOCK (5–10 empresas fictícias) —
|
|
114
151
|
# estabilidade e anti-bloqueio nesta fase. URL diferente = provedor real.
|
|
115
152
|
CNPJ_PROVEDOR_PADRAO = "https://api.opencnpj.org"
|
|
153
|
+
CNPJ_API_PADRAO = "https://brasilapi.com.br/api/cnpj/v1"
|
|
116
154
|
|
|
117
155
|
|
|
118
156
|
def _so_digitos(texto: str) -> str:
|
|
@@ -123,14 +161,16 @@ def consulta_cnpj(cnpj: str) -> dict:
|
|
|
123
161
|
cnpj = _so_digitos(cnpj)
|
|
124
162
|
if len(cnpj) != 14:
|
|
125
163
|
raise ExtratorErro("CNPJ inválido: use 14 dígitos (com ou sem pontuação).")
|
|
164
|
+
base = (_conf().get("cnpj_api_url") or CNPJ_API_PADRAO).rstrip("/")
|
|
165
|
+
url = f"{base}/{cnpj}"
|
|
126
166
|
try:
|
|
127
|
-
r = httpx.get(
|
|
167
|
+
r = httpx.get(url, timeout=30)
|
|
128
168
|
except httpx.HTTPError as e:
|
|
129
|
-
raise ExtratorErro(f"Falha ao consultar a
|
|
169
|
+
raise ExtratorErro(f"Falha ao consultar a API de CNPJ ({base}): {e}") from e
|
|
130
170
|
if r.status_code == 404:
|
|
131
|
-
raise ExtratorErro("CNPJ não encontrado na
|
|
171
|
+
raise ExtratorErro("CNPJ não encontrado na API consultada.")
|
|
132
172
|
if r.status_code >= 400:
|
|
133
|
-
raise ExtratorErro(f"
|
|
173
|
+
raise ExtratorErro(f"API de CNPJ ({base}) respondeu HTTP {r.status_code}: {r.text[:200]}")
|
|
134
174
|
d = r.json()
|
|
135
175
|
endereco = " ".join(
|
|
136
176
|
p
|
|
@@ -114,9 +114,12 @@ DEFAULT_CONFIG = {
|
|
|
114
114
|
'--kw "{palavra_chave}" --loc "{localizacao}" --lang pt --email --max {max_resultados} --outdir "{outdir}"'
|
|
115
115
|
),
|
|
116
116
|
"igmapper_cli": "",
|
|
117
|
-
"igmapper_args": "--profile {url} --outdir {outdir}",
|
|
117
|
+
"igmapper_args": "--profile {url} --limit {limit} --outdir {outdir}",
|
|
118
118
|
"scrapling_cli": "",
|
|
119
|
+
"cnpj_api_url": "https://brasilapi.com.br/api/cnpj/v1",
|
|
119
120
|
"cnpj_provedor_busca": "https://api.opencnpj.org",
|
|
121
|
+
"timeout_maps_segundos": 300,
|
|
122
|
+
"timeout_instagram_segundos": 120,
|
|
120
123
|
},
|
|
121
124
|
"agentes": {
|
|
122
125
|
"disparo": {
|
|
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,65 @@
|
|
|
1
|
+
"""Testes dos extratores REAIS (0.1.23): scraper configurado que falha retorna
|
|
2
|
+
HTTP 500 amigável e NÃO salva leads mockados/parciais; sem scraper configurado,
|
|
3
|
+
o modo mock segue funcionando. Usa o próprio executável do Python como falso
|
|
4
|
+
binário para simular falha de execução do CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from fastapi.testclient import TestClient
|
|
13
|
+
|
|
14
|
+
import main
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture()
|
|
18
|
+
def client(tmp_path, monkeypatch):
|
|
19
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
20
|
+
with TestClient(main.app) as c:
|
|
21
|
+
yield c
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _completed(returncode, stderr=""):
|
|
25
|
+
return main.extractors.subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr=stderr)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_maps_scraper_falhar_500_sem_salvar(client):
|
|
29
|
+
# "Binário" configurado (o próprio python), mas a execução falha (returncode 1).
|
|
30
|
+
client.put("/api/config", json={"extratores": {"google_maps_cli": sys.executable}})
|
|
31
|
+
antes = len(client.get("/api/crm/leads").json())
|
|
32
|
+
with patch.object(main.extractors.subprocess, "run", return_value=_completed(1, "falha simulada")):
|
|
33
|
+
r = client.post("/api/extractors/maps", json={"keyword": "pizzaria", "location": "São Paulo, SP", "limit": 8})
|
|
34
|
+
assert r.status_code == 500
|
|
35
|
+
assert "código 1" in r.json()["detail"] or "falha simulada" in r.json()["detail"]
|
|
36
|
+
# Nada foi salvo no leads.json (sem mock/parcial).
|
|
37
|
+
depois = client.get("/api/crm/leads").json()
|
|
38
|
+
assert len(depois) == antes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_maps_scraper_timeout_500(client):
|
|
42
|
+
client.put("/api/config", json={"extratores": {"google_maps_cli": sys.executable}})
|
|
43
|
+
with patch.object(
|
|
44
|
+
main.extractors.subprocess, "run", side_effect=subprocess.TimeoutExpired(cmd="x", timeout=300)
|
|
45
|
+
):
|
|
46
|
+
r = client.post("/api/extractors/maps", json={"keyword": "dentista", "location": "Rio, RJ", "limit": 8})
|
|
47
|
+
assert r.status_code == 500
|
|
48
|
+
assert "Timeout na extração" in r.json()["detail"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_instagram_scraper_falhar_500_sem_salvar(client):
|
|
52
|
+
client.put("/api/config", json={"extratores": {"igmapper_cli": sys.executable}})
|
|
53
|
+
antes = len(client.get("/api/crm/leads").json())
|
|
54
|
+
with patch.object(main.extractors.subprocess, "run", return_value=_completed(2, "igmapper explodiu")):
|
|
55
|
+
r = client.post("/api/extractors/instagram", json={"profile_url": "https://instagram.com/x", "limit": 6})
|
|
56
|
+
assert r.status_code == 500
|
|
57
|
+
depois = client.get("/api/crm/leads").json()
|
|
58
|
+
assert len(depois) == antes
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_maps_sem_scraper_mantem_mock(client):
|
|
62
|
+
r = client.post("/api/extractors/maps", json={"keyword": "pizzaria", "location": "Campinas, SP", "limit": 7})
|
|
63
|
+
assert r.status_code == 201
|
|
64
|
+
assert r.json()["origem"] == "mock"
|
|
65
|
+
assert len(r.json()["importados"]) == 7
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Testes do Funil de Prospecção — GET /api/funnel.
|
|
2
|
+
|
|
3
|
+
Semeadura determinística no leads.json do home temporário para validar
|
|
4
|
+
contagens, taxas de conversão e identificação do gargalo.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
import main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture()
|
|
16
|
+
def client(tmp_path, monkeypatch):
|
|
17
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
18
|
+
with TestClient(main.app) as c:
|
|
19
|
+
yield c
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _semear(client, distribuicao):
|
|
23
|
+
"""Escreve leads diretamente no leads.json do home do teste."""
|
|
24
|
+
home = main.storage.storage_dir()
|
|
25
|
+
leads = []
|
|
26
|
+
for etapa, qtd in distribuicao.items():
|
|
27
|
+
for i in range(qtd):
|
|
28
|
+
leads.append({"id": f"{etapa}-{i}", "nome": f"Lead {etapa} {i}", "telefone": "", "email": "", "fonte": "Teste", "etapa": etapa})
|
|
29
|
+
(home / "leads.json").write_text(json.dumps(leads), encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_funil_conversoes_e_gargalo(client):
|
|
33
|
+
# [10, 5, 2, 1]: taxas 50%, 40%, 50% → quedas 50, 60, 50 → gargalo "Em Contato"
|
|
34
|
+
_semear(client, {"leads": 10, "em_contato": 5, "proposta": 2, "fechado": 1})
|
|
35
|
+
r = client.get("/api/funnel")
|
|
36
|
+
assert r.status_code == 200
|
|
37
|
+
corpo = r.json()
|
|
38
|
+
assert [e["quantidade"] for e in corpo["etapas"]] == [10, 5, 2, 1]
|
|
39
|
+
assert [e["nome"] for e in corpo["etapas"]] == ["Leads", "Em Contato", "Proposta", "Fechado"]
|
|
40
|
+
taxas = [c["taxa"] for c in corpo["conversoes"]]
|
|
41
|
+
assert taxas == [50.0, 40.0, 50.0]
|
|
42
|
+
assert corpo["gargalo"] == "Em Contato"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_funil_gargalo_primeira_etapa(client):
|
|
46
|
+
# Todos param na primeira etapa: queda 100% em Leads → Em Contato.
|
|
47
|
+
_semear(client, {"leads": 7, "em_contato": 0, "proposta": 0, "fechado": 0})
|
|
48
|
+
corpo = client.get("/api/funnel").json()
|
|
49
|
+
assert corpo["gargalo"] == "Leads"
|
|
50
|
+
assert corpo["conversoes"][0]["taxa"] == 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_funil_sem_leads(client):
|
|
54
|
+
_semear(client, {})
|
|
55
|
+
corpo = client.get("/api/funnel").json()
|
|
56
|
+
assert [e["quantidade"] for e in corpo["etapas"]] == [0, 0, 0, 0]
|
|
57
|
+
assert all(c["taxa"] is None for c in corpo["conversoes"])
|
|
58
|
+
assert corpo["gargalo"] is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_funil_sem_gargalo(client):
|
|
62
|
+
# Quedas todas zero (contagens iguais) → sem gargalo definido.
|
|
63
|
+
_semear(client, {"leads": 4, "em_contato": 4, "proposta": 4, "fechado": 4})
|
|
64
|
+
corpo = client.get("/api/funnel").json()
|
|
65
|
+
assert corpo["gargalo"] is None
|