""" 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}"