Files
microdao-daarion/tests/test_stepan_v47_farm_state_bridge.py
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
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
2026-03-03 07:14:14 -08:00

327 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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