"""Phase-1 tests for the Observability subsystem (run INSIDE the bot container): docker compose exec bot python tools/test_observability.py Covers: _jsonable safety (UUID/datetime/Decimal/set/enum/dataclass), trace lifecycle, event round-trip through JSONB, draft linking, status updates, save_voice (day-folder mkdir), and the span() context manager. NOTE (policy): test rows/files are tagged `_test` and are NEVER deleted — the observability subsystem has no retention/cleanup by design. """ import asyncio import dataclasses import sys import uuid from datetime import datetime, timezone from decimal import Decimal from enum import Enum from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from sqlalchemy import select from app import observability as obs from app.db import SessionLocal from app.db_init import ensure_schema from app.models import BotTrace, BotTraceEvent PASSED: list[str] = [] FAILED: list[str] = [] def check(name: str, ok: bool, extra: str = "") -> None: (PASSED if ok else FAILED).append(name) print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}") class SampleColor(Enum): RED = "red" @dataclasses.dataclass class SampleRec: a: int b: str def test_jsonable() -> None: payload = { "uuid": uuid.uuid4(), "dt": datetime(2026, 10, 12, 10, 30, tzinfo=timezone.utc), "dec": Decimal("123.45"), "set": {"x", "y"}, "enum": SampleColor.RED, "dc": SampleRec(a=1, b="two"), "nested": [{"inner": uuid.UUID(int=1)}], "none": None, } out = obs._jsonable(payload) ok = ( isinstance(out["uuid"], str) and isinstance(out["dt"], str) and isinstance(out["dec"], str) and isinstance(out["set"], list) and out["enum"] == "red" and isinstance(out["dc"], dict) ) check("_jsonable produces JSON-serializable output", ok, f"dt={out['dt'][:19]}, dec={out['dec']!r}") async def main() -> None: await ensure_schema() test_jsonable() # 1) trace lifecycle tid = await obs.trace_start(chat_id=0, message_id=999001, kind="voice") check("trace_start returns an id", bool(tid), str(tid)) # 2) events with tricky payloads (must not raise; must be stored) await obs.event(tid, "input.voice", payload={ "_test": True, "size": 1234, "when": datetime.now(timezone.utc), "amount": Decimal("9.9"), }) await obs.event(tid, "routing.decision", field="project", payload={ "_test": True, "candidates": [{"id": uuid.uuid4(), "name": "منشانه"}], }) await obs.log_resolution(tid, "supplier", "مصطفوی", "many", [{"id": "x", "name": "بازرگانی مصطفوی"}]) # 3) draft linking + latest lookup draft_id = str(uuid.uuid4()) await obs.link_draft(tid, draft_id, "quote") latest = await obs.latest_trace_for_draft(draft_id) check("latest_trace_for_draft returns the linked trace", latest == tid, latest) # 4) status update + JSONB round-trip await obs.set_trace_status(tid, "committed", str(uuid.uuid4())) async with SessionLocal() as s: tr = (await s.execute(select(BotTrace).where(BotTrace.id == tid))).scalar_one() check("trace status + quote id updated", tr.status == "committed" and tr.committed_quote_id is not None) rows = (await s.execute( select(BotTraceEvent).where(BotTraceEvent.trace_id == tid) .order_by(BotTraceEvent.id) )).scalars().all() check("events persisted", len(rows) >= 3, f"{len(rows)} rows") proj = next((r for r in rows if r.stage == "routing.decision"), None) check("JSONB payload round-trip keeps values as strings", proj is not None and isinstance(proj.payload["candidates"][0]["id"], str)) # 5) save_voice (parents mkdir + day folder) path = obs.save_voice(tid, 999001, b"OGGFAKE", ext=".ogg") check("save_voice writes file under day folder", bool(path) and Path(path).exists(), str(path)) # 6) span() context manager (fire-and-forget task) with obs.span("span.test", trace_id=tid, note="unit"): pass await asyncio.sleep(0.4) async with SessionLocal() as s: n = (await s.execute( select(BotTraceEvent).where( BotTraceEvent.trace_id == tid, BotTraceEvent.stage == "span.test" ) )).scalars().all() check("span() recorded via background task", len(n) == 1, f"{len(n)} rows") total = len(PASSED) + len(FAILED) print(f"\n{len(PASSED)}/{total} passed") if FAILED: print("FAILED:", FAILED) raise SystemExit(1) if __name__ == "__main__": asyncio.run(main())