@danhachuel/leadmaster 0.1.19 → 0.1.21
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 +49 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +79 -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 +55 -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_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_agents_routes.py +82 -0
- package/backend/tests/test_webhook_routes.py +83 -0
- package/frontend/dist/assets/{index-ib1LXo9v.css → index-Bn20U7qZ.css} +1 -1
- package/frontend/dist/assets/index-CC4fz5Tw.js +90 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-CXuYUAoQ.js +0 -90
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,55 @@ 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.21] — 2026-11-09
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Módulo de Webhooks revisado conforme especificação:**
|
|
45
|
+
- `GET /api/webhook` agora retorna também **`ultimo_teste`** (registrado a cada
|
|
46
|
+
teste bem-sucedido no `config.json`).
|
|
47
|
+
- `PUT /api/webhook` e `POST /api/webhook/test` **validam a URL**: só
|
|
48
|
+
`http://`/`https://` são aceitos (400 com mensagem acionável).
|
|
49
|
+
- Novo evento **`campanha.iniciada`** — disparado quando uma campanha começa a
|
|
50
|
+
rodar; migração aditiva garante que ele apareça nas configurações existentes.
|
|
51
|
+
- Frontend `frontend/src/pages/Webhook.tsx` reformulado: eventos exibidos
|
|
52
|
+
diretamente e limpos (**Lead Criado**, **Campanha Iniciada**, **Campanha
|
|
53
|
+
Finalizada** — Lei de Hick), **prevenção de erros** (Nielsen): URL fora do
|
|
54
|
+
padrão http(s) bloqueia o salvamento e marca o input com **borda vermelha**,
|
|
55
|
+
botão **"Salvar Configurações"** em gradiente (Fitts) e **"Testar Webhook"**
|
|
56
|
+
em outline contrastante (von Restorff), toast de sucesso/erro no teste e card
|
|
57
|
+
**"Último teste realizado em: [data/hora]"**.
|
|
58
|
+
- **7 novos testes pytest** (`backend/tests/test_webhook_routes.py`): leitura do
|
|
59
|
+
padrão, salvamento de URL/eventos com persistência, URL inválida (400), teste
|
|
60
|
+
com sucesso (mock do `httpx.post`, registra `ultimo_teste`), teste com falha de
|
|
61
|
+
conexão sem quebrar (mock de `httpx.ConnectError`) e URL de teste inválida.
|
|
62
|
+
|
|
63
|
+
## [0.1.20] — 2026-11-09
|
|
64
|
+
|
|
65
|
+
### Adicionado
|
|
66
|
+
|
|
67
|
+
- **Módulo de Agentes de IA (controle e configuração):**
|
|
68
|
+
- Backend: `GET /api/agents` (status + prompt de cada agente, e flag
|
|
69
|
+
`llm_configurado`), `PUT /api/agents/{agent_id}/status` (ativo/inativo) e
|
|
70
|
+
`PUT /api/agents/{agent_id}/prompt` — persistência no novo arquivo
|
|
71
|
+
`storage/agents.json` (seed com prompts padrão em pt-BR).
|
|
72
|
+
- **Regra de bloqueio:** ativar o **Agente de Disparo** sem a chave do LLM
|
|
73
|
+
configurada retorna **400** com mensagem acionável ("Configure a API Key
|
|
74
|
+
do LLM em Ferramentas..."); desativar e o Atendimento são sempre
|
|
75
|
+
permitidos.
|
|
76
|
+
- Frontend: `frontend/src/pages/Agentes.tsx` reformulado em **dois cards
|
|
77
|
+
lado a lado** (Disparo / Atendimento): badge de status, **toggle grande**
|
|
78
|
+
(w-16 h-9 — Fitts), **pulso animado verde-esmeralda** e badge esmeralda
|
|
79
|
+
quando ativo (von Restorff + Zeigarnik), textarea do **Prompt do Sistema**
|
|
80
|
+
+ botão "Salvar Prompt", toast específico ("Configure a API Key na aba
|
|
81
|
+
Ferramentas primeiro.") quando o bloqueio 400 ocorre, e **accordion** com
|
|
82
|
+
as configurações avançadas (modelo LLM/temperatura; modo texto/voz do
|
|
83
|
+
atendimento) — Lei de Hick.
|
|
84
|
+
- **8 novos testes pytest** (`backend/tests/test_agents_routes.py`): listagem,
|
|
85
|
+
bloqueio do disparo sem LLM (400), ativação do atendimento, ativação do
|
|
86
|
+
disparo com LLM, desativação, status inválido/agente desconhecido (400/404),
|
|
87
|
+
persistência do prompt e erros de prompt.
|
|
88
|
+
|
|
40
89
|
## [0.1.19] — 2026-11-09
|
|
41
90
|
|
|
42
91
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -552,6 +552,56 @@ def buscar_cnpj(dados: CnpjSearchIn):
|
|
|
552
552
|
raise HTTPException(502, str(e))
|
|
553
553
|
|
|
554
554
|
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# Agentes de IA (status + prompt do sistema)
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
@app.get("/api/agents")
|
|
560
|
+
def listar_agentes():
|
|
561
|
+
"""Status e prompt de cada agente (disparo / atendimento)."""
|
|
562
|
+
agentes = storage.get_agents()
|
|
563
|
+
llm_ok = llm.configurado()
|
|
564
|
+
return {
|
|
565
|
+
"llm_configurado": llm_ok,
|
|
566
|
+
"agentes": {
|
|
567
|
+
agent_id: {
|
|
568
|
+
"status": agentes.get(agent_id, {}).get("status", "inativo"),
|
|
569
|
+
"prompt": agentes.get(agent_id, {}).get("prompt", ""),
|
|
570
|
+
}
|
|
571
|
+
for agent_id in storage.AGENTE_IDS
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@app.put("/api/agents/{agent_id}/status")
|
|
577
|
+
def alterar_status_agente(agent_id: str, dados: dict):
|
|
578
|
+
"""Liga/desliga um agente. Ativar o de disparo exige LLM configurada."""
|
|
579
|
+
if agent_id not in storage.AGENTE_IDS:
|
|
580
|
+
raise HTTPException(404, f"Agente desconhecido: {agent_id}. Use: disparo ou atendimento.")
|
|
581
|
+
status = (dados.get("status") or "").strip().lower()
|
|
582
|
+
if status not in ("ativo", "inativo"):
|
|
583
|
+
raise HTTPException(400, 'Status inválido. Use: ativo ou inativo.')
|
|
584
|
+
if status == "ativo" and agent_id == "disparo" and not llm.configurado():
|
|
585
|
+
raise HTTPException(
|
|
586
|
+
400,
|
|
587
|
+
"Configure a API Key do LLM em Ferramentas antes de ativar o Agente de Disparo.",
|
|
588
|
+
)
|
|
589
|
+
agente = storage.save_agent(agent_id, {"status": status})
|
|
590
|
+
return {"ok": True, "agent_id": agent_id, "status": agente["status"]}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.put("/api/agents/{agent_id}/prompt")
|
|
594
|
+
def salvar_prompt_agente(agent_id: str, dados: dict):
|
|
595
|
+
"""Salva o prompt do sistema do agente em agents.json."""
|
|
596
|
+
if agent_id not in storage.AGENTE_IDS:
|
|
597
|
+
raise HTTPException(404, f"Agente desconhecido: {agent_id}. Use: disparo ou atendimento.")
|
|
598
|
+
prompt = dados.get("prompt")
|
|
599
|
+
if prompt is None:
|
|
600
|
+
raise HTTPException(400, "Informe o prompt do sistema.")
|
|
601
|
+
agente = storage.save_agent(agent_id, {"prompt": str(prompt)})
|
|
602
|
+
return {"ok": True, "agent_id": agent_id, "prompt": agente["prompt"]}
|
|
603
|
+
|
|
604
|
+
|
|
555
605
|
# ---------------------------------------------------------------------------
|
|
556
606
|
# Configuração e integrações
|
|
557
607
|
# ---------------------------------------------------------------------------
|
|
@@ -662,21 +712,47 @@ def atendimento_chat(dados: ChatIn):
|
|
|
662
712
|
|
|
663
713
|
@app.get("/api/webhook")
|
|
664
714
|
def obter_webhook():
|
|
665
|
-
|
|
715
|
+
webhook = storage.get_config().get("webhook", {})
|
|
716
|
+
return {
|
|
717
|
+
"url": webhook.get("url", ""),
|
|
718
|
+
"eventos": webhook.get("eventos", []),
|
|
719
|
+
"ultimo_teste": webhook.get("ultimo_teste"),
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _url_valida(url: str) -> bool:
|
|
724
|
+
return isinstance(url, str) and (url.startswith("http://") or url.startswith("https://"))
|
|
666
725
|
|
|
667
726
|
|
|
668
727
|
@app.put("/api/webhook")
|
|
669
728
|
def salvar_webhook(patch: dict):
|
|
670
729
|
config = storage.get_config()
|
|
671
730
|
webhook = config.get("webhook", {})
|
|
731
|
+
url = patch.get("url", webhook.get("url", ""))
|
|
732
|
+
if url and not _url_valida(url):
|
|
733
|
+
raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
|
|
672
734
|
webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
|
|
673
735
|
storage.update_config({"webhook": webhook})
|
|
674
|
-
return
|
|
736
|
+
return {
|
|
737
|
+
"url": webhook.get("url", ""),
|
|
738
|
+
"eventos": webhook.get("eventos", []),
|
|
739
|
+
"ultimo_teste": webhook.get("ultimo_teste"),
|
|
740
|
+
}
|
|
675
741
|
|
|
676
742
|
|
|
677
743
|
@app.post("/api/webhook/test")
|
|
678
744
|
def testar_webhook(dados: WebhookTestIn):
|
|
679
|
-
|
|
745
|
+
if dados.url and not _url_valida(dados.url):
|
|
746
|
+
raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
|
|
747
|
+
resultado = agents.testar_webhook(dados.url)
|
|
748
|
+
if resultado.get("ok"):
|
|
749
|
+
# Registra o último teste bem-sucedido no config.json.
|
|
750
|
+
config = storage.get_config()
|
|
751
|
+
webhook = config.get("webhook", {})
|
|
752
|
+
webhook["ultimo_teste"] = storage.now_iso()
|
|
753
|
+
storage.update_config({"webhook": webhook})
|
|
754
|
+
resultado["ultimo_teste"] = webhook["ultimo_teste"]
|
|
755
|
+
return resultado
|
|
680
756
|
|
|
681
757
|
|
|
682
758
|
# ---------------------------------------------------------------------------
|
|
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."}
|
|
@@ -20,6 +20,7 @@ LEADS_FILE = "leads.json"
|
|
|
20
20
|
CAMPAIGNS_FILE = "campaigns.json"
|
|
21
21
|
PIPELINE_FILE = "pipeline.json"
|
|
22
22
|
CONFIG_FILE = "config.json"
|
|
23
|
+
AGENTS_FILE = "agents.json"
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
# ---------------------------------------------------------------------------
|
|
@@ -134,7 +135,7 @@ DEFAULT_CONFIG = {
|
|
|
134
135
|
},
|
|
135
136
|
"webhook": {
|
|
136
137
|
"url": "",
|
|
137
|
-
"eventos": ["lead.criado", "
|
|
138
|
+
"eventos": ["lead.criado", "campanha.iniciada", "campanha.concluida"],
|
|
138
139
|
},
|
|
139
140
|
}
|
|
140
141
|
|
|
@@ -188,11 +189,16 @@ def _limpar_deprecated(config: dict) -> dict:
|
|
|
188
189
|
mudou = True
|
|
189
190
|
# Evento de webhook do recurso removido.
|
|
190
191
|
webhook = config.get("webhook")
|
|
191
|
-
if isinstance(webhook, dict)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
196
202
|
# Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
|
|
197
203
|
pipeline_path = storage_dir() / PIPELINE_FILE
|
|
198
204
|
if pipeline_path.exists():
|
|
@@ -352,6 +358,47 @@ def delete_campaign(campaign_id: str) -> bool:
|
|
|
352
358
|
return True
|
|
353
359
|
|
|
354
360
|
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Agentes de IA (status e prompt do sistema)
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
DEFAULT_AGENTS = {
|
|
366
|
+
"disparo": {
|
|
367
|
+
"status": "inativo",
|
|
368
|
+
"prompt": (
|
|
369
|
+
"Você é um especialista em prospecção consultiva. Escreva mensagens curtas, "
|
|
370
|
+
"educadas e personalizadas, sempre citando a empresa do lead. Nunca use "
|
|
371
|
+
"linguagem agressiva ou spam."
|
|
372
|
+
),
|
|
373
|
+
},
|
|
374
|
+
"atendimento": {
|
|
375
|
+
"status": "inativo",
|
|
376
|
+
"prompt": (
|
|
377
|
+
"Você é o assistente de atendimento do LeadMaster. Responda de forma clara e "
|
|
378
|
+
"objetiva em português do Brasil, encaminhe dúvidas comerciais para o "
|
|
379
|
+
"responsável quando necessário."
|
|
380
|
+
),
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
AGENTE_IDS = ("disparo", "atendimento")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def get_agents() -> dict:
|
|
388
|
+
return _deep_merge(DEFAULT_AGENTS, _read(AGENTS_FILE, {}))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def save_agent(agent_id: str, dados: dict) -> dict:
|
|
392
|
+
"""Atualiza campos de um agente (status/prompt) e persiste em agents.json."""
|
|
393
|
+
with LOCK:
|
|
394
|
+
agents = get_agents()
|
|
395
|
+
if agent_id not in agents:
|
|
396
|
+
agents[agent_id] = {"status": "inativo", "prompt": ""}
|
|
397
|
+
agents[agent_id].update({k: v for k, v in dados.items() if k in ("status", "prompt")})
|
|
398
|
+
_write(AGENTS_FILE, agents)
|
|
399
|
+
return agents[agent_id]
|
|
400
|
+
|
|
401
|
+
|
|
355
402
|
# ---------------------------------------------------------------------------
|
|
356
403
|
# Seeds (primeira execução)
|
|
357
404
|
# ---------------------------------------------------------------------------
|
|
@@ -433,6 +480,8 @@ def ensure_seeds() -> None:
|
|
|
433
480
|
_write(CAMPAIGNS_FILE, _seed_campaigns())
|
|
434
481
|
if not (d / CONFIG_FILE).exists():
|
|
435
482
|
_write(CONFIG_FILE, DEFAULT_CONFIG)
|
|
483
|
+
if not (d / AGENTS_FILE).exists():
|
|
484
|
+
_write(AGENTS_FILE, DEFAULT_AGENTS)
|
|
436
485
|
|
|
437
486
|
|
|
438
487
|
def limpar_seeds_demo() -> None:
|
|
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,82 @@
|
|
|
1
|
+
"""Testes do controle dos Agentes de IA (backend/main.py).
|
|
2
|
+
|
|
3
|
+
GET /api/agents, PUT /api/agents/{id}/status (com bloqueio do disparo sem
|
|
4
|
+
LLM configurada) e PUT /api/agents/{id}/prompt. Home isolado em tmp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
import main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def client(tmp_path, monkeypatch):
|
|
15
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
16
|
+
with TestClient(main.app) as c:
|
|
17
|
+
yield c
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_listar_agentes(client):
|
|
21
|
+
r = client.get("/api/agents")
|
|
22
|
+
assert r.status_code == 200
|
|
23
|
+
corpo = r.json()
|
|
24
|
+
assert "disparo" in corpo["agentes"] and "atendimento" in corpo["agentes"]
|
|
25
|
+
assert corpo["agentes"]["disparo"]["status"] == "inativo"
|
|
26
|
+
assert corpo["llm_configurado"] is False # seed sem chave de LLM
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_ativar_disparo_sem_llm_bloqueia(client):
|
|
30
|
+
r = client.put("/api/agents/disparo/status", json={"status": "ativo"})
|
|
31
|
+
assert r.status_code == 400
|
|
32
|
+
assert "API Key" in r.json()["detail"]
|
|
33
|
+
# Continua inativo
|
|
34
|
+
estado = client.get("/api/agents").json()["agentes"]["disparo"]["status"]
|
|
35
|
+
assert estado == "inativo"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_ativar_atendimento_permitido_sem_llm(client):
|
|
39
|
+
r = client.put("/api/agents/atendimento/status", json={"status": "ativo"})
|
|
40
|
+
assert r.status_code == 200
|
|
41
|
+
assert r.json()["status"] == "ativo"
|
|
42
|
+
# Persistido em agents.json
|
|
43
|
+
estado = client.get("/api/agents").json()["agentes"]["atendimento"]["status"]
|
|
44
|
+
assert estado == "ativo"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_ativar_disparo_com_llm(client):
|
|
48
|
+
# Configura a chave do LLM e ativa o disparo.
|
|
49
|
+
client.put("/api/config", json={"llm": {"api_key": "sk-teste-123"}})
|
|
50
|
+
r = client.put("/api/agents/disparo/status", json={"status": "ativo"})
|
|
51
|
+
assert r.status_code == 200
|
|
52
|
+
assert r.json()["status"] == "ativo"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_desativar_agente(client):
|
|
56
|
+
client.put("/api/agents/atendimento/status", json={"status": "ativo"})
|
|
57
|
+
r = client.put("/api/agents/atendimento/status", json={"status": "inativo"})
|
|
58
|
+
assert r.status_code == 200
|
|
59
|
+
assert r.json()["status"] == "inativo"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_status_invalido_e_agente_desconhecido(client):
|
|
63
|
+
r = client.put("/api/agents/disparo/status", json={"status": "talvez"})
|
|
64
|
+
assert r.status_code == 400
|
|
65
|
+
r2 = client.put("/api/agents/inexistente/status", json={"status": "ativo"})
|
|
66
|
+
assert r2.status_code == 404
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_salvar_prompt_persiste(client):
|
|
70
|
+
prompt = "Você é um agente de prospecção especialista em SaaS."
|
|
71
|
+
r = client.put("/api/agents/disparo/prompt", json={"prompt": prompt})
|
|
72
|
+
assert r.status_code == 200
|
|
73
|
+
assert r.json()["prompt"] == prompt
|
|
74
|
+
salvo = client.get("/api/agents").json()["agentes"]["disparo"]["prompt"]
|
|
75
|
+
assert salvo == prompt
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_salvar_prompt_agente_desconhecido(client):
|
|
79
|
+
r = client.put("/api/agents/inexistente/prompt", json={"prompt": "x"})
|
|
80
|
+
assert r.status_code == 404
|
|
81
|
+
r2 = client.put("/api/agents/disparo/prompt", json={})
|
|
82
|
+
assert r2.status_code == 400
|
|
@@ -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
|