""" tests/test_stepan_v47_farm_state_bridge.py Unit tests for v4.7 FarmOS Farm State Bridge in run.py: - _load_farm_state_snapshot: reads fact from memory-service - TTL enforcement (24h) - Fail-closed: HTTP errors, JSON errors, missing fields → None - PII: no raw chat_id in URL (synthetic uid used) - Integration guard: injected into task_parts only in non-doc / non-web mode """ from __future__ import annotations import json import os import sys import types import pytest from datetime import datetime, timezone, timedelta 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, _ROOT): if _p not in sys.path: sys.path.insert(0, _p) # ── Minimal stubs so run.py importable without full stack ────────────────────── def _stub(name: str) -> types.ModuleType: m = types.ModuleType(name) sys.modules.setdefault(name, m) return sys.modules[name] # crewai _crewai = _stub("crewai") _crewai.Crew = type("Crew", (), {"__init__": lambda *a, **k: None, "kickoff": lambda *a, **k: ""}) _crewai.Task = type("Task", (), {"__init__": lambda *a, **k: None}) # langchain_core _lcc = _stub("langchain_core") _lcc_tools = _stub("langchain_core.tools") _lcc_tools.Tool = type("Tool", (), {"__init__": lambda *a, **k: None}) # agromatrix_tools _agt = _stub("agromatrix_tools") _agt.tool_dictionary = _stub("agromatrix_tools.tool_dictionary") _agt.tool_operation_plan = _stub("agromatrix_tools.tool_operation_plan") _stub("agromatrix_tools.tool_farmos_read") # crews modules stubs for _mod in [ "crews.agromatrix_crew.agents.stepan_orchestrator", "crews.agromatrix_crew.agents.operations_agent", "crews.agromatrix_crew.agents.iot_agent", "crews.agromatrix_crew.agents.platform_agent", "crews.agromatrix_crew.agents.spreadsheet_agent", "crews.agromatrix_crew.agents.sustainability_agent", "crews.agromatrix_crew.audit", "crews.agromatrix_crew.operator_commands", "crews.agromatrix_crew.memory_manager", "crews.agromatrix_crew.style_adapter", "crews.agromatrix_crew.reflection_engine", "crews.agromatrix_crew.light_reply", "crews.agromatrix_crew.depth_classifier", "crews.agromatrix_crew.telemetry", "crews.agromatrix_crew.session_context", "crews.agromatrix_crew.proactivity", "crews.agromatrix_crew.doc_facts", "crews.agromatrix_crew.doc_focus", "crews.agromatrix_crew.farm_state", ]: m = _stub(_mod) # Set needed attributes m.route_operator_command = lambda *a, **k: None m.route_operator_text = lambda *a, **k: None m.load_user_profile = lambda *a, **k: {} m.save_user_profile = lambda *a, **k: None m.load_farm_profile = lambda *a, **k: {} m.save_farm_profile = lambda *a, **k: None m.update_profile_if_needed = lambda *a, **k: {} m.adapt_response_style = lambda *a, **k: "" m.build_style_prefix = lambda *a, **k: "" m.reflect_on_response = lambda *a, **k: "" m.build_light_reply = lambda *a, **k: None m.classify_light_event = lambda *a, **k: None m.classify_depth = lambda *a, **k: "deep" m.tlog = lambda *a, **k: None m.load_session = lambda *a, **k: {} m.update_session = lambda *a, **k: None m.is_doc_focus_active = lambda *a, **k: False m.is_doc_focus_cooldown_active = lambda *a, **k: False m.DOC_FOCUS_TTL = 600 m.DOC_FOCUS_COOLDOWN_S = 60 m.maybe_add_proactivity = lambda *a, **k: "" m.extract_doc_facts = lambda *a, **k: {} m.merge_doc_facts = lambda *a, **k: {} m.can_answer_from_facts = lambda *a, **k: (False, None) m.compute_scenario = lambda *a, **k: None m.format_facts_as_text = lambda *a, **k: "" m.extract_fact_claims = lambda *a, **k: [] m.build_self_correction = lambda *a, **k: "" m._is_doc_question = lambda *a, **k: False m._detect_domain = lambda *a, **k: "general" m.detect_context_signals = lambda *a, **k: {} m.build_mode_clarifier = lambda *a, **k: "" m.detect_farm_state_updates = lambda *a, **k: {} m.update_farm_state = lambda *a, **k: None m.build_farm_state_prefix = lambda *a, **k: "" m.audit_event = lambda *a, **k: None m.new_trace = lambda *a, **k: {"trace_id": "t1"} m.build_stepan = lambda *a, **k: MagicMock() m.build_operations = lambda *a, **k: MagicMock() m.build_iot = lambda *a, **k: MagicMock() m.build_platform = lambda *a, **k: MagicMock() m.build_spreadsheet = lambda *a, **k: MagicMock() m.build_sustainability = lambda *a, **k: MagicMock() # stub subprocess, vision_guard _stub("subprocess") _vg = _stub("vision_guard") _vg.get_vision_lock = lambda *a, **k: {} # Now import the function under test import importlib import crews.agromatrix_crew.run as _run_mod importlib.reload(_run_mod) from crews.agromatrix_crew.run import _load_farm_state_snapshot, _FARM_STATE_SNAPSHOT_TTL_S # ── Helpers ─────────────────────────────────────────────────────────────────── def _fresh_ts(age_seconds: float = 0.0) -> str: ts = datetime.now(timezone.utc) - timedelta(seconds=age_seconds) return ts.isoformat() def _make_resp(text: str, age_s: float = 0.0, status: int = 200) -> MagicMock: # memory-service повертає fact_value_json як рядок (str) або dict залежно від версії # наш код підтримує обидва варіанти через isinstance(val, str) → json.loads payload = { "id": "test-uuid", "user_id": "farm:test", "fact_key": "farm_state:agromatrix:chat:test", "fact_value_json": json.dumps({ "_version": 1, "source": "farmos", "generated_at": _fresh_ts(age_s), "counts": {"asset_land": 2, "asset_plant": 1, "asset_equipment": 0}, "top": {"asset_land": ["Поле A", "Поле B"], "asset_plant": [], "asset_equipment": []}, "text": text, }) } r = MagicMock() r.status_code = status r.json.return_value = payload return r # ── Tests: _load_farm_state_snapshot ───────────────────────────────────────── class TestLoadFarmStateSnapshot: def test_returns_text_when_fresh(self): with patch("httpx.get", return_value=_make_resp("Farm state (FarmOS):\n- Поля: 2")) as mock_get: result = _load_farm_state_snapshot("chat_123") assert result == "Farm state (FarmOS):\n- Поля: 2" def test_uses_synthetic_uid_not_raw_chat_id(self): """PII: URL params must use synthetic uid farm:{chat_id}, not raw chat_id directly as user.""" captured = {} def fake_get(url, params=None, timeout=None): captured["params"] = params or {} return _make_resp("text") with patch("httpx.get", side_effect=fake_get): _load_farm_state_snapshot("sensitive_42") uid = captured["params"].get("user_id", "") assert uid == "farm:sensitive_42", f"Expected synthetic uid, got: {uid!r}" assert "sensitive_42" not in captured["params"].get("fact_key", "").split(":")[0], \ "chat_id should not appear as user_id directly" def test_correct_fact_key(self): captured = {} def fake_get(url, params=None, timeout=None): captured["params"] = params or {} return _make_resp("text") with patch("httpx.get", side_effect=fake_get): _load_farm_state_snapshot("chat_999") assert captured["params"].get("fact_key") == "farm_state:agromatrix:chat:chat_999" def test_uses_correct_endpoint_path(self): """URL повинен бути /facts/{fact_key} а не /facts/get.""" captured = {} def fake_get(url, params=None, timeout=None): captured["url"] = url captured["params"] = params or {} return _make_resp("text") with patch("httpx.get", side_effect=fake_get): _load_farm_state_snapshot("chat_ep_test") assert "/facts/farm_state:agromatrix:chat:chat_ep_test" in captured["url"] assert "/facts/get" not in captured["url"] def test_returns_none_when_non_200(self): with patch("httpx.get", return_value=_make_resp("text", status=404)): result = _load_farm_state_snapshot("chat_x") assert result is None def test_returns_none_when_snapshot_older_than_24h(self): with patch("httpx.get", return_value=_make_resp("text", age_s=86401.0)): result = _load_farm_state_snapshot("chat_x") assert result is None def test_returns_text_when_snapshot_just_within_24h(self): with patch("httpx.get", return_value=_make_resp("ok_text", age_s=86399.0)): result = _load_farm_state_snapshot("chat_x") assert result == "ok_text" def test_returns_none_on_connection_error(self): import httpx as _httpx with patch("httpx.get", side_effect=_httpx.ConnectError("fail")): result = _load_farm_state_snapshot("chat_x") assert result is None def test_returns_none_on_timeout(self): import httpx as _httpx with patch("httpx.get", side_effect=_httpx.ReadTimeout("t/o")): result = _load_farm_state_snapshot("chat_x") assert result is None def test_returns_none_when_fact_value_missing(self): r = MagicMock() r.status_code = 200 r.json.return_value = {} with patch("httpx.get", return_value=r): result = _load_farm_state_snapshot("chat_x") assert result is None def test_returns_none_when_text_empty(self): payload = { "fact_value_json": json.dumps({ "source": "farmos", "generated_at": _fresh_ts(), "text": "", }) } r = MagicMock() r.status_code = 200 r.json.return_value = payload with patch("httpx.get", return_value=r): result = _load_farm_state_snapshot("chat_x") assert result is None def test_handles_fact_value_as_plain_json_string(self): """fact_value (not fact_value_json) as a JSON string also works.""" payload = { "fact_value": json.dumps({ "source": "farmos", "generated_at": _fresh_ts(), "text": "snapshot from fact_value", }) } r = MagicMock() r.status_code = 200 r.json.return_value = payload with patch("httpx.get", return_value=r): result = _load_farm_state_snapshot("chat_x") assert result == "snapshot from fact_value" def test_returns_none_when_data_not_dict(self): """If fact_value_json parses to a list — return None.""" payload = {"fact_value_json": json.dumps(["bad", "data"])} r = MagicMock() r.status_code = 200 r.json.return_value = payload with patch("httpx.get", return_value=r): result = _load_farm_state_snapshot("chat_x") assert result is None def test_uses_memory_service_url_env(self): captured = {} def fake_get(url, params=None, timeout=None): captured["url"] = url return _make_resp("text") with patch.dict(os.environ, {"MEMORY_SERVICE_URL": "http://custom-mem:9999"}): with patch("httpx.get", side_effect=fake_get): _load_farm_state_snapshot("chat_x") assert "custom-mem:9999" in captured.get("url", "") def test_uses_agx_memory_service_url_env_priority(self): """AGX_MEMORY_SERVICE_URL takes priority over MEMORY_SERVICE_URL.""" captured = {} def fake_get(url, params=None, timeout=None): captured["url"] = url return _make_resp("text") with patch.dict(os.environ, { "MEMORY_SERVICE_URL": "http://old-mem:8000", "AGX_MEMORY_SERVICE_URL": "http://new-mem:7777", }): with patch("httpx.get", side_effect=fake_get): _load_farm_state_snapshot("chat_x") assert "new-mem:7777" in captured.get("url", "") def test_timeout_is_2s(self): captured = {} def fake_get(url, params=None, timeout=None): captured["timeout"] = timeout return _make_resp("text") with patch("httpx.get", side_effect=fake_get): _load_farm_state_snapshot("chat_x") assert captured.get("timeout") == 2.0 def test_missing_generated_at_still_returns_text(self): """If generated_at is absent, we can't enforce TTL — still return text.""" payload = { "fact_value_json": json.dumps({ "source": "farmos", "text": "snapshot without date", }) } r = MagicMock() r.status_code = 200 r.json.return_value = payload with patch("httpx.get", return_value=r): result = _load_farm_state_snapshot("chat_x") assert result == "snapshot without date" def test_farm_state_snapshot_ttl_constant_is_24h(self): assert _FARM_STATE_SNAPSHOT_TTL_S == 86400.0