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

443 lines
19 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_v45_farmos_assets.py
Unit tests for:
- Patch 1: _safe_notes (whitespace normalization + word-boundary truncation)
- Patch 2: _extract_name, _extract_label, _extract_date (extended JSON:API mapping)
- Patch 3: _parse_jsonapi_list (data not list / not dict guards)
- v4.5: _farmos_search_assets_impl (fail-closed asset search)
"""
from __future__ import annotations
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")
for _p in (_PKG,):
if _p not in sys.path:
sys.path.insert(0, _p)
# ── Mocks ──────────────────────────────────────────────────────────────────────
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
_safe_notes,
_extract_name,
_extract_label,
_extract_date,
_parse_jsonapi_list,
_farmos_search_assets_impl,
farmos_search_assets,
)
# ── 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 _mock_resp(data, status: int = 200) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.json.return_value = data
return resp
def _asset_item(label: str, asset_type: str = "asset--land", item_id: str = "abcdef12-3456-7890") -> dict:
return {
"type": asset_type,
"id": item_id,
"attributes": {"label": label},
}
# ─── Patch 1: _safe_notes ─────────────────────────────────────────────────────
class TestSafeNotes:
def test_short_unchanged(self):
assert _safe_notes("hello world") == "hello world"
def test_strips_leading_trailing(self):
assert _safe_notes(" hello ") == "hello"
def test_normalizes_tabs(self):
assert _safe_notes("a\tb") == "a b"
def test_normalizes_newlines(self):
assert _safe_notes("a\nb\nc") == "a b c"
def test_normalizes_multiple_spaces(self):
assert _safe_notes("a b c") == "a b c"
def test_truncates_to_80(self):
long = "word " * 30 # 150 chars
result = _safe_notes(long)
assert len(result) <= 80
def test_truncates_on_word_boundary(self):
# 80 chars that split a word
s = "а " * 41 # "а а а а ..." — кожне слово 1 символ
result = _safe_notes(s.strip(), max_len=10)
# Не повинно закінчуватись посередині слова (пробіл)
assert not result.endswith(" ")
def test_no_utf8_split(self):
# Рядок з кириличними символами — не повинен розрізати UTF-8 символ
s = "абвгдежзиклмн " * 5 # ~70 символів
result = _safe_notes(s, max_len=20)
assert isinstance(result, str)
assert len(result) <= 20
def test_empty_string(self):
assert _safe_notes("") == ""
def test_exact_80_chars(self):
s = "a" * 80
assert _safe_notes(s) == s
def test_81_chars_no_spaces_truncates_hard(self):
s = "a" * 81
result = _safe_notes(s, max_len=80)
assert len(result) <= 80
# ─── Patch 2: _extract_name / _extract_label / _extract_date ─────────────────
class TestExtractName:
def test_name_field(self):
assert _extract_name({"name": "Поле 1"}) == "Поле 1"
def test_label_fallback(self):
assert _extract_name({"label": "Поле 2"}) == "Поле 2"
def test_type_fallback(self):
assert _extract_name({"type": "activity"}) == "activity"
def test_explicit_fallback(self):
assert _extract_name({}, fallback="def") == "def"
def test_normalizes_whitespace(self):
assert _extract_name({"name": " Поле\t1 "}) == "Поле 1"
def test_prefers_name_over_label(self):
assert _extract_name({"name": "A", "label": "B"}) == "A"
class TestExtractLabel:
def test_label_field(self):
item = {"attributes": {"label": "Поле A"}}
assert _extract_label(item) == "Поле A"
def test_name_fallback(self):
item = {"attributes": {"name": "Поле B"}}
assert _extract_label(item) == "Поле B"
def test_type_fallback(self):
item = {"attributes": {"type": "land"}}
assert _extract_label(item) == "land"
def test_no_attrs(self):
item = {}
assert _extract_label(item) == "(no label)"
def test_non_dict(self):
assert _extract_label("string") == "(no label)" # type: ignore[arg-type]
class TestExtractDate:
def test_timestamp_int(self):
result = _extract_date({"timestamp": 1700000000})
assert result.startswith("2023-")
def test_changed_fallback(self):
result = _extract_date({"changed": 1700000000})
assert result.startswith("2023-")
def test_created_fallback(self):
result = _extract_date({"created": 1700000000})
assert result.startswith("2023-")
def test_string_date(self):
result = _extract_date({"timestamp": "2023-11-15T10:00:00Z"})
assert result == "2023-11-15"
def test_no_date_fields(self):
assert _extract_date({}) == ""
def test_prefers_timestamp_over_changed(self):
r = _extract_date({"timestamp": 1700000000, "changed": 1600000000})
assert r.startswith("2023-")
# ─── Patch 3: _parse_jsonapi_list ─────────────────────────────────────────────
class TestParseJsonapiList:
def test_valid_list(self):
resp = _mock_resp({"data": [{"id": "1"}]})
items, err = _parse_jsonapi_list(resp)
assert err is None
assert len(items) == 1
def test_empty_list(self):
resp = _mock_resp({"data": []})
items, err = _parse_jsonapi_list(resp)
assert err is None
assert items == []
def test_data_none(self):
resp = _mock_resp({"data": None})
items, err = _parse_jsonapi_list(resp)
assert err is None
assert items == []
def test_data_missing(self):
resp = _mock_resp({"other": "value"})
items, err = _parse_jsonapi_list(resp)
assert err is None
assert items == []
def test_data_not_list(self):
"""Patch 3: data is a dict, not list → error string."""
resp = _mock_resp({"data": {"id": "1"}})
items, err = _parse_jsonapi_list(resp)
assert err is not None
assert "не є списком" in err or "неочікуваний" in err
def test_response_not_dict(self):
"""Patch 3: entire response is a list, not dict."""
resp = _mock_resp([{"id": "1"}])
items, err = _parse_jsonapi_list(resp)
assert err is not None
assert "неочікуваний" in err
def test_invalid_json(self):
resp = MagicMock()
resp.json.side_effect = ValueError("no json")
items, err = _parse_jsonapi_list(resp)
assert err is not None
assert "json" in err.lower() or "розібрати" in err.lower()
# ─── v4.5: _farmos_search_assets_impl ────────────────────────────────────────
class TestFarmosSearchAssetsImpl:
# ── Config guards ──────────────────────────────────────────────────────────
def test_no_base_url(self):
result = _farmos_search_assets_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_search_assets_impl()
assert "токен" in result.lower() or "логін" in result.lower()
def test_invalid_asset_type(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
result = _farmos_search_assets_impl(asset_type="badtype")
assert "asset_type має бути" in result
assert "asset_land" in result
def test_limit_clamped(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _mock_resp({"data": []})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl(asset_type="asset_land", limit=100)
assert isinstance(result, str)
def test_name_contains_truncated(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _mock_resp({"data": []})
long_name = "a" * 200
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl(asset_type="asset_land", name_contains=long_name)
# Не падає, повертає рядок
assert isinstance(result, str)
# ── HTTP responses ─────────────────────────────────────────────────────────
def test_200_empty(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _mock_resp({"data": []})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_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 = [
_asset_item("Поле Північ", "asset--land", "aaa-111"),
_asset_item("Поле Південь", "asset--land", "bbb-222"),
]
mock_resp = _mock_resp({"data": items})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl()
assert "Поле Північ" in result
assert "Поле Південь" in result
def test_output_format(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_asset_item("Тест", "asset--land", "12345678-abcd")]
mock_resp = _mock_resp({"data": items})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl()
lines = [l for l in result.split("\n") if l.strip()]
assert lines[0].startswith("- ")
assert lines[0].count("|") >= 2
# id= лише перші 8 символів
assert "id=12345678" in lines[0]
assert "abcd" not in lines[0]
def test_client_side_filter(self, monkeypatch):
"""name_contains фільтрує client-side якщо server не підтримує filter."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [
_asset_item("Поле А"),
_asset_item("Сад Б"),
]
# Перша спроба з filter → повертає всі (сервер ігнорує filter),
# але client-side відфільтровує
mock_resp = _mock_resp({"data": items})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl(asset_type="asset_land", name_contains="поле")
assert "Поле А" in result
assert "Сад Б" not in result
def test_client_side_filter_case_insensitive(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_asset_item("ПОЛЕ ЗАХІД")]
mock_resp = _mock_resp({"data": items})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl(asset_type="asset_land", name_contains="поле")
assert "ПОЛЕ ЗАХІД" in result
def test_output_truncated_to_12(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
items = [_asset_item(f"Поле {i}") for i in range(20)]
mock_resp = _mock_resp({"data": items})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl()
lines = [l for l in result.split("\n") if l.startswith("- ")]
assert len(lines) == 12
def test_line_max_120_chars(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
long_label = "Л" * 200
items = [_asset_item(long_label)]
mock_resp = _mock_resp({"data": items})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl()
for line in result.split("\n"):
assert len(line) <= 120
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_search_assets_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_search_assets_impl()
assert "401" 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_search_assets_impl()
assert "timeout" in result.lower()
def test_connect_error(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.ConnectionError("refused")):
result = _farmos_search_assets_impl()
assert "мережна помилка" in result or "недоступний" in result.lower()
def test_never_reveals_url(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://secret.internal")
monkeypatch.setenv("FARMOS_TOKEN", "secrettoken")
mock_resp = _mock_resp({"data": [_asset_item("Поле")]})
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl()
assert "secret.internal" 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_search_assets_impl()
assert isinstance(result, str)
assert len(result) > 0
def test_data_not_list_handled(self, monkeypatch):
"""Patch 3: якщо data — dict, а не list → зрозумілий вивід."""
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _mock_resp({"data": {"id": "1"}}) # не list!
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl()
assert "неочікуваний" in result or "формат" in result
# ── Tool object ────────────────────────────────────────────────────────────
def test_tool_object_name(self):
assert hasattr(farmos_search_assets, "name")
assert farmos_search_assets.name == "farmos_search_assets"
def test_all_asset_types_accepted(self, monkeypatch):
monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
monkeypatch.setenv("FARMOS_TOKEN", "tok")
mock_resp = _mock_resp({"data": []})
for at in ("asset_land", "asset_plant", "asset_equipment",
"asset_structure", "asset_animal"):
with patch("agromatrix_tools.tool_farmos_read.requests.get", return_value=mock_resp):
result = _farmos_search_assets_impl(asset_type=at)
assert "asset_type має бути" not in result, f"whitelist rejected: {at}"