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
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""
|
||
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
|