"""Tests for record_yandex_price_history (gate price.previous/trend → offer_price_history). Behavioural tests run against an in-memory SQLite DB with a minimal listings + offer_price_history schema. The service keeps all timestamp arithmetic in Python and uses only CAST(:x AS type) (no :x::type, no Postgres-only interval), so the same SQL exercises correctly on SQLite — letting us assert the stateful idempotent logic (seed previous-point, current-point, no-op on unchanged price) without a live Postgres. Plus static checks that the emitted SQL keeps psycopg-v3 discipline. """ from __future__ import annotations import inspect import os import re os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from scraper_kit.base import ScrapedLot from sqlalchemy import create_engine, text from sqlalchemy.orm import Session, sessionmaker from app.services import yandex_price_history as yph _SERVICE_SRC = inspect.getsource(yph) # Concatenated text of every actual SQL statement the service emits (not the docstring, # which may mention the :x::type anti-pattern in prose). _SQL_TEXT = "\n".join( str(getattr(stmt, "text", "")) for stmt in (yph._RESOLVE_SQL, yph._LATEST_SQL, yph._INSERT_SQL) ) # ── Static: psycopg-v3 discipline ───────────────────────────────────────────── def test_service_uses_cast_not_colon_colon() -> None: """No :x::type shorthand in emitted SQL — only CAST(:x AS type) (psycopg-v3 rule).""" # No bind-param cast shorthand (:param::type) in the real SQL statements. assert not re.search(r":\w+::", _SQL_TEXT) assert "CAST(:listing_id AS bigint)" in _SQL_TEXT assert "CAST(:price_rub AS numeric)" in _SQL_TEXT def test_service_inserts_with_yandex_source_tag() -> None: assert "INSERT INTO offer_price_history" in _SERVICE_SRC assert "'yandex'" in _SERVICE_SRC # ── Behavioural: in-memory SQLite ───────────────────────────────────────────── @pytest.fixture() def db() -> Session: """In-memory SQLite session with minimal listings + offer_price_history schema.""" engine = create_engine("sqlite://", future=True) with engine.begin() as conn: conn.execute( text( """ CREATE TABLE listings ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, source_id TEXT ) """ ) ) conn.execute( text( """ CREATE TABLE offer_price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, listing_id INTEGER NOT NULL, change_time TEXT NOT NULL, price_rub NUMERIC NOT NULL, source TEXT ) """ ) ) sess = sessionmaker(bind=engine, future=True)() try: yield sess finally: sess.close() def _seed_listing(db: Session, *, source: str, source_id: str) -> int: db.execute( text("INSERT INTO listings (source, source_id) VALUES (:s, :sid)"), {"s": source, "sid": source_id}, ) db.commit() row = db.execute( text("SELECT id FROM listings WHERE source = :s AND source_id = :sid"), {"s": source, "sid": source_id}, ).fetchone() assert row is not None return int(row[0]) def _lot( *, source_id: str, price_rub: int, price_previous_rub: int | None = None, source: str = "yandex", ) -> ScrapedLot: return ScrapedLot( source=source, source_url=f"https://realty.yandex.ru/offer/{source_id}/", source_id=source_id, price_rub=price_rub, price_previous_rub=price_previous_rub, ) def _history(db: Session, listing_id: int) -> list[tuple[float, str]]: rows = db.execute( text( "SELECT price_rub, change_time FROM offer_price_history " "WHERE listing_id = :lid ORDER BY change_time ASC" ), {"lid": listing_id}, ).fetchall() return [(float(r[0]), str(r[1])) for r in rows] def test_first_capture_with_previous_inserts_two_points(db: Session) -> None: """price_previous задан и != price_rub → previous@-1d + current@now (2 строки).""" lid = _seed_listing(db, source="yandex", source_id="111") n = yph.record_yandex_price_history( db, [_lot(source_id="111", price_rub=5_000_000, price_previous_rub=5_500_000)] ) assert n == 2 hist = _history(db, lid) assert len(hist) == 2 # Chronological: previous-point first (earlier change_time), current second. assert hist[0][0] == 5_500_000.0 # previous assert hist[1][0] == 5_000_000.0 # current assert hist[0][1] < hist[1][1] # previous change_time strictly earlier def test_first_capture_without_previous_inserts_one_point(db: Session) -> None: """price_previous отсутствует → только current-точка (1 строка).""" lid = _seed_listing(db, source="yandex", source_id="222") n = yph.record_yandex_price_history(db, [_lot(source_id="222", price_rub=4_000_000)]) assert n == 1 hist = _history(db, lid) assert len(hist) == 1 assert hist[0][0] == 4_000_000.0 def test_first_capture_previous_equals_current_inserts_one_point(db: Session) -> None: """price_previous == price_rub → previous-seed пропущена, только current (1 строка).""" lid = _seed_listing(db, source="yandex", source_id="223") n = yph.record_yandex_price_history( db, [_lot(source_id="223", price_rub=4_000_000, price_previous_rub=4_000_000)] ) assert n == 1 assert len(_history(db, lid)) == 1 def test_recapture_same_price_is_noop(db: Session) -> None: """Повторный захват с той же ценой → 0 строк (idempotent).""" _seed_listing(db, source="yandex", source_id="333") first = yph.record_yandex_price_history(db, [_lot(source_id="333", price_rub=6_000_000)]) assert first == 1 second = yph.record_yandex_price_history(db, [_lot(source_id="333", price_rub=6_000_000)]) assert second == 0 def test_recapture_new_price_inserts_one_point(db: Session) -> None: """Повторный захват с новой ценой → 1 строка (зафиксировали смену).""" lid = _seed_listing(db, source="yandex", source_id="444") yph.record_yandex_price_history(db, [_lot(source_id="444", price_rub=6_000_000)]) n = yph.record_yandex_price_history(db, [_lot(source_id="444", price_rub=5_800_000)]) assert n == 1 hist = _history(db, lid) assert len(hist) == 2 assert {h[0] for h in hist} == {6_000_000.0, 5_800_000.0} def test_non_yandex_lot_skipped(db: Session) -> None: """Не-yandex lot пропускается (даже если листинг с таким source_id есть).""" _seed_listing(db, source="cian", source_id="555") n = yph.record_yandex_price_history( db, [_lot(source_id="555", price_rub=3_000_000, source="cian")] ) assert n == 0 def test_unknown_source_id_skipped(db: Session) -> None: """source_id без листинга в БД → skip (листинг не найден).""" n = yph.record_yandex_price_history(db, [_lot(source_id="does-not-exist", price_rub=1_000_000)]) assert n == 0 def test_lot_without_source_id_skipped(db: Session) -> None: lot = ScrapedLot( source="yandex", source_url="https://realty.yandex.ru/offer/x/", source_id=None, price_rub=2_000_000, ) assert yph.record_yandex_price_history(db, [lot]) == 0 def test_empty_lots_returns_zero(db: Session) -> None: assert yph.record_yandex_price_history(db, []) == 0 def test_broken_lot_does_not_abort_batch(db: Session, monkeypatch: pytest.MonkeyPatch) -> None: """SAVEPOINT: один битый lot (INSERT падает) не валит остальные.""" good_lid = _seed_listing(db, source="yandex", source_id="aaa") _seed_listing(db, source="yandex", source_id="bbb") real_insert = yph._insert_point def _flaky(db_: Session, **kw: object) -> None: # Ломаем только для листинга 'bbb' (его listing_id заранее не знаем — # рвём по price_rub-значению, привязанному к bbb). if kw.get("price_rub") == 999: raise RuntimeError("boom") real_insert(db_, **kw) # type: ignore[arg-type] monkeypatch.setattr(yph, "_insert_point", _flaky) lots = [ _lot(source_id="aaa", price_rub=7_000_000), _lot(source_id="bbb", price_rub=999), ] n = yph.record_yandex_price_history(db, lots) # 'aaa' inserted (1), 'bbb' rolled back via SAVEPOINT → batch survives. assert n == 1 assert len(_history(db, good_lid)) == 1