New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
341 lines
16 KiB
Python
341 lines
16 KiB
Python
"""
|
||
tests/test_stepan_v44_farmos_logs.py
|
||
|
||
Unit tests for v4.4 FarmOS read-only logs tool + connection error normalization.
|
||
|
||
Scope:
|
||
- agromatrix_tools.tool_farmos_read._farmos_read_logs_impl (fail-closed)
|
||
- agromatrix_tools.tool_farmos_read._classify_exception (error normalization)
|
||
- crews.agromatrix_crew.operator_commands.handle_farmos_status (logs subcommand)
|
||
"""
|
||
from __future__ import annotations
|
||
import json
|
||
import os
|
||
import sys
|
||
import types
|
||
import pytest
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
# ── Шляхи ─────────────────────────────────────────────────────────────────────
|
||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
_PKG = os.path.join(_ROOT, "packages", "agromatrix-tools")
|
||
_CREWS = os.path.join(_ROOT, "crews")
|
||
for _p in (_PKG, _CREWS):
|
||
if _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
|
||
# ── Мок зовнішніх модулів ─────────────────────────────────────────────────────
|
||
def _stub_module(name: str) -> types.ModuleType:
|
||
m = types.ModuleType(name)
|
||
sys.modules[name] = m
|
||
return m
|
||
|
||
for _mod in ("nats", "nats.aio", "nats.aio.client", "crewai", "crewai.tools"):
|
||
if _mod not in sys.modules:
|
||
_stub_module(_mod)
|
||
|
||
sys.modules["crewai.tools"].tool = lambda name: (lambda f: f) # type: ignore
|
||
sys.modules["nats.aio.client"].Client = MagicMock # type: ignore
|
||
|
||
_audit_stub = types.ModuleType("agromatrix_tools.audit")
|
||
_audit_stub.audit_tool_call = lambda *a, **kw: None # type: ignore
|
||
sys.modules["agromatrix_tools.audit"] = _audit_stub
|
||
|
||
import importlib as _il
|
||
if "agromatrix_tools.tool_farmos_read" in sys.modules:
|
||
_il.reload(sys.modules["agromatrix_tools.tool_farmos_read"])
|
||
|
||
from agromatrix_tools.tool_farmos_read import ( # noqa: E402
|
||
_farmos_read_logs_impl,
|
||
_classify_exception,
|
||
)
|
||
from crews.agromatrix_crew.operator_commands import handle_farmos_status # noqa: E402
|
||
|
||
|
||
# ── Fixtures ───────────────────────────────────────────────────────────────────
|
||
@pytest.fixture(autouse=True)
|
||
def clear_farmos_env(monkeypatch):
|
||
for k in ("FARMOS_BASE_URL", "FARMOS_TOKEN", "FARMOS_USER", "FARMOS_PASS", "FARMOS_PASSWORD"):
|
||
monkeypatch.delenv(k, raising=False)
|
||
yield
|
||
|
||
|
||
def _make_json_resp(items: list, status: int = 200) -> MagicMock:
|
||
resp = MagicMock()
|
||
resp.status_code = status
|
||
resp.json.return_value = {"data": items}
|
||
return resp
|
||
|
||
|
||
def _make_log_item(name: str = "Полив", ts: int = 1700000000, notes: str = "без змін") -> dict:
|
||
return {
|
||
"type": "log--activity",
|
||
"id": "abc-123",
|
||
"attributes": {
|
||
"name": name,
|
||
"timestamp": ts,
|
||
"notes": {"value": notes},
|
||
},
|
||
}
|
||
|
||
|
||
# ─── _classify_exception ──────────────────────────────────────────────────────
|
||
|
||
class TestClassifyException:
|
||
def test_timeout(self):
|
||
import requests
|
||
assert _classify_exception(requests.exceptions.Timeout()) == "timeout"
|
||
|
||
def test_connect(self):
|
||
import requests
|
||
assert _classify_exception(requests.exceptions.ConnectionError("refused")) == "connect"
|
||
|
||
def test_dns(self):
|
||
exc = Exception("Name or service not known")
|
||
assert _classify_exception(exc) == "dns"
|
||
|
||
def test_ssl(self):
|
||
exc = Exception("SSL: CERTIFICATE_VERIFY_FAILED")
|
||
assert _classify_exception(exc) == "ssl"
|
||
|
||
def test_other(self):
|
||
exc = ValueError("something")
|
||
assert _classify_exception(exc) == "other"
|
||
|
||
|
||
# ─── _farmos_read_logs_impl ───────────────────────────────────────────────────
|
||
|
||
class TestFarmosReadLogsImpl:
|
||
# ── Config guards ──────────────────────────────────────────────────────────
|
||
def test_no_base_url(self):
|
||
result = _farmos_read_logs_impl()
|
||
assert "FARMOS_BASE_URL" in result
|
||
|
||
def test_base_url_no_auth(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
result = _farmos_read_logs_impl()
|
||
assert "токен" in result.lower() or "логін" in result.lower()
|
||
|
||
def test_invalid_log_type(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
result = _farmos_read_logs_impl(log_type="badtype")
|
||
assert "log_type має бути" in result
|
||
# Whitelist перераховано у відповіді
|
||
for t in ("activity", "observation"):
|
||
assert t in result
|
||
|
||
def test_limit_clamped_min(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = _make_json_resp([], status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl(limit=-5)
|
||
# Не падає, повертає "записів не знайдено"
|
||
assert isinstance(result, str)
|
||
|
||
def test_limit_clamped_max(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
items = [_make_log_item(f"item {i}") for i in range(30)]
|
||
mock_resp = _make_json_resp(items, status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl(limit=100)
|
||
# Обмежено до _LOG_OUTPUT_MAX_LINES=12
|
||
lines = [l for l in result.split("\n") if l.strip().startswith("-")]
|
||
assert len(lines) <= 12
|
||
|
||
# ── HTTP responses ─────────────────────────────────────────────────────────
|
||
def test_200_empty(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = _make_json_resp([], status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "записів не знайдено" in result
|
||
|
||
def test_200_with_items(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
items = [
|
||
_make_log_item("Внесення добрив", 1700010000, "карбамід 200кг/га"),
|
||
_make_log_item("Оранка", 1700020000, "глибина 25см"),
|
||
]
|
||
mock_resp = _make_json_resp(items, status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "Внесення добрив" in result
|
||
assert "Оранка" in result
|
||
assert "2023-" in result # перевіряємо що дата парситься
|
||
|
||
def test_200_output_format(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
items = [_make_log_item("Тест", 1700000000, "примітка")]
|
||
mock_resp = _make_json_resp(items, status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
# Кожен рядок має формат "- name | date | notes"
|
||
lines = [l for l in result.split("\n") if l.strip()]
|
||
assert len(lines) >= 1
|
||
assert lines[0].startswith("- ")
|
||
assert lines[0].count("|") >= 2
|
||
|
||
def test_404(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = MagicMock()
|
||
mock_resp.status_code = 404
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "404" in result or "не знайдено" in result
|
||
|
||
def test_401(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "badtok")
|
||
mock_resp = MagicMock()
|
||
mock_resp.status_code = 401
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "401" in result or "авторизац" in result.lower()
|
||
|
||
def test_500(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = MagicMock()
|
||
mock_resp.status_code = 500
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "500" in result or "помилка" in result.lower()
|
||
|
||
def test_timeout(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
import requests as _r
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get",
|
||
side_effect=_r.exceptions.Timeout()):
|
||
result = _farmos_read_logs_impl()
|
||
assert "timeout" in result.lower()
|
||
|
||
def test_connection_error_dns(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
exc = Exception("Name or service not known")
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", side_effect=exc):
|
||
result = _farmos_read_logs_impl()
|
||
assert isinstance(result, str)
|
||
assert "внутрішня помилка" in result.lower()
|
||
|
||
def test_invalid_json(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = MagicMock()
|
||
mock_resp.status_code = 200
|
||
mock_resp.json.side_effect = ValueError("not json")
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "json" in result.lower() or "розібрати" in result.lower()
|
||
|
||
def test_never_reveals_url(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://secret-farmos.internal")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "secrettoken")
|
||
mock_resp = _make_json_resp([_make_log_item()], status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
assert "secret-farmos" not in result
|
||
assert "secrettoken" not in result
|
||
|
||
def test_never_raises(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get",
|
||
side_effect=RuntimeError("boom")):
|
||
result = _farmos_read_logs_impl()
|
||
assert isinstance(result, str)
|
||
assert len(result) > 0
|
||
|
||
def test_output_truncated_to_12(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
items = [_make_log_item(f"item {i}") for i in range(20)]
|
||
mock_resp = _make_json_resp(items, status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
lines = [l for l in result.split("\n") if l.startswith("- ")]
|
||
assert len(lines) == 12
|
||
|
||
def test_notes_truncated_to_80(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
long_notes = "A" * 200
|
||
items = [_make_log_item("Test", 1700000000, long_notes)]
|
||
mock_resp = _make_json_resp(items, status=200)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = _farmos_read_logs_impl()
|
||
line = [l for l in result.split("\n") if l.startswith("- ")][0]
|
||
notes_part = line.split("|")[-1].strip()
|
||
assert len(notes_part) <= 80
|
||
|
||
|
||
# ─── /farmos logs operator command ────────────────────────────────────────────
|
||
|
||
class TestFarmosLogsCommand:
|
||
def test_logs_no_env(self):
|
||
result = handle_farmos_status(["logs"])
|
||
assert isinstance(result, dict)
|
||
assert "FARMOS_BASE_URL" in result["summary"] or "FarmOS" in result["summary"]
|
||
|
||
def test_logs_with_log_type(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
items = [_make_log_item("Сівба", 1700000000, "пшениця")]
|
||
mock_resp = _make_json_resp(items)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = handle_farmos_status(["logs", "seeding"])
|
||
assert "Сівба" in result["summary"]
|
||
|
||
def test_logs_with_invalid_log_type(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
result = handle_farmos_status(["logs", "badtype"])
|
||
assert "log_type" in result["summary"] or "має бути" in result["summary"]
|
||
|
||
def test_logs_with_limit(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
items = [_make_log_item(f"item {i}") for i in range(5)]
|
||
mock_resp = _make_json_resp(items)
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = handle_farmos_status(["logs", "activity", "5"])
|
||
lines = [l for l in result["summary"].split("\n") if l.startswith("- ")]
|
||
assert len(lines) == 5
|
||
|
||
def test_logs_invalid_limit_defaults(self, monkeypatch):
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = _make_json_resp([_make_log_item()])
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = handle_farmos_status(["logs", "activity", "notanumber"])
|
||
assert isinstance(result, dict)
|
||
|
||
def test_unknown_subcommand_updated_message(self):
|
||
result = handle_farmos_status(["unknown_sub"])
|
||
assert "підтримується" in result["summary"].lower()
|
||
assert "logs" in result["summary"].lower()
|
||
|
||
def test_internal_error_fallback_logs(self):
|
||
with patch("agromatrix_tools.tool_farmos_read._farmos_read_logs_impl",
|
||
side_effect=RuntimeError("crash")):
|
||
result = handle_farmos_status(["logs"])
|
||
assert "помилка" in result["summary"].lower() or "FarmOS" in result["summary"]
|
||
|
||
def test_status_still_works(self, monkeypatch):
|
||
"""Переконуємось що farmos status не зломано v4.4."""
|
||
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
|
||
monkeypatch.setenv("FARMOS_TOKEN", "tok")
|
||
mock_resp = MagicMock()
|
||
mock_resp.status_code = 200
|
||
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
|
||
result = handle_farmos_status([])
|
||
assert "доступний" in result["summary"].lower()
|