@danhachuel/leadmaster 0.1.18 → 0.1.20
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 +65 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +92 -2
- 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/storage.py +44 -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_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/test_agents_routes.py +82 -0
- package/backend/tests/test_config.py +90 -0
- package/frontend/dist/assets/{index-BKo_OphM.css → index-BBxljeDe.css} +1 -1
- package/frontend/dist/assets/index-ErHj9z-h.js +90 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DFVTLg-7.js +0 -90
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,71 @@ 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.20] — 2026-11-09
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Módulo de Agentes de IA (controle e configuração):**
|
|
45
|
+
- Backend: `GET /api/agents` (status + prompt de cada agente, e flag
|
|
46
|
+
`llm_configurado`), `PUT /api/agents/{agent_id}/status` (ativo/inativo) e
|
|
47
|
+
`PUT /api/agents/{agent_id}/prompt` — persistência no novo arquivo
|
|
48
|
+
`storage/agents.json` (seed com prompts padrão em pt-BR).
|
|
49
|
+
- **Regra de bloqueio:** ativar o **Agente de Disparo** sem a chave do LLM
|
|
50
|
+
configurada retorna **400** com mensagem acionável ("Configure a API Key
|
|
51
|
+
do LLM em Ferramentas..."); desativar e o Atendimento são sempre
|
|
52
|
+
permitidos.
|
|
53
|
+
- Frontend: `frontend/src/pages/Agentes.tsx` reformulado em **dois cards
|
|
54
|
+
lado a lado** (Disparo / Atendimento): badge de status, **toggle grande**
|
|
55
|
+
(w-16 h-9 — Fitts), **pulso animado verde-esmeralda** e badge esmeralda
|
|
56
|
+
quando ativo (von Restorff + Zeigarnik), textarea do **Prompt do Sistema**
|
|
57
|
+
+ botão "Salvar Prompt", toast específico ("Configure a API Key na aba
|
|
58
|
+
Ferramentas primeiro.") quando o bloqueio 400 ocorre, e **accordion** com
|
|
59
|
+
as configurações avançadas (modelo LLM/temperatura; modo texto/voz do
|
|
60
|
+
atendimento) — Lei de Hick.
|
|
61
|
+
- **8 novos testes pytest** (`backend/tests/test_agents_routes.py`): listagem,
|
|
62
|
+
bloqueio do disparo sem LLM (400), ativação do atendimento, ativação do
|
|
63
|
+
disparo com LLM, desativação, status inválido/agente desconhecido (400/404),
|
|
64
|
+
persistência do prompt e erros de prompt.
|
|
65
|
+
|
|
66
|
+
## [0.1.19] — 2026-11-09
|
|
67
|
+
|
|
68
|
+
### Adicionado
|
|
69
|
+
|
|
70
|
+
- **`POST /api/config`** — alias do `PUT /api/config` (frontend continua usando
|
|
71
|
+
PUT; clientes externos podem usar POST, como na especificação da tela de
|
|
72
|
+
Ferramentas).
|
|
73
|
+
- **Prevenção de erros (Nielsen) — trim automático:** todo texto do payload de
|
|
74
|
+
configuração recebe `strip()` antes de gravar (recursivo, inclui arrays e
|
|
75
|
+
objetos aninhados) — espaço acidental em API Key ou URL não vira falha
|
|
76
|
+
misteriosa depois.
|
|
77
|
+
- **Validação de payload (422):** seção que é objeto no padrão (`llm`,
|
|
78
|
+
`extratores`, `integracoes`, `agentes`, `webhook`, `perfil`) recebendo valor
|
|
79
|
+
escalar é rejeitada com mensagem acionável, em vez de corromper o
|
|
80
|
+
`config.json` e a UI.
|
|
81
|
+
- **Ícone de olho no campo de API Key** (`CampoSecreto` em Ferramentas):
|
|
82
|
+
alterna visibilidade da chave (von Restorff) — conferir/colar sem apagar o
|
|
83
|
+
campo. Continua com placeholder mascarado e aviso "deixe vazio para manter".
|
|
84
|
+
- **Testes (`backend/tests/test_config.py`)** — 7 novos testes de
|
|
85
|
+
`GET/POST/PUT /api/config`: estrutura padrão, segredos nunca expostos ( GET
|
|
86
|
+
devolve vazio + `_configurado` e o corpo cru não contém a chave),
|
|
87
|
+
persistência + trim, segredo vazio não apaga o atual, PUT funcional,
|
|
88
|
+
payload inválido → 422 e trim em arrays aninhados.
|
|
89
|
+
Suíte completa: **51 passed**.
|
|
90
|
+
|
|
91
|
+
### Auditoria
|
|
92
|
+
|
|
93
|
+
- **Tela de Ferramentas:** auditada contra a especificação — a página
|
|
94
|
+
(`frontend/src/pages/Ferramentas.tsx`, rota `/ferramentas` já no menu) já
|
|
95
|
+
cobria LLM (provedor/base URL/modelo/API Key), Extratores (Maps, igmapper,
|
|
96
|
+
templates em accordion, CNPJ/Scrapling), Perfil e o botão gradiente "Salvar
|
|
97
|
+
configurações" com toast. Gaps entregues acima: POST, trim, validação, olho.
|
|
98
|
+
- **Integrações:** a seção cobre **OpenOutreach** (disparo) e **pipecat**
|
|
99
|
+
(atendimento por voz), que são as integrações do produto. **YouTube/Meta não
|
|
100
|
+
foram implementados** — o AGENTS.md (§1) é explícito: produção/upload de
|
|
101
|
+
vídeos **não faz parte** do LeadMaster (o storage já remove chaves desses
|
|
102
|
+
sistemas em migração). Se o pipeline de vídeos entrar no roadmap um dia, será
|
|
103
|
+
como novo módulo com decisão de produto registrada.
|
|
104
|
+
|
|
40
105
|
## [0.1.18] — 2026-11-09
|
|
41
106
|
|
|
42
107
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -552,19 +552,109 @@ 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
|
# ---------------------------------------------------------------------------
|
|
558
608
|
|
|
559
609
|
@app.get("/api/config")
|
|
560
610
|
def obter_config():
|
|
611
|
+
"""Configuração local (config.json) — segredos NUNCA são expostos (regra do
|
|
612
|
+
AGENTS.md): campo devolvido vazio + flag `<campo>_configurado`."""
|
|
613
|
+
return storage.redact_config(storage.get_config())
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _validar_patch(patch: dict) -> None:
|
|
617
|
+
"""Rejeita payload que quebraria a estrutura da config.
|
|
618
|
+
|
|
619
|
+
Seções que são objetos no padrão (llm, extratores, integracoes...) precisam
|
|
620
|
+
continuar objetos — um valor escalar no lugar corromperia o arquivo e a UI.
|
|
621
|
+
Nenhum campo é obrigatório: o app roda com os defaults e valida credenciais
|
|
622
|
+
apenas no momento da ação (regra do AGENTS.md — sem travas de pré-registo).
|
|
623
|
+
"""
|
|
624
|
+
for chave, valor in patch.items():
|
|
625
|
+
padrao = storage.DEFAULT_CONFIG.get(chave)
|
|
626
|
+
if isinstance(padrao, dict) and not isinstance(valor, dict):
|
|
627
|
+
raise HTTPException(422, f"O campo '{chave}' deve ser um objeto com os subcampos.")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _aparar_textos(valor):
|
|
631
|
+
"""Nielsen (prevenção de erros): trim automático em textos antes de salvar —
|
|
632
|
+
espaços acidentais no início/fim (copiar-e-colar de chaves) não quebram a
|
|
633
|
+
credencial nem disparam falsos "inválido" depois."""
|
|
634
|
+
if isinstance(valor, str):
|
|
635
|
+
return valor.strip()
|
|
636
|
+
if isinstance(valor, dict):
|
|
637
|
+
return {k: _aparar_textos(v) for k, v in valor.items()}
|
|
638
|
+
if isinstance(valor, list):
|
|
639
|
+
return [_aparar_textos(v) for v in valor]
|
|
640
|
+
return valor
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _salvar_config(patch: dict):
|
|
644
|
+
_validar_patch(patch)
|
|
645
|
+
storage.update_config(_aparar_textos(patch))
|
|
561
646
|
return storage.redact_config(storage.get_config())
|
|
562
647
|
|
|
563
648
|
|
|
564
649
|
@app.put("/api/config")
|
|
650
|
+
@app.post("/api/config")
|
|
565
651
|
def salvar_config(patch: dict):
|
|
566
|
-
|
|
567
|
-
|
|
652
|
+
"""Salva a configuração em config.json (merge profundo, atômico, sob lock).
|
|
653
|
+
|
|
654
|
+
POST e PUT são equivalentes (o frontend usa PUT). Segredos vazios mantêm o
|
|
655
|
+
valor atual; textos recebem trim automático. Devolve a config mascarada.
|
|
656
|
+
"""
|
|
657
|
+
return _salvar_config(patch)
|
|
568
658
|
|
|
569
659
|
|
|
570
660
|
@app.post("/api/config/test/llm")
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
# ---------------------------------------------------------------------------
|
|
@@ -352,6 +353,47 @@ def delete_campaign(campaign_id: str) -> bool:
|
|
|
352
353
|
return True
|
|
353
354
|
|
|
354
355
|
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Agentes de IA (status e prompt do sistema)
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
DEFAULT_AGENTS = {
|
|
361
|
+
"disparo": {
|
|
362
|
+
"status": "inativo",
|
|
363
|
+
"prompt": (
|
|
364
|
+
"Você é um especialista em prospecção consultiva. Escreva mensagens curtas, "
|
|
365
|
+
"educadas e personalizadas, sempre citando a empresa do lead. Nunca use "
|
|
366
|
+
"linguagem agressiva ou spam."
|
|
367
|
+
),
|
|
368
|
+
},
|
|
369
|
+
"atendimento": {
|
|
370
|
+
"status": "inativo",
|
|
371
|
+
"prompt": (
|
|
372
|
+
"Você é o assistente de atendimento do LeadMaster. Responda de forma clara e "
|
|
373
|
+
"objetiva em português do Brasil, encaminhe dúvidas comerciais para o "
|
|
374
|
+
"responsável quando necessário."
|
|
375
|
+
),
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
AGENTE_IDS = ("disparo", "atendimento")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def get_agents() -> dict:
|
|
383
|
+
return _deep_merge(DEFAULT_AGENTS, _read(AGENTS_FILE, {}))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def save_agent(agent_id: str, dados: dict) -> dict:
|
|
387
|
+
"""Atualiza campos de um agente (status/prompt) e persiste em agents.json."""
|
|
388
|
+
with LOCK:
|
|
389
|
+
agents = get_agents()
|
|
390
|
+
if agent_id not in agents:
|
|
391
|
+
agents[agent_id] = {"status": "inativo", "prompt": ""}
|
|
392
|
+
agents[agent_id].update({k: v for k, v in dados.items() if k in ("status", "prompt")})
|
|
393
|
+
_write(AGENTS_FILE, agents)
|
|
394
|
+
return agents[agent_id]
|
|
395
|
+
|
|
396
|
+
|
|
355
397
|
# ---------------------------------------------------------------------------
|
|
356
398
|
# Seeds (primeira execução)
|
|
357
399
|
# ---------------------------------------------------------------------------
|
|
@@ -433,6 +475,8 @@ def ensure_seeds() -> None:
|
|
|
433
475
|
_write(CAMPAIGNS_FILE, _seed_campaigns())
|
|
434
476
|
if not (d / CONFIG_FILE).exists():
|
|
435
477
|
_write(CONFIG_FILE, DEFAULT_CONFIG)
|
|
478
|
+
if not (d / AGENTS_FILE).exists():
|
|
479
|
+
_write(AGENTS_FILE, DEFAULT_AGENTS)
|
|
436
480
|
|
|
437
481
|
|
|
438
482
|
def limpar_seeds_demo() -> None:
|
|
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,90 @@
|
|
|
1
|
+
"""Testes das rotas de configuração — GET/POST/PUT /api/config.
|
|
2
|
+
|
|
3
|
+
Valida a estrutura padrão, o sigulo absoluto dos segredos (regra do
|
|
4
|
+
AGENTS.md: GET nunca expõe chaves), a persistência em config.json, o trim
|
|
5
|
+
automático de textos (prevenção de erros — Nielsen) e a rejeição de payloads
|
|
6
|
+
quebrados. LEADMASTER_HOME em diretório temporário.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastapi.testclient import TestClient
|
|
10
|
+
|
|
11
|
+
import main
|
|
12
|
+
from services import storage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _client(tmp_path, monkeypatch):
|
|
16
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
17
|
+
return TestClient(main.app)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_get_retorna_estrutura_padrao(tmp_path, monkeypatch):
|
|
21
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
22
|
+
c = client.get("/api/config").json()
|
|
23
|
+
for secao in ("llm", "extratores", "integracoes", "agentes", "webhook", "perfil"):
|
|
24
|
+
assert secao in c
|
|
25
|
+
assert c["llm"]["base_url"] # default do OpenAI-compat
|
|
26
|
+
assert c["llm"]["api_key"] == "" # nunca exposto
|
|
27
|
+
assert c["extratores"]["google_maps_cli"] == ""
|
|
28
|
+
assert c["integracoes"]["openoutreach"]["enabled"] is False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_get_nunca_expõe_segredos(tmp_path, monkeypatch):
|
|
32
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
33
|
+
client.post("/api/config", json={"llm": {"api_key": "sk-super-secreta"}})
|
|
34
|
+
corpo = client.get("/api/config").json()
|
|
35
|
+
assert corpo["llm"]["api_key"] == ""
|
|
36
|
+
assert corpo["llm"]["api_key_configurado"] is True
|
|
37
|
+
# Nem o corpo cru da resposta pode conter a chave.
|
|
38
|
+
assert "sk-super-secreta" not in client.get("/api/config").text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_post_persiste_e_aplica_trim(tmp_path, monkeypatch):
|
|
42
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
43
|
+
r = client.post(
|
|
44
|
+
"/api/config",
|
|
45
|
+
json={"llm": {"base_url": " https://api.exemplo.com/v1 ", "api_key": " sk-123 "}},
|
|
46
|
+
)
|
|
47
|
+
assert r.status_code == 200
|
|
48
|
+
gravado = storage.get_config()["llm"]
|
|
49
|
+
assert gravado["base_url"] == "https://api.exemplo.com/v1" # trim aplicado
|
|
50
|
+
assert gravado["api_key"] == "sk-123"
|
|
51
|
+
# Round-trip pelo GET (mascarado) confirma a persistência.
|
|
52
|
+
get = client.get("/api/config").json()
|
|
53
|
+
assert get["llm"]["base_url"] == "https://api.exemplo.com/v1"
|
|
54
|
+
assert get["llm"]["api_key_configurado"] is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_post_segredo_vazio_mantem_atual(tmp_path, monkeypatch):
|
|
58
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
59
|
+
client.post("/api/config", json={"llm": {"api_key": "sk-original"}})
|
|
60
|
+
client.post("/api/config", json={"llm": {"api_key": "", "modelo": "gpt-4o"}})
|
|
61
|
+
gravado = storage.get_config()["llm"]
|
|
62
|
+
assert gravado["api_key"] == "sk-original" # vazio não apaga
|
|
63
|
+
assert gravado["modelo"] == "gpt-4o"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_put_continua_funcionando(tmp_path, monkeypatch):
|
|
67
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
68
|
+
r = client.put("/api/config", json={"extratores": {"igmapper_cli": " C:\\ferramentas\\igmapper.exe "}})
|
|
69
|
+
assert r.status_code == 200
|
|
70
|
+
assert storage.get_config()["extratores"]["igmapper_cli"] == "C:\\ferramentas\\igmapper.exe"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_payload_invalido_422(tmp_path, monkeypatch):
|
|
74
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
75
|
+
# Lista no lugar de objeto: rejeitado pelo FastAPI.
|
|
76
|
+
assert client.post("/api/config", json=["nao", "e", "objeto"]).status_code == 422
|
|
77
|
+
# Seção que deve ser objeto recebendo escalar: rejeitado pela validação.
|
|
78
|
+
r = client.post("/api/config", json={"llm": "string-no-lugar-de-objeto"})
|
|
79
|
+
assert r.status_code == 422
|
|
80
|
+
assert "objeto" in r.json()["detail"]
|
|
81
|
+
# A configuração anterior (padrão) permanece íntegra.
|
|
82
|
+
assert storage.get_config()["llm"]["base_url"] != "string-no-lugar-de-objeto"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_post_trim_em_arrays_e_aninhados(tmp_path, monkeypatch):
|
|
86
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
87
|
+
client.post("/api/config", json={"webhook": {"url": " https://hook.exemplo.com/x ", "eventos": [" lead.criado "]}})
|
|
88
|
+
wh = storage.get_config()["webhook"]
|
|
89
|
+
assert wh["url"] == "https://hook.exemplo.com/x"
|
|
90
|
+
assert wh["eventos"][0] == "lead.criado"
|