""" 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()