"""Tests for app.services.user_events — record_event / schedule_event / should_log_login. Coverage: (a) record_event inserts exactly one row via a mocked SessionLocal, commits, and the SQL uses CAST(:x AS type) (never the psycopg-v3-broken `:x::type` form). (b) record_event swallows exceptions — a DB failure (connect or execute) never raises. (c) schedule_event in a sync context (no running event loop) calls record_event inline. (d) schedule_event in an async context schedules a non-blocking background task that still calls through, and a failure inside it is logged, not raised. (e) should_log_login: True once per (user, ip, ua, day), False on repeat; independent per user / ip / user_agent; prunes the in-process set past capacity. (f) optional real-DB round trip against app.core.db.SessionLocal (the exact session record_event uses) — self-skips without a reachable, non-placeholder Postgres, mirroring tests/test_house_dedup_merge.py's live-DB pattern. """ from __future__ import annotations import asyncio import os import re from typing import Any from unittest.mock import patch # psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from app.services import user_events as ue # --------------------------------------------------------------------------- # Fakes # --------------------------------------------------------------------------- class _FakeResult: def fetchone(self) -> None: return None class _FakeSession: """SessionLocal() stand-in supporting the `with SessionLocal() as db:` pattern.""" def __init__(self) -> None: self.executed: list[tuple[str, dict[str, Any] | None]] = [] self.committed = False def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((str(stmt), params)) return _FakeResult() def commit(self) -> None: self.committed = True def __enter__(self) -> _FakeSession: return self def __exit__(self, *exc: object) -> bool: return False # --------------------------------------------------------------------------- # (a) record_event — successful insert # --------------------------------------------------------------------------- def test_record_event_inserts_and_commits() -> None: fake = _FakeSession() with patch.object(ue, "SessionLocal", return_value=fake): ue.record_event( event_type="estimate_request", username="user1", ip="1.2.3.4", user_agent="pytest-agent", path="/api/v1/trade-in/estimate", method="POST", estimate_id="11111111-1111-1111-1111-111111111111", payload={"address": "ул. Ленина, 1", "area_m2": 50.0, "rooms": 2}, ) assert fake.committed is True assert len(fake.executed) == 1 sql, params = fake.executed[0] assert params is not None assert "INSERT INTO user_events" in sql assert params["event_type"] == "estimate_request" assert params["username"] == "user1" assert params["ip"] == "1.2.3.4" assert params["estimate_id"] == "11111111-1111-1111-1111-111111111111" # payload сериализован в JSON-строку (CAST(:payload AS jsonb) на стороне SQL). assert isinstance(params["payload"], str) assert "Ленина" in params["payload"] def test_record_event_cast_not_doublecolon() -> None: """CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md).""" fake = _FakeSession() with patch.object(ue, "SessionLocal", return_value=fake): ue.record_event(event_type="login", username="user1") sql, _ = fake.executed[0] assert not re.search(r":\w+::\w", sql) assert "CAST(:ip AS inet)" in sql assert "CAST(:estimate_id AS uuid)" in sql assert "CAST(:payload AS jsonb)" in sql def test_record_event_default_payload_is_empty_dict() -> None: fake = _FakeSession() with patch.object(ue, "SessionLocal", return_value=fake): ue.record_event(event_type="login", username="user2") _, params = fake.executed[0] assert params is not None assert params["payload"] == "{}" assert params["ip"] is None assert params["estimate_id"] is None # --------------------------------------------------------------------------- # (b) record_event never raises # --------------------------------------------------------------------------- def test_record_event_swallows_session_open_errors() -> None: class _BrokenSession: def __enter__(self) -> _BrokenSession: raise RuntimeError("db unreachable") def __exit__(self, *exc: object) -> bool: return False with patch.object(ue, "SessionLocal", return_value=_BrokenSession()): # Should NOT raise. ue.record_event(event_type="login", username="user1") def test_record_event_swallows_execute_errors() -> None: class _ExplodingSession(_FakeSession): def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult: raise RuntimeError("syntax error at or near ':'") with patch.object(ue, "SessionLocal", return_value=_ExplodingSession()): ue.record_event(event_type="login", username="user1") # --------------------------------------------------------------------------- # (c)/(d) schedule_event # --------------------------------------------------------------------------- def test_schedule_event_sync_context_calls_record_event_inline() -> None: """No running loop (plain sync call, e.g. from a script/test) → inline call.""" with patch.object(ue, "record_event") as mock_record: ue.schedule_event(event_type="login", username="user1", ip="9.9.9.9") mock_record.assert_called_once_with(event_type="login", username="user1", ip="9.9.9.9") async def test_schedule_event_async_context_is_non_blocking_and_calls_through() -> None: """Running loop present → backgrounded via asyncio.to_thread; does not block the calling coroutine and eventually calls record_event.""" with patch.object(ue, "record_event") as mock_record: ue.schedule_event(event_type="api_request", username="user1", path="/api/v1/x") # schedule_event must return immediately without awaiting the background task. # Give the scheduled task a chance to run before asserting. await asyncio.sleep(0.05) mock_record.assert_called_once_with( event_type="api_request", username="user1", path="/api/v1/x" ) async def test_schedule_event_background_failure_is_logged_not_raised() -> None: """A record_event failure inside the background task must not propagate/crash the caller — the done-callback swallows it (logs a warning).""" with patch.object(ue, "record_event", side_effect=RuntimeError("boom")): # Must not raise synchronously (the task runs in the background). ue.schedule_event(event_type="login", username="user1") await asyncio.sleep(0.05) # Reaching here without an unhandled-exception bubbling up confirms the # done-callback caught it. # --------------------------------------------------------------------------- # (e) should_log_login dedup # --------------------------------------------------------------------------- def test_should_log_login_true_once_then_false_same_day() -> None: ue._LOGIN_DEDUP_SEEN.clear() assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True assert ue.should_log_login("alice", "1.1.1.1", "chrome") is False def test_should_log_login_independent_per_ip() -> None: ue._LOGIN_DEDUP_SEEN.clear() assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True assert ue.should_log_login("alice", "2.2.2.2", "chrome") is True def test_should_log_login_independent_per_user() -> None: ue._LOGIN_DEDUP_SEEN.clear() assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True assert ue.should_log_login("bob", "1.1.1.1", "chrome") is True def test_should_log_login_independent_per_user_agent() -> None: ue._LOGIN_DEDUP_SEEN.clear() assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True assert ue.should_log_login("alice", "1.1.1.1", "firefox") is True def test_should_log_login_none_ip_and_ua_are_valid_keys() -> None: ue._LOGIN_DEDUP_SEEN.clear() assert ue.should_log_login("alice", None, None) is True assert ue.should_log_login("alice", None, None) is False def test_should_log_login_prunes_when_over_capacity() -> None: ue._LOGIN_DEDUP_SEEN.clear() for i in range(ue._LOGIN_DEDUP_MAX): ue._LOGIN_DEDUP_SEEN.add(f"filler-{i}") assert len(ue._LOGIN_DEDUP_SEEN) == ue._LOGIN_DEDUP_MAX # Next call must prune (clear the whole set) before adding, not grow unbounded. assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True assert len(ue._LOGIN_DEDUP_SEEN) == 1 ue._LOGIN_DEDUP_SEEN.clear() # --------------------------------------------------------------------------- # (f) optional real-DB round trip (self-skips without a reachable test DB) # --------------------------------------------------------------------------- def _db_reachable() -> bool: """True if app.core.db's engine (the one record_event actually uses) points at a real, non-placeholder Postgres and is reachable — mirrors the live-DB skip pattern in tests/test_house_dedup_merge.py.""" dsn = os.environ.get("DATABASE_URL", "") if not dsn or "localhost:5432/test" in dsn: return False try: from sqlalchemy import text as _t from app.core.db import engine with engine.connect() as conn: conn.execute(_t("SELECT 1")) return True except Exception: return False @pytest.mark.skipif(not _db_reachable(), reason="no reachable Postgres test DB") def test_real_record_event_inserts_row() -> None: """End-to-end on the real DB record_event() is bound to (app.core.db.SessionLocal): persists exactly the given fields and is queryable back.""" from sqlalchemy import text as _t from app.core.db import SessionLocal ue.record_event( event_type="pytest_probe", username="pytest_user_events_test", ip="203.0.113.7", user_agent="pytest-real-db", path="/api/v1/trade-in/estimate", method="POST", payload={"probe": True}, ) with SessionLocal() as db: try: row = db.execute( _t( "SELECT event_type, username, ip_address, user_agent, payload " "FROM user_events WHERE username = :u ORDER BY id DESC LIMIT 1" ), {"u": "pytest_user_events_test"}, ).fetchone() assert row is not None assert row.event_type == "pytest_probe" assert str(row.ip_address) == "203.0.113.7" assert row.payload == {"probe": True} finally: db.execute( _t("DELETE FROM user_events WHERE username = :u"), {"u": "pytest_user_events_test"}, ) db.commit()