@danhachuel/leadmaster 0.1.20 → 0.1.22
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 +46 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +64 -3
- 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 +1 -0
- package/backend/services/storage.py +11 -6
- 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_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_funnel_routes.py +65 -0
- package/backend/tests/test_webhook_routes.py +83 -0
- package/frontend/dist/assets/index-CX_h90mG.js +90 -0
- package/frontend/dist/assets/{index-BBxljeDe.css → index-ViDiQGJH.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-ErHj9z-h.js +0 -90
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,52 @@ 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.22] — 2026-11-09
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Funil de Prospecção (visão macro de conversão):**
|
|
45
|
+
- Rota `GET /api/funnel`: conta os leads por etapa do CRM (`Leads → Em
|
|
46
|
+
Contato → Proposta → Fechado`), calcula a taxa de conversão de cada
|
|
47
|
+
transição (`para/de × 100`, 1 decimal) e a queda percentual, e identifica
|
|
48
|
+
o **gargalo** — a etapa *origem* da maior queda (onde os leads param).
|
|
49
|
+
Resposta: `{etapas, conversoes, gargalo}`.
|
|
50
|
+
- Página `frontend/src/pages/Funil.tsx` (rota `/funil`, link **"Funil de
|
|
51
|
+
Prospecção"** no menu): **funil em CSS puro** com fatias de largura
|
|
52
|
+
decrescente (nome + quantidade por etapa), **cards de conversão** por
|
|
53
|
+
transição (verde ≥50%, âmbar ≥25%, vermelho abaixo), **alerta de gargalo**
|
|
54
|
+
em vermelho/laranja com "⚠️ Gargalo: muitos leads param em 'X'" e fatia
|
|
55
|
+
do gargalo destacada (von Restorff), zero filtros na tela (Lei de Hick) e
|
|
56
|
+
funil vazio com barra zerada + "Comece a prospectar para ver seu funil"
|
|
57
|
+
(Zeigarnik). Acesso aos dados via `api.funil()` no cliente padronizado.
|
|
58
|
+
- **4 novos testes pytest** (`backend/tests/test_funnel_routes.py`):
|
|
59
|
+
conversões + gargalo com distribuição [10,5,2,1] (taxas 50/40/50 → gargalo
|
|
60
|
+
"Em Contato"), gargalo na primeira etapa, funil vazio (taxas `null`,
|
|
61
|
+
gargalo `null`) e funil sem gargalo (quedas zero).
|
|
62
|
+
|
|
63
|
+
## [0.1.21] — 2026-11-09
|
|
64
|
+
|
|
65
|
+
### Adicionado
|
|
66
|
+
|
|
67
|
+
- **Módulo de Webhooks revisado conforme especificação:**
|
|
68
|
+
- `GET /api/webhook` agora retorna também **`ultimo_teste`** (registrado a cada
|
|
69
|
+
teste bem-sucedido no `config.json`).
|
|
70
|
+
- `PUT /api/webhook` e `POST /api/webhook/test` **validam a URL**: só
|
|
71
|
+
`http://`/`https://` são aceitos (400 com mensagem acionável).
|
|
72
|
+
- Novo evento **`campanha.iniciada`** — disparado quando uma campanha começa a
|
|
73
|
+
rodar; migração aditiva garante que ele apareça nas configurações existentes.
|
|
74
|
+
- Frontend `frontend/src/pages/Webhook.tsx` reformulado: eventos exibidos
|
|
75
|
+
diretamente e limpos (**Lead Criado**, **Campanha Iniciada**, **Campanha
|
|
76
|
+
Finalizada** — Lei de Hick), **prevenção de erros** (Nielsen): URL fora do
|
|
77
|
+
padrão http(s) bloqueia o salvamento e marca o input com **borda vermelha**,
|
|
78
|
+
botão **"Salvar Configurações"** em gradiente (Fitts) e **"Testar Webhook"**
|
|
79
|
+
em outline contrastante (von Restorff), toast de sucesso/erro no teste e card
|
|
80
|
+
**"Último teste realizado em: [data/hora]"**.
|
|
81
|
+
- **7 novos testes pytest** (`backend/tests/test_webhook_routes.py`): leitura do
|
|
82
|
+
padrão, salvamento de URL/eventos com persistência, URL inválida (400), teste
|
|
83
|
+
com sucesso (mock do `httpx.post`, registra `ultimo_teste`), teste com falha de
|
|
84
|
+
conexão sem quebrar (mock de `httpx.ConnectError`) e URL de teste inválida.
|
|
85
|
+
|
|
40
86
|
## [0.1.20] — 2026-11-09
|
|
41
87
|
|
|
42
88
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -602,6 +602,41 @@ def salvar_prompt_agente(agent_id: str, dados: dict):
|
|
|
602
602
|
return {"ok": True, "agent_id": agent_id, "prompt": agente["prompt"]}
|
|
603
603
|
|
|
604
604
|
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
# Funil de Prospecção (métricas de conversão do CRM)
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
ETAPAS_FUNIL = ["Leads", "Em Contato", "Proposta", "Fechado"]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@app.get("/api/funnel")
|
|
613
|
+
def funil_prospeccao():
|
|
614
|
+
"""Métricas de conversão do funil, calculadas a partir do leads.json."""
|
|
615
|
+
leads = storage.list_leads()
|
|
616
|
+
quantidades = [
|
|
617
|
+
sum(1 for l in leads if l.get("etapa") == etapa)
|
|
618
|
+
for etapa in ("leads", "em_contato", "proposta", "fechado")
|
|
619
|
+
]
|
|
620
|
+
etapas = [{"nome": nome, "quantidade": qtd} for nome, qtd in zip(ETAPAS_FUNIL, quantidades)]
|
|
621
|
+
|
|
622
|
+
conversoes = []
|
|
623
|
+
for i in range(len(ETAPAS_FUNIL) - 1):
|
|
624
|
+
de, para = ETAPAS_FUNIL[i], ETAPAS_FUNIL[i + 1]
|
|
625
|
+
if quantidades[i] > 0:
|
|
626
|
+
taxa = round(quantidades[i + 1] / quantidades[i] * 100, 1)
|
|
627
|
+
queda = round(100 - taxa, 1)
|
|
628
|
+
else:
|
|
629
|
+
taxa, queda = None, None
|
|
630
|
+
conversoes.append({"de": de, "para": para, "taxa": taxa, "queda": queda})
|
|
631
|
+
|
|
632
|
+
# Gargalo: etapa ORIGEM da maior queda entre etapas consecutivas — é onde os
|
|
633
|
+
# leads param (ex.: muitos entram em "Em Contato" e não seguem).
|
|
634
|
+
quedas = [c for c in conversoes if c["queda"] is not None and c["queda"] > 0]
|
|
635
|
+
gargalo = max(quedas, key=lambda c: c["queda"])["de"] if quedas else None
|
|
636
|
+
|
|
637
|
+
return {"etapas": etapas, "conversoes": conversoes, "gargalo": gargalo}
|
|
638
|
+
|
|
639
|
+
|
|
605
640
|
# ---------------------------------------------------------------------------
|
|
606
641
|
# Configuração e integrações
|
|
607
642
|
# ---------------------------------------------------------------------------
|
|
@@ -712,21 +747,47 @@ def atendimento_chat(dados: ChatIn):
|
|
|
712
747
|
|
|
713
748
|
@app.get("/api/webhook")
|
|
714
749
|
def obter_webhook():
|
|
715
|
-
|
|
750
|
+
webhook = storage.get_config().get("webhook", {})
|
|
751
|
+
return {
|
|
752
|
+
"url": webhook.get("url", ""),
|
|
753
|
+
"eventos": webhook.get("eventos", []),
|
|
754
|
+
"ultimo_teste": webhook.get("ultimo_teste"),
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _url_valida(url: str) -> bool:
|
|
759
|
+
return isinstance(url, str) and (url.startswith("http://") or url.startswith("https://"))
|
|
716
760
|
|
|
717
761
|
|
|
718
762
|
@app.put("/api/webhook")
|
|
719
763
|
def salvar_webhook(patch: dict):
|
|
720
764
|
config = storage.get_config()
|
|
721
765
|
webhook = config.get("webhook", {})
|
|
766
|
+
url = patch.get("url", webhook.get("url", ""))
|
|
767
|
+
if url and not _url_valida(url):
|
|
768
|
+
raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
|
|
722
769
|
webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
|
|
723
770
|
storage.update_config({"webhook": webhook})
|
|
724
|
-
return
|
|
771
|
+
return {
|
|
772
|
+
"url": webhook.get("url", ""),
|
|
773
|
+
"eventos": webhook.get("eventos", []),
|
|
774
|
+
"ultimo_teste": webhook.get("ultimo_teste"),
|
|
775
|
+
}
|
|
725
776
|
|
|
726
777
|
|
|
727
778
|
@app.post("/api/webhook/test")
|
|
728
779
|
def testar_webhook(dados: WebhookTestIn):
|
|
729
|
-
|
|
780
|
+
if dados.url and not _url_valida(dados.url):
|
|
781
|
+
raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
|
|
782
|
+
resultado = agents.testar_webhook(dados.url)
|
|
783
|
+
if resultado.get("ok"):
|
|
784
|
+
# Registra o último teste bem-sucedido no config.json.
|
|
785
|
+
config = storage.get_config()
|
|
786
|
+
webhook = config.get("webhook", {})
|
|
787
|
+
webhook["ultimo_teste"] = storage.now_iso()
|
|
788
|
+
storage.update_config({"webhook": webhook})
|
|
789
|
+
resultado["ultimo_teste"] = webhook["ultimo_teste"]
|
|
790
|
+
return resultado
|
|
730
791
|
|
|
731
792
|
|
|
732
793
|
# ---------------------------------------------------------------------------
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -100,6 +100,7 @@ def iniciar_campanha(campaign_id: str) -> dict:
|
|
|
100
100
|
campaign.setdefault("erros", 0)
|
|
101
101
|
campaign.setdefault("eventos", [])
|
|
102
102
|
storage.upsert_campaign(campaign)
|
|
103
|
+
disparar_webhook("campanha.iniciada", {"campanha_id": campaign_id, "nome": campaign["nome"]})
|
|
103
104
|
|
|
104
105
|
threading.Thread(target=_executar_campanha, args=(campaign_id,), daemon=True).start()
|
|
105
106
|
return {"ok": True, "mensagem": "Campanha iniciada."}
|
|
@@ -135,7 +135,7 @@ DEFAULT_CONFIG = {
|
|
|
135
135
|
},
|
|
136
136
|
"webhook": {
|
|
137
137
|
"url": "",
|
|
138
|
-
"eventos": ["lead.criado", "
|
|
138
|
+
"eventos": ["lead.criado", "campanha.iniciada", "campanha.concluida"],
|
|
139
139
|
},
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -189,11 +189,16 @@ def _limpar_deprecated(config: dict) -> dict:
|
|
|
189
189
|
mudou = True
|
|
190
190
|
# Evento de webhook do recurso removido.
|
|
191
191
|
webhook = config.get("webhook")
|
|
192
|
-
if isinstance(webhook, dict)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
192
|
+
if isinstance(webhook, dict):
|
|
193
|
+
if isinstance(webhook.get("eventos"), list):
|
|
194
|
+
eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
|
|
195
|
+
# Migração aditiva: o evento "campanha.iniciada" passou a existir
|
|
196
|
+
# (0.1.21) — garantimos que configs antigas também o ofereçam.
|
|
197
|
+
if "campanha.iniciada" not in eventos:
|
|
198
|
+
eventos.append("campanha.iniciada")
|
|
199
|
+
if eventos != webhook["eventos"]:
|
|
200
|
+
webhook["eventos"] = eventos
|
|
201
|
+
mudou = True
|
|
197
202
|
# Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
|
|
198
203
|
pipeline_path = storage_dir() / PIPELINE_FILE
|
|
199
204
|
if pipeline_path.exists():
|
|
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 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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Testes das rotas de Webhook (backend/main.py).
|
|
2
|
+
|
|
3
|
+
GET/PUT /api/webhook (com validação de URL) e POST /api/webhook/test com o
|
|
4
|
+
HTTP externo mockado — o endpoint de teste nunca quebra com URL inválida ou
|
|
5
|
+
indisponível. HOME isolado em diretório temporário.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
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 test_obter_padrao(client):
|
|
25
|
+
r = client.get("/api/webhook")
|
|
26
|
+
assert r.status_code == 200
|
|
27
|
+
corpo = r.json()
|
|
28
|
+
assert corpo["url"] == ""
|
|
29
|
+
assert "lead.criado" in corpo["eventos"]
|
|
30
|
+
assert corpo["ultimo_teste"] is None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_salvar_url_e_eventos(client):
|
|
34
|
+
r = client.put(
|
|
35
|
+
"/api/webhook",
|
|
36
|
+
json={"url": "https://n8n.exemplo.com/webhook/abc", "eventos": ["lead.criado"]},
|
|
37
|
+
)
|
|
38
|
+
assert r.status_code == 200
|
|
39
|
+
corpo = r.json()
|
|
40
|
+
assert corpo["url"] == "https://n8n.exemplo.com/webhook/abc"
|
|
41
|
+
assert corpo["eventos"] == ["lead.criado"]
|
|
42
|
+
# Persistido no config.json
|
|
43
|
+
salvo = client.get("/api/webhook").json()
|
|
44
|
+
assert salvo["url"] == "https://n8n.exemplo.com/webhook/abc"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_salvar_url_invalida_400(client):
|
|
48
|
+
r = client.put("/api/webhook", json={"url": "nao-e-uma-url"})
|
|
49
|
+
assert r.status_code == 400
|
|
50
|
+
assert "http" in r.json()["detail"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resposta_ok(*_args, **_kwargs):
|
|
54
|
+
return httpx.Response(200, json={"ok": True})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_testar_webhook_sucesso(client):
|
|
58
|
+
client.put("/api/webhook", json={"url": "https://exemplo.com/hook"})
|
|
59
|
+
with patch.object(main.agents.httpx, "post", side_effect=_resposta_ok):
|
|
60
|
+
r = client.post("/api/webhook/test", json={"url": "https://exemplo.com/hook"})
|
|
61
|
+
assert r.status_code == 200
|
|
62
|
+
corpo = r.json()
|
|
63
|
+
assert corpo["ok"] is True
|
|
64
|
+
assert corpo["status_http"] == 200
|
|
65
|
+
assert corpo["ultimo_teste"] # registrado
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_testar_webhook_conexao_falha_nao_quebra(client):
|
|
69
|
+
# URL indisponível: retorna erro amigável (não 500).
|
|
70
|
+
with patch.object(main.agents.httpx, "post", side_effect=httpx.ConnectError("sem rede")):
|
|
71
|
+
r = client.post("/api/webhook/test", json={"url": "https://inexistente.invalid/hook"})
|
|
72
|
+
assert r.status_code == 200
|
|
73
|
+
assert r.json()["ok"] is False
|
|
74
|
+
assert "mensagem" in r.json()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_testar_webhook_url_invalida_400(client):
|
|
78
|
+
# Passa o min_length do modelo, mas falha no esquema http(s) → 400.
|
|
79
|
+
r = client.post("/api/webhook/test", json={"url": "nao-http-url.com/webhook"})
|
|
80
|
+
assert r.status_code == 400
|
|
81
|
+
# URL curta demais falha na validação do modelo.
|
|
82
|
+
r2 = client.post("/api/webhook/test", json={"url": "xyz"})
|
|
83
|
+
assert r2.status_code == 422
|