""" Unit-тести для telemetry.py (AGX_STEPAN_METRIC tag) — v2.7.2. Перевіряє: 1. tlog() форматує рядок з тегом AGX_STEPAN_METRIC 2. tlog() коректно серіалізує типи (bool, int, float, list, dict) 3. depth_classifier — логи depth=light/deep мають тег 4. memory_manager.push_recent_topic — topics_push=true/false мають тег 5. Безпека: tlog() не кидає виняток якщо value некоректний 6. PII-safe: user_id/chat_id анонімізуються у tlog() (формат h:xxxxxxxxxx) 7. anonymize_id() — коректність, стабільність, edge cases """ import logging import sys from pathlib import Path 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.telemetry import tlog, TELEMETRY_TAG, _fmt_value, anonymize_id from crews.agromatrix_crew.depth_classifier import classify_depth from crews.agromatrix_crew.memory_manager import ( _default_user_profile, push_recent_topic, ) # ─── tlog() unit ───────────────────────────────────────────────────────────── class _CaptureHandler(logging.Handler): """Ловить LogRecord-и для перевірки.""" def __init__(self): super().__init__() self.records: list[logging.LogRecord] = [] def emit(self, record: logging.LogRecord) -> None: self.records.append(record) @property def messages(self) -> list[str]: return [r.getMessage() for r in self.records] def _capture_logger(name: str) -> tuple[logging.Logger, _CaptureHandler]: lg = logging.getLogger(name) lg.setLevel(logging.DEBUG) h = _CaptureHandler() lg.addHandler(h) return lg, h def test_tlog_contains_tag(): lg, h = _capture_logger("test_tag") tlog(lg, "depth", depth="light", reason="greeting") assert any(TELEMETRY_TAG in m for m in h.messages), \ f"Expected {TELEMETRY_TAG!r} in log, got: {h.messages}" def test_tlog_message_format(): lg, h = _capture_logger("test_fmt") tlog(lg, "crew_launch", launched=True, depth="deep") msg = h.messages[-1] assert msg.startswith(TELEMETRY_TAG) assert "crew_launch" in msg assert "launched=true" in msg assert "depth=deep" in msg def test_tlog_bool_lowercase(): lg, h = _capture_logger("test_bool") tlog(lg, "test_event", flag_a=True, flag_b=False) msg = h.messages[-1] assert "flag_a=true" in msg assert "flag_b=false" in msg def test_tlog_list_joined(): lg, h = _capture_logger("test_list") tlog(lg, "agents", agents=["ops", "iot", "platform"]) msg = h.messages[-1] assert "ops,iot,platform" in msg def test_tlog_dict_compact_json(): lg, h = _capture_logger("test_dict") tlog(lg, "event", meta={"key": "val"}) msg = h.messages[-1] assert '"key"' in msg or "key" in msg def test_tlog_float_formatted(): lg, h = _capture_logger("test_float") tlog(lg, "confidence", score=0.75) msg = h.messages[-1] assert "score=0.75" in msg def test_tlog_no_kv(): lg, h = _capture_logger("test_nokv") tlog(lg, "simple_event") msg = h.messages[-1] assert TELEMETRY_TAG in msg assert "simple_event" in msg def test_tlog_safe_on_bad_value(): """tlog() не має кидати виняток навіть при некоректному value.""" lg, h = _capture_logger("test_safe") class _Bad: def __str__(self): raise RuntimeError("bad") # Should NOT raise tlog(lg, "event", bad=_Bad()) # At least one message logged (fallback) assert len(h.messages) >= 1 # ─── _fmt_value unit ────────────────────────────────────────────────────────── def test_fmt_bool_true(): assert _fmt_value(True) == "true" def test_fmt_bool_false(): assert _fmt_value(False) == "false" def test_fmt_int(): assert _fmt_value(42) == "42" def test_fmt_float(): assert _fmt_value(3.14) == "3.14" def test_fmt_list(): assert _fmt_value(["a", "b", "c"]) == "a,b,c" def test_fmt_str(): assert _fmt_value("hello") == "hello" # ─── depth_classifier logs tagged ──────────────────────────────────────────── def test_depth_classifier_greeting_logs_tagged(): """classify_depth("привіт") має emitнути рядок з TELEMETRY_TAG.""" dc_logger = logging.getLogger("crews.agromatrix_crew.depth_classifier") dc_logger.setLevel(logging.DEBUG) h = _CaptureHandler() dc_logger.addHandler(h) try: classify_depth("привіт") assert any(TELEMETRY_TAG in m for m in h.messages), \ f"No tagged log for greeting. Messages: {h.messages}" finally: dc_logger.removeHandler(h) def test_depth_classifier_deep_logs_tagged(): """classify_depth("зроби план на тиждень") → тег присутній.""" dc_logger = logging.getLogger("crews.agromatrix_crew.depth_classifier") dc_logger.setLevel(logging.DEBUG) h = _CaptureHandler() dc_logger.addHandler(h) try: classify_depth("зроби план на тиждень") assert any(TELEMETRY_TAG in m for m in h.messages), \ f"No tagged log for deep. Messages: {h.messages}" finally: dc_logger.removeHandler(h) def test_depth_classifier_followup_logs_tagged(): """classify_depth("а на завтра?", last_topic=...) → тег присутній.""" dc_logger = logging.getLogger("crews.agromatrix_crew.depth_classifier") dc_logger.setLevel(logging.DEBUG) h = _CaptureHandler() dc_logger.addHandler(h) try: classify_depth("а на завтра?", last_topic="plan_day") assert any(TELEMETRY_TAG in m for m in h.messages), \ f"No tagged log for followup. Messages: {h.messages}" finally: dc_logger.removeHandler(h) # ─── memory_manager.push_recent_topic logs tagged ──────────────────────────── def test_push_recent_topic_push_tagged(): """push_recent_topic → topics_push лог має тег.""" mm_logger = logging.getLogger("crews.agromatrix_crew.memory_manager") mm_logger.setLevel(logging.DEBUG) h = _CaptureHandler() mm_logger.addHandler(h) try: profile = _default_user_profile("u_tag_push") push_recent_topic(profile, "plan_day", "план на завтра") tagged = [m for m in h.messages if TELEMETRY_TAG in m] assert tagged, f"No tagged log for topics_push. Messages: {h.messages}" assert any("topics_push" in m for m in tagged) assert any("pushed=true" in m for m in tagged) finally: mm_logger.removeHandler(h) def test_push_recent_topic_dedup_tagged(): """push_recent_topic dedup → topics_push=false лог має тег.""" mm_logger = logging.getLogger("crews.agromatrix_crew.memory_manager") mm_logger.setLevel(logging.DEBUG) h = _CaptureHandler() mm_logger.addHandler(h) try: profile = _default_user_profile("u_tag_dedup") push_recent_topic(profile, "plan_day", "план на завтра") h.records.clear() push_recent_topic(profile, "plan_day", "план на завтра") # dedup tagged = [m for m in h.messages if TELEMETRY_TAG in m] assert tagged, f"No tagged log for dedup. Messages: {h.messages}" assert any("pushed=false" in m for m in tagged) finally: mm_logger.removeHandler(h) # ─── Log level passthrough ──────────────────────────────────────────────────── def test_tlog_default_level_is_info(): """tlog без level= використовує INFO.""" lg, h = _capture_logger("test_level") tlog(lg, "event") assert h.records[-1].levelno == logging.INFO def test_tlog_warning_level(): """tlog з level=logging.WARNING записує WARNING.""" lg, h = _capture_logger("test_warn_level") import logging as _logging tlog(lg, "fallback_event", level=_logging.WARNING, reason="timeout") assert h.records[-1].levelno == _logging.WARNING # ─── anonymize_id unit ──────────────────────────────────────────────────────── def test_anonymize_none_returns_none(): assert anonymize_id(None) is None def test_anonymize_empty_returns_empty(): assert anonymize_id("") == "" def test_anonymize_format_h_prefix(): result = anonymize_id("123456789") assert result is not None assert result.startswith("h:") assert len(result) == 12 # "h:" + 10 hex chars def test_anonymize_hex_chars_only(): """Хеш частина містить тільки hex символи.""" import re result = anonymize_id("some_user_id") assert result is not None hash_part = result[2:] # skip "h:" assert re.fullmatch(r'[0-9a-f]{10}', hash_part), \ f"Expected 10 lowercase hex chars, got: {hash_part!r}" def test_anonymize_stable_same_input(): """Той самий input → той самий псевдонім (детерміновано).""" a = anonymize_id("user_42") b = anonymize_id("user_42") assert a == b def test_anonymize_different_inputs_different_hashes(): """Різні inputs → різні псевдоніми (з високою ймовірністю).""" a = anonymize_id("user_1") b = anonymize_id("user_2") assert a != b def test_anonymize_int_via_str(): """Типовий user_id як число (stringified).""" result = anonymize_id("987654321") assert result is not None assert result.startswith("h:") assert len(result) == 12 def test_anonymize_telegram_negative_chat_id(): """Telegram chat_id може бути від'ємним числом.""" result = anonymize_id("-1001234567890") assert result is not None assert result.startswith("h:") assert len(result) == 12 # ─── tlog PII-safe behavior ─────────────────────────────────────────────────── def test_tlog_user_id_is_anonymized(): """user_id= → у лог-рядку з'являється h:... а не сире значення.""" lg, h = _capture_logger("test_pii_uid") raw_user_id = "987654321" tlog(lg, "memory_save", user_id=raw_user_id, ok=True) msg = h.messages[-1] # Сирого id не має бути у рядку assert raw_user_id not in msg, \ f"Raw user_id found in log: {msg!r}" # Замість нього має бути анонімізований псевдонім assert "user_id=h:" in msg, f"Expected 'user_id=h:' in: {msg!r}" def test_tlog_chat_id_is_anonymized(): """chat_id → анонімізується автоматично.""" lg, h = _capture_logger("test_pii_cid") raw_chat_id = "-1001234567890" tlog(lg, "memory_save", entity="FarmProfile", chat_id=raw_chat_id, ok=True) msg = h.messages[-1] assert raw_chat_id not in msg, \ f"Raw chat_id found in log: {msg!r}" assert "chat_id=h:" in msg, f"Expected 'chat_id=h:' in: {msg!r}" def test_tlog_non_pii_key_not_anonymized(): """Звичайні ключі (не user_id/chat_id) не анонімізуються.""" lg, h = _capture_logger("test_nonpii") tlog(lg, "depth", depth="light", reason="greeting", words=3) msg = h.messages[-1] assert "depth=light" in msg assert "reason=greeting" in msg assert "words=3" in msg def test_tlog_pii_anonymize_format_stable(): """Один і той самий user_id → той самий псевдонім у різних tlog викликах.""" lg, h = _capture_logger("test_pii_stable") uid = "user_test_stable" tlog(lg, "event_a", user_id=uid) tlog(lg, "event_b", user_id=uid) # Витягаємо псевдоніми anon_a = [p for p in h.messages[0].split() if p.startswith("user_id=")][0] anon_b = [p for p in h.messages[1].split() if p.startswith("user_id=")][0] assert anon_a == anon_b, \ f"Unstable anonymization: {anon_a!r} vs {anon_b!r}" def test_tlog_custom_pii_keys(): """Можна передати власний set pii_keys для додаткових полів.""" lg, h = _capture_logger("test_custom_pii") tlog(lg, "custom", pii_keys=frozenset({"session_id"}), session_id="abc123xyz") msg = h.messages[-1] assert "abc123xyz" not in msg assert "session_id=h:" in msg def test_tlog_none_user_id_is_null(): """None user_id → логується як null (не помилка).""" lg, h = _capture_logger("test_pii_none") tlog(lg, "event", user_id=None) msg = h.messages[-1] assert "user_id=null" in msg def test_memory_manager_logs_no_raw_user_id(): """ Перевіряємо що memory_manager tlog логи не містять сирого user_id. Симулюємо через push_recent_topic (логи topics_push). """ mm_logger = logging.getLogger("crews.agromatrix_crew.memory_manager") mm_logger.setLevel(logging.DEBUG) h = _CaptureHandler() mm_logger.addHandler(h) try: from crews.agromatrix_crew.memory_manager import _default_user_profile, push_recent_topic raw_uid = "99887766554" profile = _default_user_profile(raw_uid) push_recent_topic(profile, "plan_day", "план на завтра") for msg in h.messages: if TELEMETRY_TAG in msg: assert raw_uid not in msg, \ f"Raw user_id found in telemetry log: {msg!r}" finally: mm_logger.removeHandler(h)