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
This commit is contained in:
Apple
2026-03-03 07:14:14 -08:00
parent e9dedffa48
commit 129e4ea1fc
241 changed files with 69349 additions and 0 deletions

View File

@@ -0,0 +1,442 @@
"""
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}"