""" tests/test_stepan_v46_farm_state.py Unit tests for v4.6 /farm state operator command. Scope: - handle_farm_command (routing + unknown subcommand) - _handle_farm_state (full flow: ping → assets → snapshot → save) - _parse_asset_lines / _label_from_asset_line / _build_snapshot_text - _save_farm_state_snapshot (fail-closed, no leaking) """ from __future__ import annotations import json import os import sys import types import pytest from unittest.mock import patch, MagicMock # ── Paths ────────────────────────────────────────────────────────────────────── _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) # ── Mocks for external deps ──────────────────────────────────────────────────── def _stub(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(_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 targets ───────────────────────────────────────────────────────────── import importlib as _il if "agromatrix_tools.tool_farmos_read" in sys.modules: _il.reload(sys.modules["agromatrix_tools.tool_farmos_read"]) if "crews.agromatrix_crew.operator_commands" in sys.modules: _il.reload(sys.modules["crews.agromatrix_crew.operator_commands"]) from crews.agromatrix_crew.operator_commands import ( # noqa: E402 handle_farm_command, _parse_asset_lines, _label_from_asset_line, _build_snapshot_text, _save_farm_state_snapshot, OPERATOR_COMMANDS, parse_operator_command, ) # ── Fixtures ─────────────────────────────────────────────────────────────────── @pytest.fixture(autouse=True) def clear_farmos_env(monkeypatch): for k in ("FARMOS_BASE_URL", "FARMOS_TOKEN", "FARMOS_USER", "FARMOS_PASS", "FARMOS_PASSWORD", "AGX_MEMORY_SERVICE_URL", "MEMORY_SERVICE_URL"): monkeypatch.delenv(k, raising=False) yield def _sample_asset_text(labels: list[str]) -> str: """Генерує рядки у форматі farmos_search_assets_impl.""" lines = [f"- {lbl} | asset--land | id=abc12345" for lbl in labels] return "\n".join(lines) # ─── OPERATOR_COMMANDS registry ─────────────────────────────────────────────── class TestFarmCommandRegistry: def test_farm_in_operator_commands(self): assert "farm" in OPERATOR_COMMANDS def test_parse_farm_state(self): parsed = parse_operator_command("/farm state") assert parsed is not None assert parsed["cmd"] == "farm" assert "state" in parsed["args"] def test_parse_farm_no_args(self): parsed = parse_operator_command("/farm") assert parsed is not None assert parsed["cmd"] == "farm" assert parsed["args"] == [] # ─── handle_farm_command ────────────────────────────────────────────────────── class TestHandleFarmCommand: def test_no_args_returns_help(self): result = handle_farm_command([]) assert "підтримується" in result["summary"].lower() assert "/farm state" in result["summary"] def test_unknown_sub_returns_help(self): result = handle_farm_command(["foo"]) assert "підтримується" in result["summary"].lower() def test_returns_dict_with_required_keys(self): result = handle_farm_command([]) for k in ("status", "summary", "artifacts", "tool_calls", "next_actions"): assert k in result def test_state_no_farmos_env(self): """Без FARMOS_BASE_URL → повертає config message, не падає.""" result = handle_farm_command(["state"], chat_id="test_chat") assert "FarmOS" in result["summary"] assert "FARMOS_BASE_URL" in result["summary"] or "налаштований" in result["summary"] def test_state_ping_unreachable(self, monkeypatch): """Якщо ping повертає 'недоступний' → command зупиняється.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com") monkeypatch.setenv("FARMOS_TOKEN", "tok") with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl", return_value="FarmOS недоступний: timeout (3s)."): result = handle_farm_command(["state"], chat_id="chat1") assert "недоступний" in result["summary"] assert "timeout" in result["summary"] def test_state_ping_ok_no_assets(self, monkeypatch): """Ping OK, але активів немає → повертає 'немає даних'.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com") monkeypatch.setenv("FARMOS_TOKEN", "tok") with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl", return_value="FarmOS доступний."): with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl", return_value="FarmOS: нічого не знайдено."): result = handle_farm_command(["state"], chat_id="chat1") assert "немає даних" in result["summary"] or "FarmOS" in result["summary"] def test_state_ping_ok_with_assets(self, monkeypatch): """Ping OK, активи знайдено → snapshot contains field count.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com") monkeypatch.setenv("FARMOS_TOKEN", "tok") def _mock_search(asset_type="asset_land", name_contains="", limit=10): if asset_type == "asset_land": return _sample_asset_text(["Поле Північ", "Поле Південь"]) if asset_type == "asset_plant": return _sample_asset_text(["Пшениця"]) return "FarmOS: нічого не знайдено." with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl", return_value="FarmOS доступний."): with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl", side_effect=_mock_search): with patch("crews.agromatrix_crew.operator_commands._save_farm_state_snapshot", return_value=True): result = handle_farm_command(["state"], chat_id="chat1") assert "Farm state" in result["summary"] assert "Поля" in result["summary"] assert "2" in result["summary"] # 2 fields found def test_state_snapshot_save_failure_appends_suffix(self, monkeypatch): """Якщо memory save fails → suffix додається до відповіді.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com") monkeypatch.setenv("FARMOS_TOKEN", "tok") with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl", return_value="FarmOS доступний."): with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl", return_value=_sample_asset_text(["Поле 1"])): with patch("crews.agromatrix_crew.operator_commands._save_farm_state_snapshot", return_value=False): result = handle_farm_command(["state"], chat_id="chat1") assert "пам'ять" in result["summary"].lower() or "зберег" in result["summary"].lower() def test_state_no_chat_id_still_returns_snapshot(self, monkeypatch): """Без chat_id snapshot формується, але не зберігається.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com") monkeypatch.setenv("FARMOS_TOKEN", "tok") with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl", return_value="FarmOS доступний."): with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl", return_value=_sample_asset_text(["Поле 1"])): result = handle_farm_command(["state"], chat_id=None) assert "Farm state" in result["summary"] or "FarmOS" in result["summary"] def test_never_raises_on_exception(self, monkeypatch): """handle_farm_command ніколи не кидає виняток.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com") monkeypatch.setenv("FARMOS_TOKEN", "tok") with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl", side_effect=RuntimeError("boom")): result = handle_farm_command(["state"], chat_id="chat1") assert isinstance(result, dict) assert "summary" in result # ─── _parse_asset_lines ─────────────────────────────────────────────────────── class TestParseAssetLines: def test_normal_lines(self): text = "- Поле А | asset--land | id=abc12345\n- Поле Б | asset--land | id=def56789" lines = _parse_asset_lines(text) assert len(lines) == 2 assert all(l.startswith("- ") for l in lines) def test_ignores_non_dash_lines(self): text = "FarmOS: нічого не знайдено.\nsome garbage" lines = _parse_asset_lines(text) assert lines == [] def test_empty_string(self): assert _parse_asset_lines("") == [] def test_mixed_content(self): text = "- Поле 1 | asset--land | id=aaa\nFarmOS: записів не знайдено.\n- Поле 2 | asset--land | id=bbb" lines = _parse_asset_lines(text) assert len(lines) == 2 def test_never_raises(self): result = _parse_asset_lines(None) # type: ignore[arg-type] assert isinstance(result, list) # ─── _label_from_asset_line ─────────────────────────────────────────────────── class TestLabelFromAssetLine: def test_normal(self): assert _label_from_asset_line("- Поле Північ | asset--land | id=abc") == "Поле Північ" def test_no_pipe(self): assert _label_from_asset_line("- Просто назва") == "Просто назва" def test_strips_dash(self): result = _label_from_asset_line("- Тест | x | y") assert result == "Тест" def test_empty(self): result = _label_from_asset_line("") assert isinstance(result, str) # ─── _build_snapshot_text ───────────────────────────────────────────────────── class TestBuildSnapshotText: def test_all_zeros_returns_no_data(self): text = _build_snapshot_text( {"asset_land": 0, "asset_plant": 0, "asset_equipment": 0}, {"asset_land": [], "asset_plant": [], "asset_equipment": []}, ) assert "немає даних" in text def test_with_counts_shows_label(self): text = _build_snapshot_text( {"asset_land": 3, "asset_plant": 1, "asset_equipment": 0}, {"asset_land": ["Поле А", "Поле Б", "Поле В"], "asset_plant": ["Кукурудза"], "asset_equipment": []}, ) assert "Поля" in text assert "3" in text assert "Поле А" in text def test_max_chars_respected(self): tops = {"asset_land": ["А" * 200], "asset_plant": [], "asset_equipment": []} counts = {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0} text = _build_snapshot_text(counts, tops) assert len(text) <= 900 def test_starts_with_header(self): text = _build_snapshot_text( {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0}, {"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []}, ) assert text.startswith("Farm state") def test_top_3_labels_shown(self): tops = {"asset_land": ["A", "B", "C", "D"], "asset_plant": [], "asset_equipment": []} counts = {"asset_land": 4, "asset_plant": 0, "asset_equipment": 0} text = _build_snapshot_text(counts, tops) assert "A" in text assert "B" in text assert "C" in text # D — 4-й, має не показуватись assert "D" not in text # ─── _save_farm_state_snapshot ──────────────────────────────────────────────── class TestSaveFarmStateSnapshot: def test_returns_true_on_200(self): mock_resp = MagicMock() mock_resp.status_code = 200 with patch("httpx.post", return_value=mock_resp): result = _save_farm_state_snapshot( "chat123", {"asset_land": 2, "asset_plant": 0, "asset_equipment": 0}, {"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []}, "Farm state (FarmOS):\n- Поля: 2", ) assert result is True def test_returns_true_on_201(self): mock_resp = MagicMock() mock_resp.status_code = 201 with patch("httpx.post", return_value=mock_resp): result = _save_farm_state_snapshot("chat1", {}, {}, "text") assert result is True def test_returns_false_on_500(self): mock_resp = MagicMock() mock_resp.status_code = 500 with patch("httpx.post", return_value=mock_resp): result = _save_farm_state_snapshot("chat1", {}, {}, "text") assert result is False def test_returns_false_on_exception(self): with patch("httpx.post", side_effect=RuntimeError("network fail")): result = _save_farm_state_snapshot("chat1", {}, {}, "text") assert result is False def test_payload_contains_required_fields(self): captured = {} mock_resp = MagicMock() mock_resp.status_code = 200 def _capture_post(url, *, json, timeout): captured.update(json) return mock_resp with patch("httpx.post", side_effect=_capture_post): _save_farm_state_snapshot( "chat999", {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0}, {"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []}, "Farm state (FarmOS):\n- Поля: 1", ) assert captured.get("user_id") == "farm:chat999" assert captured.get("fact_key") == "farm_state:agromatrix:chat:chat999" val = captured.get("fact_value_json", {}) assert val.get("source") == "farmos" assert val.get("_version") == 1 assert "generated_at" in val assert "counts" in val assert "top" in val assert "text" in val def test_payload_no_full_uuid(self): """top labels не містять повних UUID — тільки short_id або відсутній.""" captured = {} mock_resp = MagicMock() mock_resp.status_code = 200 def _capture_post(url, *, json, timeout): captured.update(json) return mock_resp full_uuid = "12345678-abcd-ef01-2345-67890abcdef0" with patch("httpx.post", side_effect=_capture_post): _save_farm_state_snapshot( "chatX", {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0}, {"asset_land": ["Поле А"], "asset_plant": [], "asset_equipment": []}, "text", ) val_str = json.dumps(captured) assert full_uuid not in val_str def test_memory_url_from_env(self, monkeypatch): """Використовує AGX_MEMORY_SERVICE_URL з env.""" monkeypatch.setenv("AGX_MEMORY_SERVICE_URL", "http://custom-memory:9999") captured_url = {} mock_resp = MagicMock() mock_resp.status_code = 200 def _capture_post(url, *, json, timeout): captured_url["url"] = url return mock_resp with patch("httpx.post", side_effect=_capture_post): _save_farm_state_snapshot("c1", {}, {}, "t") assert "custom-memory:9999" in captured_url.get("url", "") def test_never_reveals_farmos_url(self, monkeypatch): """URL farmOS ніколи не потрапляє у payload.""" monkeypatch.setenv("FARMOS_BASE_URL", "http://secret-farmos.internal") captured = {} mock_resp = MagicMock() mock_resp.status_code = 200 def _capture_post(url, *, json, timeout): captured.update(json) return mock_resp with patch("httpx.post", side_effect=_capture_post): _save_farm_state_snapshot("c1", {"asset_land": 1}, {"asset_land": ["F"]}, "text") val_str = json.dumps(captured) assert "secret-farmos" not in val_str