Files
microdao-daarion/tests/test_stepan_v44_farmos_logs.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

341 lines
16 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_v44_farmos_logs.py
Unit tests for v4.4 FarmOS read-only logs tool + connection error normalization.
Scope:
- agromatrix_tools.tool_farmos_read._farmos_read_logs_impl (fail-closed)
- agromatrix_tools.tool_farmos_read._classify_exception (error normalization)
- crews.agromatrix_crew.operator_commands.handle_farmos_status (logs subcommand)
"""
from __future__ import annotations
import json
import os
import sys
import types
import pytest
from unittest.mock import patch, MagicMock
# ── Шляхи ─────────────────────────────────────────────────────────────────────
_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)
# ── Мок зовнішніх модулів ─────────────────────────────────────────────────────
def _stub_module(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_module(_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 importlib as _il
if "agromatrix_tools.tool_farmos_read" in sys.modules:
_il.reload(sys.modules["agromatrix_tools.tool_farmos_read"])
from agromatrix_tools.tool_farmos_read import ( # noqa: E402
_farmos_read_logs_impl,
_classify_exception,
)
from crews.agromatrix_crew.operator_commands import handle_farmos_status # noqa: E402
# ── Fixtures ───────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def clear_farmos_env(monkeypatch):
for k in ("FARMOS_BASE_URL", "FARMOS_TOKEN", "FARMOS_USER", "FARMOS_PASS", "FARMOS_PASSWORD"):
monkeypatch.delenv(k, raising=False)
yield
def _make_json_resp(items: list, status: int = 200) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.json.return_value = {"data": items}
return resp
def _make_log_item(name: str = "Полив", ts: int = 1700000000, notes: str = "без змін") -> dict:
return {
"type": "log--activity",
"id": "abc-123",
"attributes": {
"name": name,
"timestamp": ts,
"notes": {"value": notes},
},
}
# ─── _classify_exception ──────────────────────────────────────────────────────
class TestClassifyException:
def test_timeout(self):
import requests
assert _classify_exception(requests.exceptions.Timeout()) == "timeout"
def test_connect(self):
import requests
assert _classify_exception(requests.exceptions.ConnectionError("refused")) == "connect"
def test_dns(self):
exc = Exception("Name or service not known")
assert _classify_exception(exc) == "dns"
def test_ssl(self):
exc = Exception("SSL: CERTIFICATE_VERIFY_FAILED")
assert _classify_exception(exc) == "ssl"
def test_other(self):
exc = ValueError("something")
assert _classify_exception(exc) == "other"
# ─── _farmos_read_logs_impl ───────────────────────────────────────────────────
class TestFarmosReadLogsImpl:
# ── Config guards ──────────────────────────────────────────────────────────
def test_no_base_url(self):
result = _farmos_read_logs_impl()
assert "FARMOS_BASE_URL" in result
def test_base_url_no_auth(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
result = _farmos_read_logs_impl()
assert "токен" in result.lower() or "логін" in result.lower()
def test_invalid_log_type(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
result = _farmos_read_logs_impl(log_type="badtype")
assert "log_type має бути" in result
# Whitelist перераховано у відповіді
for t in ("activity", "observation"):
assert t in result
def test_limit_clamped_min(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _make_json_resp([], status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl(limit=-5)
# Не падає, повертає "записів не знайдено"
assert isinstance(result, str)
def test_limit_clamped_max(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_make_log_item(f"item {i}") for i in range(30)]
mock_resp = _make_json_resp(items, status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl(limit=100)
# Обмежено до _LOG_OUTPUT_MAX_LINES=12
lines = [l for l in result.split("\n") if l.strip().startswith("-")]
assert len(lines) <= 12
# ── HTTP responses ─────────────────────────────────────────────────────────
def test_200_empty(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _make_json_resp([], status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "записів не знайдено" in result
def test_200_with_items(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [
_make_log_item("Внесення добрив", 1700010000, "карбамід 200кг/га"),
_make_log_item("Оранка", 1700020000, "глибина 25см"),
]
mock_resp = _make_json_resp(items, status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "Внесення добрив" in result
assert "Оранка" in result
assert "2023-" in result # перевіряємо що дата парситься
def test_200_output_format(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_make_log_item("Тест", 1700000000, "примітка")]
mock_resp = _make_json_resp(items, status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
# Кожен рядок має формат "- name | date | notes"
lines = [l for l in result.split("\n") if l.strip()]
assert len(lines) >= 1
assert lines[0].startswith("- ")
assert lines[0].count("|") >= 2
def test_404(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 404
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "404" in result or "не знайдено" in result
def test_401(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "badtok")
mock_resp = MagicMock()
mock_resp.status_code = 401
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "401" in result or "авторизац" in result.lower()
def test_500(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 500
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "500" in result or "помилка" in result.lower()
def test_timeout(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
import requests as _r
with patch("agromatrix_tools.tool_farmos_read.requests.get",
side_effect=_r.exceptions.Timeout()):
result = _farmos_read_logs_impl()
assert "timeout" in result.lower()
def test_connection_error_dns(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
exc = Exception("Name or service not known")
with patch("agromatrix_tools.tool_farmos_read.requests.get", side_effect=exc):
result = _farmos_read_logs_impl()
assert isinstance(result, str)
assert "внутрішня помилка" in result.lower()
def test_invalid_json(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.side_effect = ValueError("not json")
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "json" in result.lower() or "розібрати" in result.lower()
def test_never_reveals_url(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://secret-farmos.internal")
monkeypatch.setenv("FARMOS_TOKEN", "secrettoken")
mock_resp = _make_json_resp([_make_log_item()], status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
assert "secret-farmos" not in result
assert "secrettoken" not in result
def test_never_raises(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
with patch("agromatrix_tools.tool_farmos_read.requests.get",
side_effect=RuntimeError("boom")):
result = _farmos_read_logs_impl()
assert isinstance(result, str)
assert len(result) > 0
def test_output_truncated_to_12(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_make_log_item(f"item {i}") for i in range(20)]
mock_resp = _make_json_resp(items, status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
lines = [l for l in result.split("\n") if l.startswith("- ")]
assert len(lines) == 12
def test_notes_truncated_to_80(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
long_notes = "A" * 200
items = [_make_log_item("Test", 1700000000, long_notes)]
mock_resp = _make_json_resp(items, status=200)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_read_logs_impl()
line = [l for l in result.split("\n") if l.startswith("- ")][0]
notes_part = line.split("|")[-1].strip()
assert len(notes_part) <= 80
# ─── /farmos logs operator command ────────────────────────────────────────────
class TestFarmosLogsCommand:
def test_logs_no_env(self):
result = handle_farmos_status(["logs"])
assert isinstance(result, dict)
assert "FARMOS_BASE_URL" in result["summary"] or "FarmOS" in result["summary"]
def test_logs_with_log_type(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_make_log_item("Сівба", 1700000000, "пшениця")]
mock_resp = _make_json_resp(items)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = handle_farmos_status(["logs", "seeding"])
assert "Сівба" in result["summary"]
def test_logs_with_invalid_log_type(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
result = handle_farmos_status(["logs", "badtype"])
assert "log_type" in result["summary"] or "має бути" in result["summary"]
def test_logs_with_limit(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_make_log_item(f"item {i}") for i in range(5)]
mock_resp = _make_json_resp(items)
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = handle_farmos_status(["logs", "activity", "5"])
lines = [l for l in result["summary"].split("\n") if l.startswith("- ")]
assert len(lines) == 5
def test_logs_invalid_limit_defaults(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _make_json_resp([_make_log_item()])
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = handle_farmos_status(["logs", "activity", "notanumber"])
assert isinstance(result, dict)
def test_unknown_subcommand_updated_message(self):
result = handle_farmos_status(["unknown_sub"])
assert "підтримується" in result["summary"].lower()
assert "logs" in result["summary"].lower()
def test_internal_error_fallback_logs(self):
with patch("agromatrix_tools.tool_farmos_read._farmos_read_logs_impl",
side_effect=RuntimeError("crash")):
result = handle_farmos_status(["logs"])
assert "помилка" in result["summary"].lower() or "FarmOS" in result["summary"]
def test_status_still_works(self, monkeypatch):
"""Переконуємось що farmos status не зломано v4.4."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = handle_farmos_status([])
assert "доступний" in result["summary"].lower()