Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
@@ -1,57 +1,57 @@
|
||||
"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute."""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# devtests/ лежит внутри backend/ — кладём в path сам backend
|
||||
tmp = tempfile.mkdtemp(prefix="leadradar_boot2_")
|
||||
os.environ["LEADRADAR_DATA"] = tmp
|
||||
os.environ["LEADRADAR_DEMO"] = "1"
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
from app.services import rates as R # noqa: E402
|
||||
|
||||
|
||||
def seed_lead(client):
|
||||
# ручной лид через demo-эндпоинт (не требует ИИ/телеграма)
|
||||
r = client.post("/api/demo/simulate-lead")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
|
||||
|
||||
# шифрование настроек aiConfigs (класс провайдера)
|
||||
s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}})
|
||||
assert s.status_code == 200, s.text
|
||||
pub = client.get("/api/settings").json()
|
||||
assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"]
|
||||
|
||||
# FTS rebuild
|
||||
f = client.post("/api/admin/fts/rebuild")
|
||||
assert f.status_code == 200 and f.json()["ready"] is True, f.text
|
||||
|
||||
# демо-лид + поиск
|
||||
lead = seed_lead(client)
|
||||
assert lead.get("id"), lead
|
||||
q = client.get("/api/search", params={"q": "Python"}).json()
|
||||
assert isinstance(q["leads"], list)
|
||||
|
||||
# check-message (этап 1: стоп-фразы без ИИ)
|
||||
cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"})
|
||||
assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text
|
||||
|
||||
# пересчёт конверсий при смене валюты (архивные не трогаем)
|
||||
R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock")
|
||||
client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
|
||||
refreshed = client.get("/api/settings").json()
|
||||
assert refreshed["targetCurrency"] == "RUB"
|
||||
|
||||
tick = client.post("/api/admin/tick")
|
||||
assert tick.status_code == 200
|
||||
print("BOOT2 OK")
|
||||
"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute."""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# devtests/ лежит внутри backend/ — кладём в path сам backend
|
||||
tmp = tempfile.mkdtemp(prefix="leadradar_boot2_")
|
||||
os.environ["LEADRADAR_DATA"] = tmp
|
||||
os.environ["LEADRADAR_DEMO"] = "1"
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
from app.services import rates as R # noqa: E402
|
||||
|
||||
|
||||
def seed_lead(client):
|
||||
# ручной лид через demo-эндпоинт (не требует ИИ/телеграма)
|
||||
r = client.post("/api/demo/simulate-lead")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
|
||||
|
||||
# шифрование настроек aiConfigs (класс провайдера)
|
||||
s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}})
|
||||
assert s.status_code == 200, s.text
|
||||
pub = client.get("/api/settings").json()
|
||||
assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"]
|
||||
|
||||
# FTS rebuild
|
||||
f = client.post("/api/admin/fts/rebuild")
|
||||
assert f.status_code == 200 and f.json()["ready"] is True, f.text
|
||||
|
||||
# демо-лид + поиск
|
||||
lead = seed_lead(client)
|
||||
assert lead.get("id"), lead
|
||||
q = client.get("/api/search", params={"q": "Python"}).json()
|
||||
assert isinstance(q["leads"], list)
|
||||
|
||||
# check-message (этап 1: стоп-фразы без ИИ)
|
||||
cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"})
|
||||
assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text
|
||||
|
||||
# пересчёт конверсий при смене валюты (архивные не трогаем)
|
||||
R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock")
|
||||
client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
|
||||
refreshed = client.get("/api/settings").json()
|
||||
assert refreshed["targetCurrency"] == "RUB"
|
||||
|
||||
tick = client.post("/api/admin/tick")
|
||||
assert tick.status_code == 200
|
||||
print("BOOT2 OK")
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе.
|
||||
|
||||
Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик,
|
||||
FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO —
|
||||
в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://localhost:8000"
|
||||
MUTATE = os.getenv("SMOKE_MUTATE", "") == "1"
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
if not cond:
|
||||
ok = False
|
||||
print("FAIL:", name, extra)
|
||||
else:
|
||||
print("ok:", name)
|
||||
|
||||
|
||||
with httpx.Client(base_url=BASE, timeout=30) as c:
|
||||
check("health", c.get("/api/health").status_code == 200)
|
||||
page = c.get("/")
|
||||
check("spa", page.status_code == 200 and 'id="app"' in page.text)
|
||||
|
||||
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
|
||||
check("login", r.status_code == 200 and c.cookies.get("leadradar_session"))
|
||||
|
||||
boards = c.get("/api/boards").json()
|
||||
check("boards api", isinstance(boards, list))
|
||||
settings = c.get("/api/settings").json()
|
||||
check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB")
|
||||
|
||||
# демо-механика в проде выключена
|
||||
demo = c.post("/api/demo/simulate-lead")
|
||||
check("demo disabled", demo.status_code == 404)
|
||||
|
||||
# поиск отвечает (лиды создаются только из Telegram)
|
||||
q = c.get("/api/search", params={"q": "Python"}).json()
|
||||
check("search", isinstance(q.get("leads"), list))
|
||||
|
||||
# тик (правила хранения + очередь), FTS, счётчики
|
||||
tick = c.post("/api/admin/tick").json()
|
||||
check("tick", "pipeline" in tick and "queue" in tick)
|
||||
check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True)
|
||||
counts = c.get("/api/leads/counts").json()
|
||||
check("counts", "ml" in counts and "ai" in counts and "learning" in counts)
|
||||
|
||||
# автономный ML-сервис (отдельный контейнер) через API основного приложения
|
||||
ml = c.get("/api/ml/status").json()
|
||||
check("ml reachable", ml.get("reachable") is True, ml)
|
||||
pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json()
|
||||
check("ml predict", isinstance(pred.get("scores"), dict))
|
||||
|
||||
if MUTATE:
|
||||
# opt-in: проект + файл через MinIO (после проверки проект надо удалить)
|
||||
card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json()
|
||||
check("project", bool(card.get("id")))
|
||||
r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))])
|
||||
check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
|
||||
fid = r.json()["items"][0]["id"]
|
||||
dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download")
|
||||
check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100])
|
||||
|
||||
check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива
|
||||
|
||||
print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED")
|
||||
sys.exit(0 if ok else 1)
|
||||
"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе.
|
||||
|
||||
Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик,
|
||||
FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO —
|
||||
в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://localhost:8000"
|
||||
MUTATE = os.getenv("SMOKE_MUTATE", "") == "1"
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
if not cond:
|
||||
ok = False
|
||||
print("FAIL:", name, extra)
|
||||
else:
|
||||
print("ok:", name)
|
||||
|
||||
|
||||
with httpx.Client(base_url=BASE, timeout=30) as c:
|
||||
check("health", c.get("/api/health").status_code == 200)
|
||||
page = c.get("/")
|
||||
check("spa", page.status_code == 200 and 'id="app"' in page.text)
|
||||
|
||||
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
|
||||
check("login", r.status_code == 200 and c.cookies.get("leadradar_session"))
|
||||
|
||||
boards = c.get("/api/boards").json()
|
||||
check("boards api", isinstance(boards, list))
|
||||
settings = c.get("/api/settings").json()
|
||||
check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB")
|
||||
|
||||
# демо-механика в проде выключена
|
||||
demo = c.post("/api/demo/simulate-lead")
|
||||
check("demo disabled", demo.status_code == 404)
|
||||
|
||||
# поиск отвечает (лиды создаются только из Telegram)
|
||||
q = c.get("/api/search", params={"q": "Python"}).json()
|
||||
check("search", isinstance(q.get("leads"), list))
|
||||
|
||||
# тик (правила хранения + очередь), FTS, счётчики
|
||||
tick = c.post("/api/admin/tick").json()
|
||||
check("tick", "pipeline" in tick and "queue" in tick)
|
||||
check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True)
|
||||
counts = c.get("/api/leads/counts").json()
|
||||
check("counts", "ml" in counts and "ai" in counts and "learning" in counts)
|
||||
|
||||
# автономный ML-сервис (отдельный контейнер) через API основного приложения
|
||||
ml = c.get("/api/ml/status").json()
|
||||
check("ml reachable", ml.get("reachable") is True, ml)
|
||||
pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json()
|
||||
check("ml predict", isinstance(pred.get("scores"), dict))
|
||||
|
||||
if MUTATE:
|
||||
# opt-in: проект + файл через MinIO (после проверки проект надо удалить)
|
||||
card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json()
|
||||
check("project", bool(card.get("id")))
|
||||
r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))])
|
||||
check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
|
||||
fid = r.json()["items"][0]["id"]
|
||||
dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download")
|
||||
check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100])
|
||||
|
||||
check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива
|
||||
|
||||
print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED")
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
@@ -1,175 +1,175 @@
|
||||
"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn
|
||||
tmp = tempfile.mkdtemp(prefix="leadradar_e2e_")
|
||||
env = dict(os.environ)
|
||||
env["LEADRADAR_DATA"] = tmp
|
||||
env["LEADRADAR_DEMO"] = "1"
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"],
|
||||
cwd=BACKEND_DIR,
|
||||
env=env,
|
||||
)
|
||||
|
||||
BASE = "http://127.0.0.1:8077"
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
if not cond:
|
||||
ok = False
|
||||
print("FAIL:", name, extra)
|
||||
else:
|
||||
print("ok:", name)
|
||||
|
||||
|
||||
try:
|
||||
for _ in range(40):
|
||||
try:
|
||||
r = httpx.get(BASE + "/api/health", timeout=1)
|
||||
if r.status_code == 200:
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise SystemExit("server did not start")
|
||||
|
||||
with httpx.Client(base_url=BASE, timeout=20) as c:
|
||||
check("health", c.get("/api/health").status_code == 200)
|
||||
|
||||
# вход
|
||||
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
|
||||
check("login", r.status_code == 200)
|
||||
check("cookie set", bool(c.cookies.get("leadradar_session")))
|
||||
|
||||
me = c.get("/api/auth/me")
|
||||
check("me", me.status_code == 200 and me.json().get("login") == "admin")
|
||||
|
||||
# стартовые данные: колонок нет по умолчанию — создаём одну через API
|
||||
boards = c.get("/api/boards").json()
|
||||
check("no default boards", boards == [])
|
||||
bid = c.post("/api/boards", json={"name": "Python"}).json()["id"]
|
||||
check("board created", bool(bid))
|
||||
settings = c.get("/api/settings").json()
|
||||
check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek")
|
||||
|
||||
# демо-лид -> поиск
|
||||
lead = c.post("/api/demo/simulate-lead").json()
|
||||
check("demo lead", bool(lead.get("id")), str(lead)[:120])
|
||||
lid = lead["id"]
|
||||
q = c.get("/api/search", params={"q": "Python"}).json()
|
||||
check("search", isinstance(q.get("leads"), list))
|
||||
|
||||
# перенос на доску
|
||||
r = c.post(f"/api/leads/{lid}/move", json={"to": bid})
|
||||
check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text)
|
||||
r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"})
|
||||
check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1)
|
||||
|
||||
# демо-старение -> архив
|
||||
r = c.post("/api/demo/age-lead")
|
||||
check("age-lead", r.status_code == 200, r.text)
|
||||
leads = c.get("/api/leads").json()["items"]
|
||||
arch = c.get("/api/leads", params={"col": "archive"}).json()["items"]
|
||||
check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}")
|
||||
|
||||
# восстановление
|
||||
r = c.post(f"/api/leads/{lid}/restore")
|
||||
check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text)
|
||||
|
||||
# проекты: взять в работу
|
||||
card = c.post("/api/projects/take", json={"leadId": lid}).json()
|
||||
check("take to projects", bool(card.get("id")), str(card)[:120])
|
||||
cid = card["id"]
|
||||
leads_after = c.get("/api/leads").json()["items"]
|
||||
check("lead gone from board", all(l["id"] != lid for l in leads_after))
|
||||
|
||||
# стадия + комментарий + ссылка + ТЗ
|
||||
r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"})
|
||||
check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply")
|
||||
r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"})
|
||||
check("proj comment", r.status_code == 200)
|
||||
r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"})
|
||||
check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://"))
|
||||
r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"})
|
||||
check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM")
|
||||
|
||||
# файл (локальный fallback без MinIO) + скачивание
|
||||
r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))])
|
||||
check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
|
||||
file_id = r.json()["items"][0]["id"]
|
||||
dl = c.get(f"/api/projects/{cid}/files/{file_id}/download")
|
||||
check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80])
|
||||
r = c.delete(f"/api/projects/{cid}/files/{file_id}")
|
||||
check("file remove", r.status_code == 200)
|
||||
|
||||
# локальная карточка
|
||||
loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json()
|
||||
check("local card", loc.get("local") is True and loc.get("title") == "")
|
||||
|
||||
# напоминание (hold)
|
||||
r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"})
|
||||
check("stage hold", r.status_code == 200)
|
||||
at = int(time.time() * 1000) + 60000
|
||||
r = c.post(f"/api/projects/{cid}/reminder", json={"at": at})
|
||||
check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text)
|
||||
rem = c.get("/api/projects/reminders").json()["items"]
|
||||
check("active reminders", any(x["id"] == cid for x in rem))
|
||||
|
||||
# настройки валюты и пересчёт
|
||||
r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
|
||||
check("settings patch", r.status_code == 200)
|
||||
rates = c.get("/api/rates").json()
|
||||
check("rates have USD", "USD" in rates.get("rates", {}))
|
||||
|
||||
# mark-col-seen (новый эндпоинт)
|
||||
r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"})
|
||||
check("mark col seen", r.status_code == 200)
|
||||
|
||||
# ручная полная очистка «Отклонено» (проектные карточки)
|
||||
r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"})
|
||||
check("stage rejected", r.status_code == 200)
|
||||
r = c.post("/api/projects/clear-rejected")
|
||||
check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
|
||||
gone = c.get("/api/projects").json()["items"]
|
||||
check("rejected gone", all(x["id"] != cid for x in gone))
|
||||
|
||||
# ручная полная очистка корзины (лиды дашборда)
|
||||
d2 = c.post("/api/demo/simulate-lead").json()
|
||||
c.post(f"/api/leads/{d2['id']}/trash")
|
||||
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
|
||||
check("trash has lead", any(x["id"] == d2["id"] for x in trash_items))
|
||||
r = c.post("/api/leads/clear-col", json={"col": "trash"})
|
||||
check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
|
||||
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
|
||||
check("trash empty", len(trash_items) == 0)
|
||||
# архив: тот же эндпоинт (сейчас пуст — просто валидируем)
|
||||
r = c.post("/api/leads/clear-col", json={"col": "archive"})
|
||||
check("clear archive", r.status_code == 200)
|
||||
|
||||
# FTS rebuild
|
||||
r = c.post("/api/admin/fts/rebuild")
|
||||
check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text)
|
||||
|
||||
# статика фронтенда из dist
|
||||
page = c.get("/")
|
||||
check("spa served", page.status_code == 200 and "<div id=\"app\">" in page.text)
|
||||
|
||||
print("E2E OK" if ok else "E2E FAILED")
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except Exception:
|
||||
server.kill()
|
||||
"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn
|
||||
tmp = tempfile.mkdtemp(prefix="leadradar_e2e_")
|
||||
env = dict(os.environ)
|
||||
env["LEADRADAR_DATA"] = tmp
|
||||
env["LEADRADAR_DEMO"] = "1"
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"],
|
||||
cwd=BACKEND_DIR,
|
||||
env=env,
|
||||
)
|
||||
|
||||
BASE = "http://127.0.0.1:8077"
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
global ok
|
||||
if not cond:
|
||||
ok = False
|
||||
print("FAIL:", name, extra)
|
||||
else:
|
||||
print("ok:", name)
|
||||
|
||||
|
||||
try:
|
||||
for _ in range(40):
|
||||
try:
|
||||
r = httpx.get(BASE + "/api/health", timeout=1)
|
||||
if r.status_code == 200:
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise SystemExit("server did not start")
|
||||
|
||||
with httpx.Client(base_url=BASE, timeout=20) as c:
|
||||
check("health", c.get("/api/health").status_code == 200)
|
||||
|
||||
# вход
|
||||
r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"})
|
||||
check("login", r.status_code == 200)
|
||||
check("cookie set", bool(c.cookies.get("leadradar_session")))
|
||||
|
||||
me = c.get("/api/auth/me")
|
||||
check("me", me.status_code == 200 and me.json().get("login") == "admin")
|
||||
|
||||
# стартовые данные: колонок нет по умолчанию — создаём одну через API
|
||||
boards = c.get("/api/boards").json()
|
||||
check("no default boards", boards == [])
|
||||
bid = c.post("/api/boards", json={"name": "Python"}).json()["id"]
|
||||
check("board created", bool(bid))
|
||||
settings = c.get("/api/settings").json()
|
||||
check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek")
|
||||
|
||||
# демо-лид -> поиск
|
||||
lead = c.post("/api/demo/simulate-lead").json()
|
||||
check("demo lead", bool(lead.get("id")), str(lead)[:120])
|
||||
lid = lead["id"]
|
||||
q = c.get("/api/search", params={"q": "Python"}).json()
|
||||
check("search", isinstance(q.get("leads"), list))
|
||||
|
||||
# перенос на доску
|
||||
r = c.post(f"/api/leads/{lid}/move", json={"to": bid})
|
||||
check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text)
|
||||
r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"})
|
||||
check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1)
|
||||
|
||||
# демо-старение -> архив
|
||||
r = c.post("/api/demo/age-lead")
|
||||
check("age-lead", r.status_code == 200, r.text)
|
||||
leads = c.get("/api/leads").json()["items"]
|
||||
arch = c.get("/api/leads", params={"col": "archive"}).json()["items"]
|
||||
check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}")
|
||||
|
||||
# восстановление
|
||||
r = c.post(f"/api/leads/{lid}/restore")
|
||||
check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text)
|
||||
|
||||
# проекты: взять в работу
|
||||
card = c.post("/api/projects/take", json={"leadId": lid}).json()
|
||||
check("take to projects", bool(card.get("id")), str(card)[:120])
|
||||
cid = card["id"]
|
||||
leads_after = c.get("/api/leads").json()["items"]
|
||||
check("lead gone from board", all(l["id"] != lid for l in leads_after))
|
||||
|
||||
# стадия + комментарий + ссылка + ТЗ
|
||||
r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"})
|
||||
check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply")
|
||||
r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"})
|
||||
check("proj comment", r.status_code == 200)
|
||||
r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"})
|
||||
check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://"))
|
||||
r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"})
|
||||
check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM")
|
||||
|
||||
# файл (локальный fallback без MinIO) + скачивание
|
||||
r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))])
|
||||
check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text)
|
||||
file_id = r.json()["items"][0]["id"]
|
||||
dl = c.get(f"/api/projects/{cid}/files/{file_id}/download")
|
||||
check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80])
|
||||
r = c.delete(f"/api/projects/{cid}/files/{file_id}")
|
||||
check("file remove", r.status_code == 200)
|
||||
|
||||
# локальная карточка
|
||||
loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json()
|
||||
check("local card", loc.get("local") is True and loc.get("title") == "")
|
||||
|
||||
# напоминание (hold)
|
||||
r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"})
|
||||
check("stage hold", r.status_code == 200)
|
||||
at = int(time.time() * 1000) + 60000
|
||||
r = c.post(f"/api/projects/{cid}/reminder", json={"at": at})
|
||||
check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text)
|
||||
rem = c.get("/api/projects/reminders").json()["items"]
|
||||
check("active reminders", any(x["id"] == cid for x in rem))
|
||||
|
||||
# настройки валюты и пересчёт
|
||||
r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True})
|
||||
check("settings patch", r.status_code == 200)
|
||||
rates = c.get("/api/rates").json()
|
||||
check("rates have USD", "USD" in rates.get("rates", {}))
|
||||
|
||||
# mark-col-seen (новый эндпоинт)
|
||||
r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"})
|
||||
check("mark col seen", r.status_code == 200)
|
||||
|
||||
# ручная полная очистка «Отклонено» (проектные карточки)
|
||||
r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"})
|
||||
check("stage rejected", r.status_code == 200)
|
||||
r = c.post("/api/projects/clear-rejected")
|
||||
check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
|
||||
gone = c.get("/api/projects").json()["items"]
|
||||
check("rejected gone", all(x["id"] != cid for x in gone))
|
||||
|
||||
# ручная полная очистка корзины (лиды дашборда)
|
||||
d2 = c.post("/api/demo/simulate-lead").json()
|
||||
c.post(f"/api/leads/{d2['id']}/trash")
|
||||
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
|
||||
check("trash has lead", any(x["id"] == d2["id"] for x in trash_items))
|
||||
r = c.post("/api/leads/clear-col", json={"col": "trash"})
|
||||
check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text)
|
||||
trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"]
|
||||
check("trash empty", len(trash_items) == 0)
|
||||
# архив: тот же эндпоинт (сейчас пуст — просто валидируем)
|
||||
r = c.post("/api/leads/clear-col", json={"col": "archive"})
|
||||
check("clear archive", r.status_code == 200)
|
||||
|
||||
# FTS rebuild
|
||||
r = c.post("/api/admin/fts/rebuild")
|
||||
check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text)
|
||||
|
||||
# статика фронтенда из dist
|
||||
page = c.get("/")
|
||||
check("spa served", page.status_code == 200 and "<div id=\"app\">" in page.text)
|
||||
|
||||
print("E2E OK" if ok else "E2E FAILED")
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except Exception:
|
||||
server.kill()
|
||||
|
||||
@@ -1,164 +1,164 @@
|
||||
"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox.
|
||||
|
||||
Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его
|
||||
напрямую по HTTP и проверяет весь контур основного приложения:
|
||||
* обучение всегда идёт через outbox (даже при выключенном ML в пайплайне);
|
||||
* /api/ml/predict, /api/ml/candidates, /api/ml/apply;
|
||||
* очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
TMP = tempfile.mkdtemp(prefix="leadradar_pl_")
|
||||
os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app")
|
||||
os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121"
|
||||
os.environ["LEADRADAR_DEMO"] = "1"
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice")
|
||||
|
||||
# ── локальный ML-сервис ───────────────────────────────────────────────────
|
||||
ml_env = dict(os.environ)
|
||||
ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb")
|
||||
ml_server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"],
|
||||
cwd=ML_DIR,
|
||||
env=ml_env,
|
||||
)
|
||||
|
||||
try:
|
||||
for _ in range(50):
|
||||
try:
|
||||
if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200:
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
else:
|
||||
raise SystemExit("ml service did not start")
|
||||
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
from app.services import pipeline as pl # noqa: E402
|
||||
|
||||
ML = "http://127.0.0.1:8121"
|
||||
|
||||
SPAM_TEXTS = [
|
||||
"Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал",
|
||||
"Инвестируй в наш фонд и получай пассивный доход каждый день без риска",
|
||||
"Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас",
|
||||
"Бесплатный курс по заработку на бирже забери по ссылке внизу поста",
|
||||
"Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня",
|
||||
"Схема быстрого заработка на арбитраже крипты без риска все проверено",
|
||||
"Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас",
|
||||
"Пирамида дохода открыла набор новых участников успей вложиться",
|
||||
]
|
||||
PY_TEXTS = [
|
||||
"Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно",
|
||||
"Ищем middle python бекенд разработчика на fastapi для стартапа удаленка",
|
||||
"Задача для python джуна написать скрипт парсинга авито с антидетектом",
|
||||
"Python разработчик на django проект CRM интеграция с телеграм ботом",
|
||||
"Нужен python программист для автоматизации отчетов и бота в телеграм",
|
||||
"Срочно python разработчик aiogram телеграм бот для интернет магазина",
|
||||
"Python backend для API на fastapi микросервисы postgres kafka",
|
||||
"Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel",
|
||||
"Ищем python специалиста для интеграции с мессенджерами и crm",
|
||||
"Python разработчик на парсер и бота оплата достойная сразу в лс",
|
||||
]
|
||||
FRONT_TEXTS = [
|
||||
"Frontend разработчик vuejs для корпоративного портала удаленная работа",
|
||||
"Нужен верстальщик реакт для интернет магазина срочно до конца недели",
|
||||
"Ищем frontend специалиста vue3 typescript компоненты дизайн система",
|
||||
"Задача для фронтендера сверстать адаптивный лендинг на nuxtjs",
|
||||
"Frontend разработчик react nextjs для панели администратора стартапа",
|
||||
"Верстка писем и лендингов html css для маркетинговых рассылок заказ",
|
||||
"Нужен vue разработчик доработка фронта для телеграм мини апп",
|
||||
"Frontend инженер angular для банковского приложения гибрид офис",
|
||||
]
|
||||
|
||||
# учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox)
|
||||
# — перенесено внутрь with: метки = id колонок, которые создаём через API
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
|
||||
client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True})
|
||||
# колонок по умолчанию нет — создаём Python и Frontend
|
||||
py = client.post("/api/boards", json={"name": "Python"}).json()["id"]
|
||||
fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"]
|
||||
for t in PY_TEXTS + FRONT_TEXTS:
|
||||
httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t})
|
||||
for t in SPAM_TEXTS:
|
||||
httpx.post(ML + "/learn", json={"label": "spam", "text": t})
|
||||
|
||||
def wait_until(pred, seconds=8):
|
||||
"""Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия."""
|
||||
deadline = time.time() + seconds
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = client.post("/api/admin/tick").json()
|
||||
if pred():
|
||||
return last
|
||||
time.sleep(0.5)
|
||||
raise AssertionError("условие не наступило: %s" % pred())
|
||||
|
||||
def leads():
|
||||
return client.get("/api/leads").json()["items"]
|
||||
|
||||
# статус/предсказание через основное приложение
|
||||
st = client.get("/api/ml/status").json()
|
||||
assert st["reachable"] and st["service"]["ready"], st
|
||||
p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json()
|
||||
assert p["take"] and p["label"] == py, p
|
||||
print("ml status/predict ok")
|
||||
|
||||
# стоп-фраза -> удаляется из очереди, ничего не оседает
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000)
|
||||
assert pl.queue_len() == 1
|
||||
wait_until(lambda: pl.queue_len() == 0)
|
||||
assert len(leads()) == 0
|
||||
print("stop-phrase drop ok")
|
||||
|
||||
# ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000)
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000)
|
||||
wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads()))
|
||||
card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3]
|
||||
assert len(card) == 1, leads()
|
||||
print("ml fast-path ok")
|
||||
|
||||
# ручная разметка apply: spam -> карточка в корзину + обучение (outbox)
|
||||
r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json()
|
||||
assert r["learned"] is True and r["moved"] == "trash", r
|
||||
assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1
|
||||
# flush -> обучение уехало в ML-сервис
|
||||
f = client.post("/api/ml/flush").json()
|
||||
assert f["outbox"] == 0 and f["flushed"] >= 1, f
|
||||
print("apply + outbox ok")
|
||||
|
||||
# обучение идёт всегда, даже если ML выключен в пайплайне
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000)
|
||||
wait_until(lambda: any(l["col"] == fr for l in leads()))
|
||||
lead = [l for l in leads() if l["col"] == fr][0]
|
||||
boards = client.get("/api/boards").json()
|
||||
client.patch("/api/settings", json={"mlEnabled": False})
|
||||
client.post(f"/api/leads/{lead['id']}/move", json={"to": py})
|
||||
assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1
|
||||
print("learn-always (ml off) ok")
|
||||
|
||||
# кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback)
|
||||
cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json()
|
||||
assert isinstance(cand.get("items"), list)
|
||||
print("candidates ok")
|
||||
|
||||
print("PIPELINE TEST OK")
|
||||
finally:
|
||||
ml_server.terminate()
|
||||
try:
|
||||
ml_server.wait(timeout=10)
|
||||
except Exception:
|
||||
ml_server.kill()
|
||||
"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox.
|
||||
|
||||
Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его
|
||||
напрямую по HTTP и проверяет весь контур основного приложения:
|
||||
* обучение всегда идёт через outbox (даже при выключенном ML в пайплайне);
|
||||
* /api/ml/predict, /api/ml/candidates, /api/ml/apply;
|
||||
* очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
TMP = tempfile.mkdtemp(prefix="leadradar_pl_")
|
||||
os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app")
|
||||
os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121"
|
||||
os.environ["LEADRADAR_DEMO"] = "1"
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice")
|
||||
|
||||
# ── локальный ML-сервис ───────────────────────────────────────────────────
|
||||
ml_env = dict(os.environ)
|
||||
ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb")
|
||||
ml_server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"],
|
||||
cwd=ML_DIR,
|
||||
env=ml_env,
|
||||
)
|
||||
|
||||
try:
|
||||
for _ in range(50):
|
||||
try:
|
||||
if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200:
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
else:
|
||||
raise SystemExit("ml service did not start")
|
||||
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
from app.services import pipeline as pl # noqa: E402
|
||||
|
||||
ML = "http://127.0.0.1:8121"
|
||||
|
||||
SPAM_TEXTS = [
|
||||
"Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал",
|
||||
"Инвестируй в наш фонд и получай пассивный доход каждый день без риска",
|
||||
"Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас",
|
||||
"Бесплатный курс по заработку на бирже забери по ссылке внизу поста",
|
||||
"Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня",
|
||||
"Схема быстрого заработка на арбитраже крипты без риска все проверено",
|
||||
"Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас",
|
||||
"Пирамида дохода открыла набор новых участников успей вложиться",
|
||||
]
|
||||
PY_TEXTS = [
|
||||
"Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно",
|
||||
"Ищем middle python бекенд разработчика на fastapi для стартапа удаленка",
|
||||
"Задача для python джуна написать скрипт парсинга авито с антидетектом",
|
||||
"Python разработчик на django проект CRM интеграция с телеграм ботом",
|
||||
"Нужен python программист для автоматизации отчетов и бота в телеграм",
|
||||
"Срочно python разработчик aiogram телеграм бот для интернет магазина",
|
||||
"Python backend для API на fastapi микросервисы postgres kafka",
|
||||
"Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel",
|
||||
"Ищем python специалиста для интеграции с мессенджерами и crm",
|
||||
"Python разработчик на парсер и бота оплата достойная сразу в лс",
|
||||
]
|
||||
FRONT_TEXTS = [
|
||||
"Frontend разработчик vuejs для корпоративного портала удаленная работа",
|
||||
"Нужен верстальщик реакт для интернет магазина срочно до конца недели",
|
||||
"Ищем frontend специалиста vue3 typescript компоненты дизайн система",
|
||||
"Задача для фронтендера сверстать адаптивный лендинг на nuxtjs",
|
||||
"Frontend разработчик react nextjs для панели администратора стартапа",
|
||||
"Верстка писем и лендингов html css для маркетинговых рассылок заказ",
|
||||
"Нужен vue разработчик доработка фронта для телеграм мини апп",
|
||||
"Frontend инженер angular для банковского приложения гибрид офис",
|
||||
]
|
||||
|
||||
# учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox)
|
||||
# — перенесено внутрь with: метки = id колонок, которые создаём через API
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200
|
||||
client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True})
|
||||
# колонок по умолчанию нет — создаём Python и Frontend
|
||||
py = client.post("/api/boards", json={"name": "Python"}).json()["id"]
|
||||
fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"]
|
||||
for t in PY_TEXTS + FRONT_TEXTS:
|
||||
httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t})
|
||||
for t in SPAM_TEXTS:
|
||||
httpx.post(ML + "/learn", json={"label": "spam", "text": t})
|
||||
|
||||
def wait_until(pred, seconds=8):
|
||||
"""Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия."""
|
||||
deadline = time.time() + seconds
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = client.post("/api/admin/tick").json()
|
||||
if pred():
|
||||
return last
|
||||
time.sleep(0.5)
|
||||
raise AssertionError("условие не наступило: %s" % pred())
|
||||
|
||||
def leads():
|
||||
return client.get("/api/leads").json()["items"]
|
||||
|
||||
# статус/предсказание через основное приложение
|
||||
st = client.get("/api/ml/status").json()
|
||||
assert st["reachable"] and st["service"]["ready"], st
|
||||
p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json()
|
||||
assert p["take"] and p["label"] == py, p
|
||||
print("ml status/predict ok")
|
||||
|
||||
# стоп-фраза -> удаляется из очереди, ничего не оседает
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000)
|
||||
assert pl.queue_len() == 1
|
||||
wait_until(lambda: pl.queue_len() == 0)
|
||||
assert len(leads()) == 0
|
||||
print("stop-phrase drop ok")
|
||||
|
||||
# ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000)
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000)
|
||||
wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads()))
|
||||
card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3]
|
||||
assert len(card) == 1, leads()
|
||||
print("ml fast-path ok")
|
||||
|
||||
# ручная разметка apply: spam -> карточка в корзину + обучение (outbox)
|
||||
r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json()
|
||||
assert r["learned"] is True and r["moved"] == "trash", r
|
||||
assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1
|
||||
# flush -> обучение уехало в ML-сервис
|
||||
f = client.post("/api/ml/flush").json()
|
||||
assert f["outbox"] == 0 and f["flushed"] >= 1, f
|
||||
print("apply + outbox ok")
|
||||
|
||||
# обучение идёт всегда, даже если ML выключен в пайплайне
|
||||
pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000)
|
||||
wait_until(lambda: any(l["col"] == fr for l in leads()))
|
||||
lead = [l for l in leads() if l["col"] == fr][0]
|
||||
boards = client.get("/api/boards").json()
|
||||
client.patch("/api/settings", json={"mlEnabled": False})
|
||||
client.post(f"/api/leads/{lead['id']}/move", json={"to": py})
|
||||
assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1
|
||||
print("learn-always (ml off) ok")
|
||||
|
||||
# кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback)
|
||||
cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json()
|
||||
assert isinstance(cand.get("items"), list)
|
||||
print("candidates ok")
|
||||
|
||||
print("PIPELINE TEST OK")
|
||||
finally:
|
||||
ml_server.terminate()
|
||||
try:
|
||||
ml_server.wait(timeout=10)
|
||||
except Exception:
|
||||
ml_server.kill()
|
||||
|
||||
Reference in New Issue
Block a user