from __future__ import annotations import tempfile import unittest from datetime import datetime, timedelta, timezone from pathlib import Path from sqlalchemy import select from mobilede_scraper.core.config import Settings from mobilede_scraper.storage.db import PersistenceService from mobilede_scraper.storage.models import Car, Image, SyncRun from mobilede_scraper.storage.schemas import CarRecord, ImageRecord class TestPersistenceServiceIntegration(unittest.TestCase): def setUp(self) -> None: self.tmp_dir = tempfile.TemporaryDirectory() db_path = Path(self.tmp_dir.name) / "test.sqlite" self.settings = Settings() self.settings.database.url = f"sqlite:///{db_path.as_posix()}" self.settings.database.echo = False self.persistence = PersistenceService(self.settings) self.persistence.create_tables() def tearDown(self) -> None: self.persistence.engine.dispose() self.tmp_dir.cleanup() @staticmethod def _record(origin_id: str, *, price: int = 1000) -> CarRecord: return CarRecord( parser_id=f"mobilede:{origin_id}", brand="Toyota", model="Camry", year=2014, price=price, origin_url=f"https://www.MOBILEDE.com/VehicleDetail/{origin_id}~US", origin_id=origin_id, slug=f"toyota-camry-{origin_id}", images=[ ImageRecord( fullres_image="https://vis.MOBILEDE.com/resizer?imageKeys=1&width=845&height=633", preview_image="https://vis.MOBILEDE.com/resizer?imageKeys=1&width=400&height=300", order_index=0, ) ], ) @staticmethod def _as_utc(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: return value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc) def test_insert_update_and_skip_flow(self) -> None: first = self._record("777", price=1000) inserted = self.persistence.upsert_car(first) self.assertEqual(inserted["action"], "inserted") self.assertEqual(inserted["images_upserted"], 1) same = self._record("777", price=1000) updated_same = self.persistence.upsert_car(same) self.assertEqual(updated_same["action"], "updated") self.assertEqual(updated_same["images_upserted"], 1) changed = self._record("777", price=1500) updated = self.persistence.upsert_car(changed) self.assertEqual(updated["action"], "updated") self.assertEqual(updated["images_upserted"], 1) with self.persistence.session_scope() as session: cars = session.execute(select(Car)).scalars().all() images = session.execute(select(Image)).scalars().all() self.assertEqual(len(cars), 1) self.assertEqual(cars[0].price, 1500) self.assertEqual(len(images), 1) def test_update_replaces_old_images(self) -> None: first = self._record("888") self.persistence.upsert_car(first) second = self._record("888") second.images = [ ImageRecord( fullres_image="https://vis.MOBILEDE.com/resizer?imageKeys=2&width=845&height=633", preview_image="https://vis.MOBILEDE.com/resizer?imageKeys=2&width=400&height=300", order_index=0, ) ] self.persistence.upsert_car(second) with self.persistence.session_scope() as session: images = session.execute(select(Image)).scalars().all() self.assertEqual(len(images), 1) self.assertIn("imageKeys=2", images[0].fullres_image) def test_start_sync_run_marks_stale_running_runs_as_failed(self) -> None: first_run_id = self.persistence.start_sync_run("lane-a") second_run_id = self.persistence.start_sync_run("lane-b") self.assertNotEqual(first_run_id, second_run_id) with self.persistence.session_scope() as session: first = session.get(SyncRun, first_run_id) second = session.get(SyncRun, second_run_id) self.assertEqual(first.status, "failed") self.assertIsNotNone(first.finished_at) self.assertEqual(second.status, "running") def test_upsert_falls_back_to_origin_url_to_prevent_duplicates(self) -> None: first = self._record("OLD-ID") first.origin_url = "https://www.MOBILEDE.com/VehicleDetail/45089484~US" self.persistence.upsert_car(first) second = self._record("NEW-ID") second.origin_url = "https://www.MOBILEDE.com/VehicleDetail/45089484~US" result = self.persistence.upsert_car(second) self.assertEqual(result["action"], "updated") with self.persistence.session_scope() as session: cars = session.execute(select(Car)).scalars().all() self.assertEqual(len(cars), 1) self.assertEqual(cars[0].origin_id, "NEW-ID") def test_upsert_preserves_first_seen_and_reactivates_seen_car(self) -> None: first_seen = datetime(2026, 5, 1, tzinfo=timezone.utc) sold_at = datetime(2026, 5, 2, tzinfo=timezone.utc) seen_at = datetime(2026, 5, 5, tzinfo=timezone.utc) first = self._record("mobile.de:777", price=1000) first.first_seen_at = first_seen first.last_seen_at = first_seen self.persistence.upsert_car(first) with self.persistence.session_scope() as session: car = session.execute(select(Car).where(Car.origin_id == "mobile.de:777")).scalar_one() car.is_sold = True car.sold_at = sold_at updated = self._record("mobile.de:777", price=1500) updated.first_seen_at = seen_at updated.last_seen_at = seen_at updated.is_sold = False updated.sold_at = None self.persistence.upsert_car(updated) with self.persistence.session_scope() as session: car = session.execute(select(Car).where(Car.origin_id == "mobile.de:777")).scalar_one() self.assertEqual(self._as_utc(car.first_seen_at), first_seen) self.assertEqual(self._as_utc(car.last_seen_at), seen_at) self.assertFalse(car.is_sold) self.assertIsNone(car.sold_at) self.assertEqual(car.price, 1500) def test_mark_sold_not_seen_since_sets_sold_at_to_cycle_seen_at(self) -> None: seen_at = datetime(2026, 5, 5, tzinfo=timezone.utc) old = self._record("mobile.de:old") old.first_seen_at = seen_at - timedelta(days=2) old.last_seen_at = seen_at - timedelta(days=1) current = self._record("mobile.de:current") current.first_seen_at = seen_at current.last_seen_at = seen_at self.persistence.upsert_car(old) self.persistence.upsert_car(current) marked = self.persistence.mark_sold_not_seen_since(seen_at) self.assertEqual(marked, 1) with self.persistence.session_scope() as session: cars = session.execute(select(Car).order_by(Car.origin_id.asc())).scalars().all() sold_map = {car.origin_id: (car.is_sold, self._as_utc(car.sold_at)) for car in cars} self.assertEqual(sold_map["mobile.de:old"], (True, seen_at)) self.assertEqual(sold_map["mobile.de:current"], (False, None)) def test_get_active_cars_batch_for_image_enrich_selects_low_image_active_cars(self) -> None: low = self._record("mobile.de:low") low.images = [ ImageRecord( fullres_image="https://img.classistatic.de/api/v1/mo-prod/images/low?rule=mo-640.jpg", preview_image="https://img.classistatic.de/api/v1/mo-prod/images/low?rule=mo-200.jpg", order_index=0, ) ] rich = self._record("mobile.de:rich") rich.images = [ ImageRecord( fullres_image=f"https://img.classistatic.de/api/v1/mo-prod/images/rich-{idx}?rule=mo-640.jpg", preview_image=f"https://img.classistatic.de/api/v1/mo-prod/images/rich-{idx}?rule=mo-200.jpg", order_index=idx, ) for idx in range(3) ] sold = self._record("mobile.de:sold") sold.images = [] sold.is_sold = True self.persistence.upsert_car(low) self.persistence.upsert_car(rich) self.persistence.upsert_car(sold) candidates = self.persistence.get_active_cars_batch_for_image_enrich(limit=10, max_existing_images=1) self.assertEqual([(origin_id, image_count) for _id, origin_id, _url, image_count in candidates], [("mobile.de:low", 1)]) if __name__ == "__main__": unittest.main()