Fast IAAI implementation
This commit is contained in:
13
tests/conftest.py
Normal file
13
tests/conftest.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
os.environ.setdefault("IAAI_BASE_URL", "https://www.iaai.com")
|
||||
231
tests/test_client.py
Normal file
231
tests/test_client.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from iaai_sync_service.client import (
|
||||
LISTING_MARKER,
|
||||
HybridSessionAuth,
|
||||
build_resizer_images_from_keys,
|
||||
is_challenge_response,
|
||||
parse_listing_page,
|
||||
parse_product_details_vm,
|
||||
resolve_listing_scope_paths,
|
||||
)
|
||||
from iaai_sync_service.settings import Settings
|
||||
|
||||
|
||||
def _catalog_html() -> str:
|
||||
return (Path(__file__).resolve().parents[1] / "html" / "catalog.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _page_html() -> str:
|
||||
return (Path(__file__).resolve().parents[1] / "html" / "page.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_parse_listing_page_extracts_hidden_payloads() -> None:
|
||||
parsed = parse_listing_page(_catalog_html())
|
||||
|
||||
assert parsed.result_count >= len(parsed.vehicles)
|
||||
assert parsed.page_size == 100
|
||||
assert parsed.current_page == 1
|
||||
assert parsed.vehicles
|
||||
first = parsed.vehicles[0]
|
||||
assert first.inventory_id.endswith("~US")
|
||||
assert first.inventory_status == "RS"
|
||||
|
||||
|
||||
def test_parse_product_details_vm_extracts_json_script() -> None:
|
||||
payload = parse_product_details_vm(_page_html())
|
||||
inventory_view = payload["inventoryView"]
|
||||
attrs = inventory_view["attributes"]
|
||||
|
||||
assert attrs["Id"] == "45078011~US"
|
||||
assert attrs["Make"] == "ACURA"
|
||||
assert attrs["Model"] == "RSX"
|
||||
|
||||
|
||||
def test_build_resizer_images_from_keys_deduplicates_and_orders() -> None:
|
||||
keys = [
|
||||
{"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
|
||||
{"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
|
||||
{"k": "abc~2", "w": 1800, "h": 1200, "i": 1},
|
||||
]
|
||||
images = build_resizer_images_from_keys(keys)
|
||||
|
||||
assert len(images) == 2
|
||||
assert images[0]["order_index"] == 1
|
||||
assert "imageKeys=abc~2" in str(images[0]["fullres_image"])
|
||||
|
||||
|
||||
def test_is_challenge_response_detects_missing_marker() -> None:
|
||||
html = "<html><body><h1>Blocked</h1></body></html>"
|
||||
assert is_challenge_response(status_code=200, body_text=html, expected_marker='id="GBPSearchQuery"') is True
|
||||
|
||||
|
||||
def test_is_challenge_response_detects_incapsula_marker() -> None:
|
||||
html = '<html><script>var a="_Incapsula_Resource";</script></html>'
|
||||
assert is_challenge_response(status_code=200, body_text=html, expected_marker=None) is True
|
||||
|
||||
|
||||
def test_is_challenge_response_accepts_expected_marker_even_with_incapsula_script() -> None:
|
||||
html = f'<html><input id="GBPSearchQuery" value="{{}}" /><script src="/_Incapsula_Resource"></script></html>'
|
||||
assert is_challenge_response(status_code=200, body_text=html, expected_marker=LISTING_MARKER) is False
|
||||
|
||||
|
||||
def test_catalog_hidden_vehicle_json_is_valid() -> None:
|
||||
parsed = parse_listing_page(_catalog_html())
|
||||
# smoke-check we can serialize parsed query back to JSON
|
||||
dumped = json.dumps(parsed.gbp_search_query)
|
||||
assert "CurrentPage" in dumped
|
||||
|
||||
|
||||
def test_resolve_listing_scope_paths_prefers_explicit_url() -> None:
|
||||
paths = resolve_listing_scope_paths(
|
||||
listing_start_url="https://www.iaai.com/Search?url=abc",
|
||||
brands={"acura", "bmw"},
|
||||
)
|
||||
assert paths == ["https://www.iaai.com/Search?url=abc"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, text: str) -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, responses: list[_FakeResponse], *, cookies: list[object] | None = None) -> None:
|
||||
self._responses = responses
|
||||
self.calls = 0
|
||||
self.cookies = cookies or []
|
||||
|
||||
def request(self, *args, **kwargs): # noqa: ANN002, ANN003, ANN202
|
||||
self.calls += 1
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
database_url="sqlite+pysqlite:///:memory:",
|
||||
celery_broker_url="redis://localhost:6379/0",
|
||||
celery_result_backend="redis://localhost:6379/1",
|
||||
iaai_base_url="https://www.iaai.com",
|
||||
iaai_listing_start_url="",
|
||||
sync_runtime_config_file=Path("sync_runtime_config.json"),
|
||||
iaai_session_cookies="",
|
||||
iaai_storage_state_path=Path("iaai_storage_state.json"),
|
||||
iaai_login="",
|
||||
iaai_password="",
|
||||
http_timeout=10,
|
||||
http_retries=0,
|
||||
http_retry_backoff_ms=1,
|
||||
fetch_concurrency=1,
|
||||
db_commit_batch_size=10,
|
||||
session_keepalive_enabled=True,
|
||||
session_keepalive_interval_minutes=15,
|
||||
schema_bootstrap_enabled=False,
|
||||
advisory_lock_key=1,
|
||||
error_summary_max_len=2000,
|
||||
)
|
||||
|
||||
|
||||
def test_hybrid_auth_retries_after_challenge(monkeypatch) -> None: # noqa: ANN001
|
||||
auth = HybridSessionAuth(_settings())
|
||||
cookie = type("Cookie", (), {"domain": ".iaai.com"})()
|
||||
fake_session = _FakeSession(
|
||||
[
|
||||
_FakeResponse(403, "<html>forbidden</html>"),
|
||||
_FakeResponse(200, f"<html>{LISTING_MARKER}</html>"),
|
||||
],
|
||||
cookies=[cookie],
|
||||
)
|
||||
auth._thread_local.session = fake_session # type: ignore[attr-defined]
|
||||
auth._bootstrap_cookies_loaded = True # type: ignore[attr-defined]
|
||||
|
||||
refreshed = {"count": 0}
|
||||
|
||||
def _fake_refresh(*, expected_marker=None, session=None) -> None: # noqa: ANN001
|
||||
refreshed["count"] += 1
|
||||
|
||||
monkeypatch.setattr(auth, "_refresh_session_via_playwright", _fake_refresh)
|
||||
|
||||
response = auth.request(
|
||||
"GET",
|
||||
"https://www.iaai.com/Vehiclelisting/Cars",
|
||||
timeout=5,
|
||||
retries=0,
|
||||
retry_backoff_ms=1,
|
||||
expected_marker=LISTING_MARKER,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert refreshed["count"] == 1
|
||||
assert fake_session.calls == 2
|
||||
|
||||
|
||||
def test_hybrid_auth_bootstraps_anonymous_session_when_no_cookies(monkeypatch) -> None: # noqa: ANN001
|
||||
auth = HybridSessionAuth(_settings())
|
||||
fake_session = _FakeSession(
|
||||
[
|
||||
_FakeResponse(200, f"<html>{LISTING_MARKER}</html>"),
|
||||
],
|
||||
cookies=[],
|
||||
)
|
||||
auth._thread_local.session = fake_session # type: ignore[attr-defined]
|
||||
auth._bootstrap_cookies_loaded = True # type: ignore[attr-defined]
|
||||
|
||||
refreshed = {"count": 0}
|
||||
|
||||
def _fake_refresh(*, expected_marker=None, session=None) -> None: # noqa: ANN001
|
||||
refreshed["count"] += 1
|
||||
|
||||
monkeypatch.setattr(auth, "_refresh_session_via_playwright", _fake_refresh)
|
||||
|
||||
response = auth.request(
|
||||
"GET",
|
||||
"https://www.iaai.com/Vehiclelisting/Cars",
|
||||
timeout=5,
|
||||
retries=0,
|
||||
retry_backoff_ms=1,
|
||||
expected_marker=LISTING_MARKER,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert refreshed["count"] == 1
|
||||
assert fake_session.calls == 1
|
||||
|
||||
|
||||
def test_wait_until_non_challenge_tolerates_transient_navigation_errors() -> None:
|
||||
class _FakePage:
|
||||
def __init__(self) -> None:
|
||||
self.content_calls = 0
|
||||
self.goto_calls = 0
|
||||
|
||||
def wait_for_timeout(self, *_args, **_kwargs) -> None: # noqa: ANN002, ANN003
|
||||
return
|
||||
|
||||
def wait_for_load_state(self, *_args, **_kwargs) -> None: # noqa: ANN002, ANN003
|
||||
return
|
||||
|
||||
def content(self) -> str:
|
||||
self.content_calls += 1
|
||||
if self.content_calls == 1:
|
||||
raise RuntimeError(
|
||||
"Page.content: Unable to retrieve content because the page is navigating and changing the content."
|
||||
)
|
||||
return f"<html>{LISTING_MARKER}</html>"
|
||||
|
||||
def goto(self, *_args, **_kwargs) -> None: # noqa: ANN002, ANN003
|
||||
self.goto_calls += 1
|
||||
|
||||
page = _FakePage()
|
||||
HybridSessionAuth._wait_until_non_challenge(
|
||||
page=page,
|
||||
target="https://www.iaai.com/Vehiclelisting/Cars",
|
||||
timeout_ms=5_000,
|
||||
expected_marker=LISTING_MARKER,
|
||||
)
|
||||
assert page.content_calls >= 2
|
||||
assert page.goto_calls == 0
|
||||
36
tests/test_migrations.py
Normal file
36
tests/test_migrations.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module(path: Path): # noqa: ANN202
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_revision_chain_is_correct() -> None:
|
||||
base = Path(__file__).resolve().parents[1] / "alembic" / "versions"
|
||||
rev1 = _load_module(base / "0001_ensure_sync_schema.py")
|
||||
rev2 = _load_module(base / "0002_extend_origin_enum_for_iaai.py")
|
||||
rev3 = _load_module(base / "0003_create_iaai_source_tables.py")
|
||||
|
||||
assert rev1.revision == "0001_ensure_sync_schema"
|
||||
assert rev1.down_revision is None
|
||||
assert rev2.revision == "0002_extend_origin_enum_for_iaai"
|
||||
assert rev2.down_revision == "0001_ensure_sync_schema"
|
||||
assert rev3.revision == "0003_create_iaai_source_tables"
|
||||
assert rev3.down_revision == "0002_extend_origin_enum_for_iaai"
|
||||
|
||||
|
||||
def test_enum_migration_contains_required_values() -> None:
|
||||
migration = (
|
||||
Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0002_extend_origin_enum_for_iaai.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "originenum" in migration
|
||||
assert "IAAI" in migration
|
||||
assert "ADD VALUE IF NOT EXISTS" in migration
|
||||
40
tests/test_runtime_config.py
Normal file
40
tests/test_runtime_config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from iaai_sync_service.runtime_config import RuntimeConfig, load_runtime_config
|
||||
|
||||
|
||||
def test_load_runtime_config_reads_filters(tmp_path) -> None: # noqa: ANN001
|
||||
config_path = tmp_path / "sync_runtime_config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sync": {
|
||||
"condition_check_enabled": "true",
|
||||
},
|
||||
"filters": {
|
||||
"brands": ["Acura", "BMW"],
|
||||
"models": ["RSX", "3 SERIES"],
|
||||
"years": [2021, "2022"],
|
||||
"body_types": ["SUV"],
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime = load_runtime_config(config_path)
|
||||
assert runtime.condition_check_enabled is True
|
||||
assert runtime.filters.brands == {"acura", "bmw"}
|
||||
assert runtime.filters.models == {"rsx", "3 series"}
|
||||
assert runtime.filters.years == {2021, 2022}
|
||||
assert runtime.filters.body_types == {"suv"}
|
||||
|
||||
|
||||
def test_load_runtime_config_returns_defaults_on_invalid_json(tmp_path) -> None: # noqa: ANN001
|
||||
config_path = tmp_path / "sync_runtime_config.json"
|
||||
config_path.write_text("{invalid", encoding="utf-8")
|
||||
|
||||
runtime = load_runtime_config(config_path)
|
||||
assert runtime == RuntimeConfig()
|
||||
66
tests/test_settings.py
Normal file
66
tests/test_settings.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from iaai_sync_service.settings import Settings
|
||||
|
||||
|
||||
def test_settings_from_env_reads_required_values(monkeypatch) -> None: # noqa: ANN001
|
||||
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/db")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
monkeypatch.setenv("IAAI_BASE_URL", "https://www.iaai.com")
|
||||
monkeypatch.setenv("IAAI_LISTING_START_URL", "https://www.iaai.com/Search?url=abc")
|
||||
monkeypatch.setenv("IAAI_FETCH_CONCURRENCY", "4")
|
||||
monkeypatch.setenv("IAAI_SESSION_KEEPALIVE_ENABLED", "false")
|
||||
monkeypatch.setenv("IAAI_SESSION_KEEPALIVE_INTERVAL_MINUTES", "20")
|
||||
monkeypatch.setenv("IAAI_SCHEMA_BOOTSTRAP_ENABLED", "true")
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
assert settings.database_url == "postgresql+psycopg://user:pass@localhost:5432/db"
|
||||
assert settings.celery_broker_url == "redis://localhost:6379/0"
|
||||
assert settings.iaai_base_url == "https://www.iaai.com"
|
||||
assert settings.iaai_listing_start_url == "https://www.iaai.com/Search?url=abc"
|
||||
assert settings.fetch_concurrency == 4
|
||||
assert settings.session_keepalive_enabled is False
|
||||
assert settings.session_keepalive_interval_minutes == 20
|
||||
assert settings.schema_bootstrap_enabled is True
|
||||
|
||||
|
||||
def test_settings_rejects_non_positive_concurrency(monkeypatch) -> None: # noqa: ANN001
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/db")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
monkeypatch.setenv("IAAI_BASE_URL", "https://www.iaai.com")
|
||||
monkeypatch.setenv("IAAI_FETCH_CONCURRENCY", "0")
|
||||
|
||||
with pytest.raises(RuntimeError, match="IAAI_FETCH_CONCURRENCY"):
|
||||
Settings.from_env()
|
||||
|
||||
|
||||
def test_settings_bootstrap_flag_defaults_to_false(monkeypatch) -> None: # noqa: ANN001
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/db")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
monkeypatch.setenv("IAAI_BASE_URL", "https://www.iaai.com")
|
||||
|
||||
settings = Settings.from_env()
|
||||
assert settings.schema_bootstrap_enabled is False
|
||||
|
||||
|
||||
def test_settings_rejects_invalid_listing_start_url(monkeypatch) -> None: # noqa: ANN001
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/db")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
monkeypatch.setenv("IAAI_BASE_URL", "https://www.iaai.com")
|
||||
monkeypatch.setenv("IAAI_LISTING_START_URL", "Search?url=abc")
|
||||
|
||||
with pytest.raises(RuntimeError, match="IAAI_LISTING_START_URL"):
|
||||
Settings.from_env()
|
||||
|
||||
|
||||
def test_settings_rejects_invalid_keepalive_interval(monkeypatch) -> None: # noqa: ANN001
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/db")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
monkeypatch.setenv("IAAI_BASE_URL", "https://www.iaai.com")
|
||||
monkeypatch.setenv("IAAI_SESSION_KEEPALIVE_INTERVAL_MINUTES", "0")
|
||||
|
||||
with pytest.raises(RuntimeError, match="IAAI_SESSION_KEEPALIVE_INTERVAL_MINUTES"):
|
||||
Settings.from_env()
|
||||
288
tests/test_sync_engine.py
Normal file
288
tests/test_sync_engine.py
Normal file
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from iaai_sync_service.client import ListingVehicle
|
||||
from iaai_sync_service.models import Base, Car, Image
|
||||
from iaai_sync_service.runtime_config import RuntimeConfig, RuntimeFilters
|
||||
from iaai_sync_service.settings import Settings
|
||||
from iaai_sync_service.sync_engine import (
|
||||
prepare_car,
|
||||
replace_images_if_changed,
|
||||
run_sync_once,
|
||||
)
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(bind=engine)
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
database_url="sqlite+pysqlite:///:memory:",
|
||||
celery_broker_url="redis://localhost:6379/0",
|
||||
celery_result_backend="redis://localhost:6379/1",
|
||||
iaai_base_url="https://www.iaai.com",
|
||||
iaai_listing_start_url="",
|
||||
sync_runtime_config_file=Path("sync_runtime_config.json"),
|
||||
iaai_session_cookies="",
|
||||
iaai_storage_state_path=Path("iaai_storage_state.json"),
|
||||
iaai_login="",
|
||||
iaai_password="",
|
||||
http_timeout=10,
|
||||
http_retries=1,
|
||||
http_retry_backoff_ms=1,
|
||||
fetch_concurrency=2,
|
||||
db_commit_batch_size=20,
|
||||
session_keepalive_enabled=True,
|
||||
session_keepalive_interval_minutes=15,
|
||||
schema_bootstrap_enabled=False,
|
||||
advisory_lock_key=1,
|
||||
error_summary_max_len=2000,
|
||||
)
|
||||
|
||||
|
||||
def _listing_vehicle(inventory_id: str, *, status: str = "RS", closed: bool = False) -> ListingVehicle:
|
||||
return ListingVehicle(
|
||||
inventory_id=inventory_id,
|
||||
tenant="US",
|
||||
auction_id="1_1",
|
||||
auction_date="2026-04-08T08:30:00+00:00",
|
||||
inventory_status=status,
|
||||
currency="USD",
|
||||
timed_auction_closed=closed,
|
||||
timed_auction_indicator=False,
|
||||
prebid_indicator=True,
|
||||
buynow_indicator=False,
|
||||
)
|
||||
|
||||
|
||||
def _detail_payload(
|
||||
inventory_id: str,
|
||||
*,
|
||||
make: str = "ACURA",
|
||||
model: str = "RSX",
|
||||
series: str = "BASE",
|
||||
year: int = 2002,
|
||||
bid: int = 500,
|
||||
) -> dict:
|
||||
return {
|
||||
"inventoryView": {
|
||||
"attributes": {
|
||||
"Id": inventory_id,
|
||||
"Year": str(year),
|
||||
"Make": make,
|
||||
"Model": model,
|
||||
"Series": series,
|
||||
"Currency": "USD",
|
||||
"ODOValue": "219187",
|
||||
"ExteriorColor": "GRAY",
|
||||
"DriveLineTypeDesc": "FWD",
|
||||
"Transmission": "Automatic",
|
||||
"BodyStyleName": "COUPE",
|
||||
"EngineSize": "2.0L I-4",
|
||||
"VehicleGrade": "50",
|
||||
"PrimaryDamageDesc": "NORMAL WEAR & TEAR",
|
||||
"SecondaryDamageDesc": " ",
|
||||
},
|
||||
"imageDimensions": {
|
||||
"keys": {
|
||||
"$values": [
|
||||
{"k": f"{inventory_id}~I1", "w": 1600, "h": 1200, "i": 0},
|
||||
{"k": f"{inventory_id}~I2", "w": 1600, "h": 1200, "i": 1},
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
"auctionInformation": {
|
||||
"biddingInformation": {
|
||||
"buyNowAmount": 0,
|
||||
"buyNowPrice": "$0",
|
||||
},
|
||||
"prebidInformation": {
|
||||
"decimalHighBidAmount": str(bid),
|
||||
"highBidAmount": f"${bid}",
|
||||
"buyNowPrice": "$0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, listing: list[ListingVehicle], detail_payloads: dict[str, dict]) -> None:
|
||||
self._listing = listing
|
||||
self._detail_payloads = detail_payloads
|
||||
self.detail_calls = 0
|
||||
self.persist_calls = 0
|
||||
|
||||
def iter_listing_vehicles(self, filters: RuntimeFilters): # noqa: ANN201, ARG002
|
||||
return iter(self._listing)
|
||||
|
||||
def fetch_vehicle_detail_payload(self, inventory_id: str): # noqa: ANN201
|
||||
self.detail_calls += 1
|
||||
return self._detail_payloads[inventory_id]
|
||||
|
||||
def persist_session_state(self) -> None:
|
||||
self.persist_calls += 1
|
||||
|
||||
|
||||
def test_prepare_car_maps_fields() -> None:
|
||||
row = prepare_car(
|
||||
listing_vehicle=_listing_vehicle("45078011~US"),
|
||||
detail_payload=_detail_payload("45078011~US", bid=600),
|
||||
currency_labels={"USD", "CAD", "NA"},
|
||||
country_labels={"US", "CA", "NA"},
|
||||
drive_labels={"FWD", "RWD", "4WD", "NA"},
|
||||
gearbox_labels={"AT", "MT", "NA"},
|
||||
body_type_labels={"COUPE", "OTHER"},
|
||||
body_type_default="OTHER",
|
||||
steering_left="LEFT",
|
||||
selling_type_auction="AUCTION",
|
||||
origin_code="IAAI",
|
||||
)
|
||||
|
||||
assert row.origin_id == "iaai:45078011~US"
|
||||
assert row.origin_url == "https://www.iaai.com/VehicleDetail/45078011~US"
|
||||
assert row.brand == "ACURA"
|
||||
assert row.model == "RSX BASE"
|
||||
assert row.year == 2002
|
||||
assert row.price == 600
|
||||
assert row.currency == "USD"
|
||||
assert row.country == "US"
|
||||
assert row.drive == "FWD"
|
||||
assert row.gearbox == "AT"
|
||||
assert row.body_type == "COUPE"
|
||||
assert row.engine_volume == 2000
|
||||
assert row.is_damaged is False
|
||||
assert len(row.images) == 2
|
||||
|
||||
|
||||
def test_replace_images_if_changed_returns_zero_for_same_set() -> None:
|
||||
session = _session()
|
||||
car = Car(
|
||||
parser_id="car-aaaaaaaaaaaaaaaaaaaaaa",
|
||||
brand="ACURA",
|
||||
model="RSX",
|
||||
origin_id="iaai:45078011~US",
|
||||
origin_url="https://www.iaai.com/VehicleDetail/45078011~US",
|
||||
slug="acura-rsx-2002",
|
||||
is_sold=False,
|
||||
last_seen_at=datetime(2026, 4, 1, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
session.add(car)
|
||||
session.flush()
|
||||
session.add(
|
||||
Image(
|
||||
car_id=car.id,
|
||||
fullres_image="https://vis.iaai.com/resizer?imageKeys=a&width=1600&height=1200",
|
||||
preview_image="https://vis.iaai.com/resizer?imageKeys=a&width=640&height=480",
|
||||
order_index=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
written = replace_images_if_changed(
|
||||
session=session,
|
||||
car_id=car.id,
|
||||
images=[
|
||||
{
|
||||
"order_index": 0,
|
||||
"fullres_image": "https://vis.iaai.com/resizer?imageKeys=a&width=1600&height=1200",
|
||||
"preview_image": "https://vis.iaai.com/resizer?imageKeys=a&width=640&height=480",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert written == 0
|
||||
|
||||
|
||||
def test_run_sync_once_upserts_and_marks_missing_as_sold() -> None:
|
||||
session = _session()
|
||||
|
||||
listing_first = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
|
||||
detail_first = {
|
||||
"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
|
||||
"45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
|
||||
}
|
||||
client_first = FakeClient(listing_first, detail_first)
|
||||
|
||||
stats1, errors1 = run_sync_once(session=session, client=client_first, settings=_settings())
|
||||
assert errors1 == []
|
||||
assert stats1.ids_fetched == 2
|
||||
assert stats1.cars_upserted == 2
|
||||
assert client_first.persist_calls == 1
|
||||
|
||||
listing_second = [_listing_vehicle("45078011~US")]
|
||||
detail_second = {
|
||||
"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=650),
|
||||
}
|
||||
client_second = FakeClient(listing_second, detail_second)
|
||||
|
||||
stats2, errors2 = run_sync_once(session=session, client=client_second, settings=_settings())
|
||||
assert errors2 == []
|
||||
assert stats2.ids_fetched == 1
|
||||
assert stats2.cars_upserted == 1
|
||||
|
||||
active = session.query(Car).filter_by(origin_id="iaai:45078011~US").one()
|
||||
sold = session.query(Car).filter_by(origin_id="iaai:45268167~US").one()
|
||||
assert active.is_sold is False
|
||||
assert active.price == 650
|
||||
assert sold.is_sold is True
|
||||
|
||||
|
||||
def test_run_sync_once_skips_reconcile_when_runtime_filters_enabled() -> None:
|
||||
session = _session()
|
||||
session.add(
|
||||
Car(
|
||||
parser_id="car-zzzzzzzzzzzzzzzzzzzzzz",
|
||||
brand="BMW",
|
||||
model="X5",
|
||||
origin_id="iaai:99999999~US",
|
||||
origin_url="https://www.iaai.com/VehicleDetail/99999999~US",
|
||||
slug="bmw-x5-2020",
|
||||
is_sold=False,
|
||||
last_seen_at=datetime(2026, 4, 1, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
listing = [_listing_vehicle("45078011~US")]
|
||||
details = {"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500)}
|
||||
runtime = RuntimeConfig(filters=RuntimeFilters(brands={"bmw"}))
|
||||
|
||||
stats, errors = run_sync_once(
|
||||
session=session,
|
||||
client=FakeClient(listing, details),
|
||||
settings=_settings(),
|
||||
runtime_config=runtime,
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert stats.ids_fetched == 0
|
||||
|
||||
row = session.query(Car).filter_by(origin_id="iaai:99999999~US").one()
|
||||
assert row.is_sold is False
|
||||
|
||||
|
||||
def test_run_sync_once_condition_check_skips_closed_rows() -> None:
|
||||
session = _session()
|
||||
listing = [_listing_vehicle("45078011~US", status="SOLD", closed=True)]
|
||||
details = {"45078011~US": _detail_payload("45078011~US", bid=500)}
|
||||
|
||||
runtime = RuntimeConfig(condition_check_enabled=True)
|
||||
stats, errors = run_sync_once(
|
||||
session=session,
|
||||
client=FakeClient(listing, details),
|
||||
settings=_settings(),
|
||||
runtime_config=runtime,
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert stats.ids_fetched == 0
|
||||
assert session.query(Car).count() == 0
|
||||
167
tests/test_tasks.py
Normal file
167
tests/test_tasks.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from iaai_sync_service.models import SyncRun
|
||||
from iaai_sync_service.sync_engine import SyncStats
|
||||
from iaai_sync_service.tasks import execute_keepalive_job, execute_sync_job
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, value): # noqa: ANN001
|
||||
self._value = value
|
||||
|
||||
def scalar_one(self): # noqa: ANN201
|
||||
return self._value
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, *, lock_acquired: bool) -> None:
|
||||
self.lock_acquired = lock_acquired
|
||||
self.runs: dict[int, SyncRun] = {}
|
||||
self.closed = False
|
||||
|
||||
def execute(self, stmt, params=None): # noqa: ANN001, ANN201
|
||||
sql = str(stmt)
|
||||
if "pg_try_advisory_lock" in sql:
|
||||
return _ScalarResult(self.lock_acquired)
|
||||
if "pg_advisory_unlock" in sql:
|
||||
return _ScalarResult(True)
|
||||
raise AssertionError(f"Unexpected SQL execute call: {sql}")
|
||||
|
||||
def add(self, obj) -> None: # noqa: ANN001
|
||||
if isinstance(obj, SyncRun):
|
||||
if obj.id is None:
|
||||
obj.id = len(self.runs) + 1
|
||||
self.runs[obj.id] = obj
|
||||
|
||||
def commit(self) -> None:
|
||||
return
|
||||
|
||||
def rollback(self) -> None:
|
||||
return
|
||||
|
||||
def get(self, model, run_id): # noqa: ANN001, ANN201
|
||||
assert model is SyncRun
|
||||
return self.runs.get(run_id)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _settings() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
iaai_base_url="https://www.iaai.com",
|
||||
sync_runtime_config_file=Path("sync_runtime_config.json"),
|
||||
schema_bootstrap_enabled=True,
|
||||
advisory_lock_key=123,
|
||||
error_summary_max_len=2000,
|
||||
)
|
||||
|
||||
|
||||
def test_execute_sync_job_skips_when_lock_not_available(monkeypatch) -> None: # noqa: ANN001
|
||||
session = FakeSession(lock_acquired=False)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_settings", _settings)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_session", lambda: session)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.ensure_schema", lambda _session: None)
|
||||
|
||||
result = execute_sync_job()
|
||||
|
||||
assert result == {"status": "skipped_locked"}
|
||||
assert session.closed is True
|
||||
|
||||
|
||||
def test_execute_sync_job_success_writes_sync_run(monkeypatch) -> None: # noqa: ANN001
|
||||
session = FakeSession(lock_acquired=True)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_settings", _settings)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_session", lambda: session)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.ensure_schema", lambda _session: None)
|
||||
monkeypatch.setattr(
|
||||
"iaai_sync_service.tasks.run_sync_once",
|
||||
lambda **kwargs: (SyncStats(ids_fetched=5, cars_upserted=4, cars_failed=0, images_upserted=12), []),
|
||||
)
|
||||
|
||||
result = execute_sync_job()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["ids_fetched"] == 5
|
||||
run = session.runs[result["run_id"]]
|
||||
assert run.status == "success"
|
||||
assert run.cars_upserted == 4
|
||||
|
||||
|
||||
def test_execute_sync_job_marks_failed_when_sync_raises(monkeypatch) -> None: # noqa: ANN001
|
||||
session = FakeSession(lock_acquired=True)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_settings", _settings)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_session", lambda: session)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.ensure_schema", lambda _session: None)
|
||||
|
||||
def _raise(**kwargs): # noqa: ANN003, ANN202
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.run_sync_once", _raise)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
execute_sync_job()
|
||||
|
||||
failed = session.runs[1]
|
||||
assert failed.status == "failed"
|
||||
assert failed.error_summary is not None
|
||||
|
||||
|
||||
def test_execute_sync_job_skips_schema_bootstrap_when_disabled(monkeypatch) -> None: # noqa: ANN001
|
||||
session = FakeSession(lock_acquired=False)
|
||||
settings = _settings()
|
||||
settings.schema_bootstrap_enabled = False
|
||||
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_settings", lambda: settings)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_session", lambda: session)
|
||||
|
||||
def _fail_if_called(_session): # noqa: ANN001, ANN202
|
||||
raise AssertionError("ensure_schema should not be called")
|
||||
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.ensure_schema", _fail_if_called)
|
||||
|
||||
result = execute_sync_job()
|
||||
assert result == {"status": "skipped_locked"}
|
||||
|
||||
|
||||
def test_execute_keepalive_job_skips_when_lock_not_available(monkeypatch) -> None: # noqa: ANN001
|
||||
session = FakeSession(lock_acquired=False)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_settings", _settings)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_session", lambda: session)
|
||||
|
||||
result = execute_keepalive_job()
|
||||
|
||||
assert result == {"status": "skipped_locked"}
|
||||
assert session.closed is True
|
||||
|
||||
|
||||
def test_execute_keepalive_job_success(monkeypatch) -> None: # noqa: ANN001
|
||||
session = FakeSession(lock_acquired=True)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_settings", _settings)
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.get_session", lambda: session)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, settings): # noqa: ANN001
|
||||
self._settings = settings
|
||||
|
||||
def keep_session_alive(self): # noqa: ANN201
|
||||
return {
|
||||
"scope": "https://www.iaai.com/Search?url=abc",
|
||||
"marker_present": True,
|
||||
"result_count": 1234,
|
||||
"elapsed_ms": 456,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("iaai_sync_service.tasks.IAAIClient", _FakeClient)
|
||||
|
||||
result = execute_keepalive_job()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["marker_present"] is True
|
||||
assert result["result_count"] == 1234
|
||||
assert session.closed is True
|
||||
Reference in New Issue
Block a user