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
354 lines
14 KiB
Python
354 lines
14 KiB
Python
"""
|
||
Tests для Humanized Stepan v2.8 — Multi-user Farm Model.
|
||
|
||
Покриває:
|
||
1. FarmProfile keyed by chat_id (новий ключ farm_profile:agromatrix:chat:{chat_id})
|
||
2. Lazy migration: legacy key → chat-key (write-through)
|
||
3. Два users одного chat_id ділять один FarmProfile
|
||
4. Conflict detection: наступний user з відмінним legacy → лише лог, не перезаписує
|
||
5. Acceptance сценарій: userA оновлює farm, userB бачить ті ж дані
|
||
6. backward-compat: load_farm_profile без user_id → повертає default (не крашить)
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import patch, MagicMock
|
||
from copy import deepcopy
|
||
|
||
root = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(root))
|
||
sys.path.insert(0, str(root / 'packages' / 'agromatrix-tools'))
|
||
|
||
from crews.agromatrix_crew.memory_manager import (
|
||
_default_farm_profile,
|
||
_chat_fact_key,
|
||
_legacy_farm_fact_key,
|
||
_farm_profiles_differ,
|
||
migrate_farm_profile_legacy_to_chat,
|
||
load_farm_profile,
|
||
save_farm_profile,
|
||
_cache_set,
|
||
_cache_get,
|
||
)
|
||
from crews.agromatrix_crew.telemetry import TELEMETRY_TAG
|
||
|
||
|
||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
def _make_farm(chat_id: str, crops: list | None = None) -> dict:
|
||
p = _default_farm_profile(chat_id)
|
||
if crops:
|
||
p["crops"] = crops
|
||
return p
|
||
|
||
|
||
# ─── Unit: key helpers ────────────────────────────────────────────────────────
|
||
|
||
def test_chat_fact_key_format():
|
||
key = _chat_fact_key("123456")
|
||
assert key == "farm_profile:agromatrix:chat:123456"
|
||
|
||
|
||
def test_legacy_fact_key_format():
|
||
key = _legacy_farm_fact_key("user_42")
|
||
assert key == "farm_profile:agromatrix:user_42"
|
||
|
||
|
||
def test_chat_key_differs_from_legacy():
|
||
assert _chat_fact_key("999") != _legacy_farm_fact_key("999")
|
||
|
||
|
||
# ─── Unit: _farm_profiles_differ ─────────────────────────────────────────────
|
||
|
||
def test_farm_profiles_identical_not_differ():
|
||
a = _make_farm("chat1", crops=["пшениця"])
|
||
b = _make_farm("chat1", crops=["пшениця"])
|
||
assert _farm_profiles_differ(a, b) is False
|
||
|
||
|
||
def test_farm_profiles_different_crops():
|
||
a = _make_farm("chat1", crops=["пшениця"])
|
||
b = _make_farm("chat1", crops=["кукурудза"])
|
||
assert _farm_profiles_differ(a, b) is True
|
||
|
||
|
||
def test_farm_profiles_different_region():
|
||
a = _make_farm("chat1")
|
||
b = _make_farm("chat1")
|
||
a["region"] = "Полтавська"
|
||
b["region"] = "Харківська"
|
||
assert _farm_profiles_differ(a, b) is True
|
||
|
||
|
||
def test_farm_profiles_metadata_ignored():
|
||
"""Відмінності у updated_at та _version не впливають на результат."""
|
||
a = _make_farm("chat1", crops=["ріпак"])
|
||
b = deepcopy(a)
|
||
b["updated_at"] = "2026-01-01T00:00:00"
|
||
b["_version"] = 99
|
||
assert _farm_profiles_differ(a, b) is False
|
||
|
||
|
||
# ─── Unit: default farm profile ───────────────────────────────────────────────
|
||
|
||
def test_default_farm_version_5():
|
||
p = _default_farm_profile("chat_x")
|
||
assert p["_version"] == 5
|
||
|
||
|
||
def test_default_farm_has_new_fields():
|
||
p = _default_farm_profile("chat_x")
|
||
for field in ("farm_name", "field_ids", "crop_ids", "active_integrations",
|
||
"iot_sensors", "alert_thresholds", "seasonal_context"):
|
||
assert field in p, f"Missing field: {field}"
|
||
|
||
|
||
def test_default_farm_chat_id():
|
||
p = _default_farm_profile("chat_99")
|
||
assert p["chat_id"] == "chat_99"
|
||
|
||
|
||
# ─── Unit: load_farm_profile — no memory-service (cache only) ─────────────────
|
||
|
||
def test_load_farm_profile_from_cache():
|
||
"""Якщо в cache є profile під chat-key — повертаємо його."""
|
||
chat_id = "cache_test_chat"
|
||
stored = _make_farm(chat_id, crops=["соняшник"])
|
||
_cache_set(_chat_fact_key(chat_id), stored)
|
||
|
||
# patch _http_get_fact to ensure it's not called
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact") as mock_get:
|
||
result = load_farm_profile(chat_id, user_id="any_user")
|
||
mock_get.assert_not_called()
|
||
|
||
assert result["crops"] == ["соняшник"]
|
||
|
||
|
||
def test_load_farm_profile_falls_back_to_default():
|
||
"""При відсутності і в cache і в memory-service — default."""
|
||
chat_id = "nodata_chat_999"
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=None):
|
||
result = load_farm_profile(chat_id, user_id=None)
|
||
assert result["_version"] == 5
|
||
assert result["chat_id"] == chat_id
|
||
|
||
|
||
def test_load_farm_profile_no_user_id_no_crash():
|
||
"""load_farm_profile без user_id не падає."""
|
||
chat_id = "chat_noid_888"
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=None):
|
||
result = load_farm_profile(chat_id) # user_id=None за замовчуванням
|
||
assert result is not None
|
||
assert result["chat_id"] == chat_id
|
||
|
||
|
||
# ─── Unit: lazy migration (legacy → chat key) ────────────────────────────────
|
||
|
||
def test_load_farm_profile_migrates_legacy():
|
||
"""
|
||
load_farm_profile без chat-profile але з legacy → мігрує в chat-key.
|
||
"""
|
||
chat_id = "migration_chat_1"
|
||
user_id = "legacy_user_1"
|
||
legacy_profile = _make_farm(user_id, crops=["ячмінь"])
|
||
|
||
def mock_http_get(uid, key):
|
||
if key == _chat_fact_key(chat_id):
|
||
return None # нового ключа нема
|
||
if key == _legacy_farm_fact_key(user_id):
|
||
return legacy_profile
|
||
return None
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", side_effect=mock_http_get), \
|
||
patch("crews.agromatrix_crew.memory_manager._http_upsert_fact", return_value=True):
|
||
result = load_farm_profile(chat_id, user_id=user_id)
|
||
|
||
assert result["crops"] == ["ячмінь"]
|
||
assert result.get("chat_id") == chat_id
|
||
assert "_migrated_from" in result
|
||
assert user_id in result["_migrated_from"]
|
||
|
||
|
||
def test_load_farm_profile_migration_sets_chat_id():
|
||
"""Після міграції chat_id у профілі відповідає chat_id, а не user_id."""
|
||
chat_id = "migration_chat_2"
|
||
user_id = "legacy_user_2"
|
||
legacy_profile = _make_farm(user_id)
|
||
|
||
def mock_http_get(uid, key):
|
||
return legacy_profile if key == _legacy_farm_fact_key(user_id) else None
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", side_effect=mock_http_get), \
|
||
patch("crews.agromatrix_crew.memory_manager._http_upsert_fact", return_value=True):
|
||
result = load_farm_profile(chat_id, user_id=user_id)
|
||
|
||
assert result["chat_id"] == chat_id
|
||
|
||
|
||
# ─── Unit: migrate_farm_profile_legacy_to_chat ───────────────────────────────
|
||
|
||
def test_migrate_no_conflict_copies_profile():
|
||
"""Якщо chat-profile не існує → migration записує legacy."""
|
||
chat_id = "mig_chat_3"
|
||
user_id = "mig_user_3"
|
||
legacy = _make_farm(user_id, crops=["соя"])
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=None), \
|
||
patch("crews.agromatrix_crew.memory_manager._http_upsert_fact", return_value=True):
|
||
result = migrate_farm_profile_legacy_to_chat(chat_id, user_id, legacy)
|
||
|
||
assert result["crops"] == ["соя"]
|
||
assert result["chat_id"] == chat_id
|
||
|
||
|
||
def test_migrate_conflict_keeps_existing():
|
||
"""
|
||
Якщо chat-profile вже існує і відрізняється від legacy → не перезаписуємо,
|
||
повертаємо існуючий.
|
||
"""
|
||
chat_id = "mig_chat_4"
|
||
user_id = "mig_user_4"
|
||
existing = _make_farm(chat_id, crops=["пшениця"])
|
||
legacy = _make_farm(user_id, crops=["кукурудза"]) # відрізняється
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=existing), \
|
||
patch("crews.agromatrix_crew.memory_manager._http_upsert_fact") as mock_upsert:
|
||
result = migrate_farm_profile_legacy_to_chat(chat_id, user_id, legacy)
|
||
|
||
# Existing profile returned unchanged
|
||
assert result["crops"] == ["пшениця"]
|
||
# upsert NOT called (conflict — no overwrite)
|
||
mock_upsert.assert_not_called()
|
||
|
||
|
||
def test_migrate_conflict_logs_telemetry(caplog):
|
||
"""Conflict → tlog виводить farm_profile_conflict."""
|
||
import logging
|
||
chat_id = "mig_chat_5"
|
||
user_id = "mig_user_5"
|
||
existing = _make_farm(chat_id, crops=["пшениця"])
|
||
legacy = _make_farm(user_id, crops=["кукурудза"])
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=existing), \
|
||
patch("crews.agromatrix_crew.memory_manager._http_upsert_fact", return_value=False), \
|
||
caplog.at_level(logging.INFO, logger="crews.agromatrix_crew.memory_manager"):
|
||
migrate_farm_profile_legacy_to_chat(chat_id, user_id, legacy)
|
||
|
||
tagged = [r.getMessage() for r in caplog.records if TELEMETRY_TAG in r.getMessage()]
|
||
assert any("farm_profile_conflict" in m for m in tagged), \
|
||
f"Expected farm_profile_conflict telemetry, got: {tagged}"
|
||
|
||
|
||
def test_migrate_no_conflict_logs_migrated(caplog):
|
||
"""Successful migration → tlog виводить farm_profile_migrated."""
|
||
import logging
|
||
chat_id = "mig_chat_6"
|
||
user_id = "mig_user_6"
|
||
legacy = _make_farm(user_id, crops=["ріпак"])
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=None), \
|
||
patch("crews.agromatrix_crew.memory_manager._http_upsert_fact", return_value=True), \
|
||
caplog.at_level(logging.INFO, logger="crews.agromatrix_crew.memory_manager"):
|
||
migrate_farm_profile_legacy_to_chat(chat_id, user_id, legacy)
|
||
|
||
tagged = [r.getMessage() for r in caplog.records if TELEMETRY_TAG in r.getMessage()]
|
||
assert any("farm_profile_migrated" in m for m in tagged), \
|
||
f"Expected farm_profile_migrated telemetry, got: {tagged}"
|
||
|
||
|
||
# ─── Acceptance: two users share farm profile ────────────────────────────────
|
||
|
||
def test_two_users_same_chat_share_farm_profile():
|
||
"""
|
||
userA і userB в одному chatX → ділять один FarmProfile.
|
||
Перевірка: обидва load_farm_profile повертають одні й ті ж дані.
|
||
"""
|
||
chat_id = "shared_chat_XY"
|
||
user_a = "user_a_1"
|
||
user_b = "user_b_2"
|
||
|
||
shared_farm = _make_farm(chat_id, crops=["пшениця", "соняшник"])
|
||
# chat-key вже заповнений (userA зберіг)
|
||
_cache_set(_chat_fact_key(chat_id), shared_farm)
|
||
|
||
# userA завантажує
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact") as mock_get:
|
||
farm_a = load_farm_profile(chat_id, user_id=user_a)
|
||
mock_get.assert_not_called() # cache hit
|
||
|
||
# userB завантажує
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact") as mock_get:
|
||
farm_b = load_farm_profile(chat_id, user_id=user_b)
|
||
mock_get.assert_not_called() # cache hit
|
||
|
||
assert farm_a["crops"] == farm_b["crops"]
|
||
assert farm_a["chat_id"] == farm_b["chat_id"] == chat_id
|
||
|
||
|
||
def test_user_profile_per_user_independent():
|
||
"""UserProfile залишається per-user, не shared."""
|
||
from crews.agromatrix_crew.memory_manager import _default_user_profile
|
||
p_a = _default_user_profile("user_a_ind")
|
||
p_b = _default_user_profile("user_b_ind")
|
||
assert p_a["user_id"] != p_b["user_id"]
|
||
|
||
|
||
def test_farm_profile_update_visible_to_second_user():
|
||
"""
|
||
Якщо userA записав farm_profile (crops оновились) →
|
||
userB при наступному завантаженні бачить оновлені crops.
|
||
"""
|
||
chat_id = "update_shared_chat"
|
||
user_a = "user_upd_a"
|
||
user_b = "user_upd_b"
|
||
|
||
# Початковий стан
|
||
initial_farm = _make_farm(chat_id, crops=["пшениця"])
|
||
_cache_set(_chat_fact_key(chat_id), initial_farm)
|
||
|
||
# userA додає crop
|
||
updated_farm = deepcopy(initial_farm)
|
||
updated_farm["crops"].append("кукурудза")
|
||
_cache_set(_chat_fact_key(chat_id), updated_farm)
|
||
|
||
# userB завантажує
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact") as mock_get:
|
||
farm_b = load_farm_profile(chat_id, user_id=user_b)
|
||
mock_get.assert_not_called()
|
||
|
||
assert "кукурудза" in farm_b["crops"]
|
||
assert "пшениця" in farm_b["crops"]
|
||
|
||
|
||
# ─── backward-compat ──────────────────────────────────────────────────────────
|
||
|
||
def test_load_farm_backward_compat_no_user_id():
|
||
"""Старий виклик load_farm_profile(chat_id) без user_id — не падає."""
|
||
with patch("crews.agromatrix_crew.memory_manager._http_get_fact", return_value=None):
|
||
result = load_farm_profile("old_call_chat")
|
||
assert result is not None
|
||
assert result["_version"] == 5
|
||
|
||
|
||
def test_save_farm_uses_chat_key():
|
||
"""save_farm_profile зберігає під chat-key, не legacy."""
|
||
chat_id = "save_test_chat"
|
||
farm = _make_farm(chat_id)
|
||
|
||
calls: list[tuple] = []
|
||
|
||
def mock_upsert(uid, key, data):
|
||
calls.append((uid, key))
|
||
return True
|
||
|
||
with patch("crews.agromatrix_crew.memory_manager._http_upsert_fact", side_effect=mock_upsert):
|
||
save_farm_profile(chat_id, farm)
|
||
|
||
assert calls, "upsert not called"
|
||
used_key = calls[0][1]
|
||
assert used_key == _chat_fact_key(chat_id), \
|
||
f"Expected chat-key, got: {used_key!r}"
|
||
assert "chat:" in used_key
|
||
# Legacy key NOT used
|
||
assert _legacy_farm_fact_key(chat_id) != used_key
|