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
443 lines
19 KiB
Python
443 lines
19 KiB
Python
"""
|
||
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}"
|