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