All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 47s
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5) убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main. Заодно: - удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/), импортировавших уже-удалённые или удаляемые сейчас legacy-модули - тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.* эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete golden-parity тестов, потерявших legacy-оракл - kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config — ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(), MagicMock HouseMatcher, region_code=66) Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка #2208 (test_search_cache_hit, не связан со scrapers).
238 lines
8.9 KiB
Python
238 lines
8.9 KiB
Python
"""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
|