All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 51s
RequestAuditMiddleware logs api_request + deduped login per authenticated /api/* request; estimate() logs estimate_request with address. Fire-and-forget, never blocks/breaks the request. Writes to user_events (Feature 2/3 capture).
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""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()
|