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
407 lines
18 KiB
Python
407 lines
18 KiB
Python
"""
|
||
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
|