Update model translations

This commit is contained in:
qananasikq
2026-07-01 14:35:49 +03:00
commit 3904cd003e
53 changed files with 7629 additions and 0 deletions

7
tests/conftest.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
import logging
def pytest_configure() -> None:
logging.disable(logging.CRITICAL)

127
tests/test_db.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from sqlalchemy import select
from encar_scraper.core.config import Settings
from encar_scraper.storage.db import PersistenceService
from encar_scraper.storage.models import Car, Image, SyncRun
from encar_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"encar:{origin_id}",
brand="Toyota",
model="Camry",
year=2014,
price=price,
origin_url=f"https://www.encar.com/dc/dc_cardetailview.do?carid={origin_id}",
origin_id=origin_id,
slug=f"toyota-camry-{origin_id}",
images=[
ImageRecord(
fullres_image=f"https://www.encar.com/carpicture01/pic{origin_id}_001.jpg",
preview_image=f"https://www.encar.com/carpicture01/pic{origin_id}_001.jpg",
order_index=0,
)
],
)
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://www.encar.com/carpicture01/pic888_002.jpg",
preview_image="https://www.encar.com/carpicture01/pic888_002.jpg",
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("pic888_002.jpg", 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.encar.com/dc/dc_cardetailview.do?carid=45089484"
self.persistence.upsert_car(first)
second = self._record("NEW-ID")
second.origin_url = "https://www.encar.com/dc/dc_cardetailview.do?carid=45089484"
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")
if __name__ == "__main__":
unittest.main()

148
tests/test_encar.py Normal file
View File

@@ -0,0 +1,148 @@
import unittest
from unittest.mock import MagicMock
from encar_scraper.encar import EncarMapper, EncarScraper, ENCAR_DETAIL_URL_TEMPLATE
class TestEncarMapper(unittest.TestCase):
def test_map_to_car_record(self):
payload = {
"Id": 41421262,
"Manufacturer": "Hyundai",
"Model": "G90 (RS4)",
"FormYear": "2023",
"Price": 8590,
"Mileage": 37923,
"Photos": [
{"location": "/carpicture01/pic4141/41412663_001.jpg", "ordering": 1},
{"location": "/carpicture01/pic4141/41412663_003.jpg", "ordering": 3},
],
}
record = EncarMapper().map_to_car_record(
ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41421262"),
payload,
)
self.assertEqual(record.origin_id, "encar:41421262")
self.assertEqual(record.parser_id, "encar:41421262")
self.assertEqual(record.brand, "Hyundai")
# Скобки удаляются — на оригинальном сайте Encar их нет.
self.assertEqual(record.model, "G90")
self.assertEqual(record.year, 2023)
self.assertEqual(record.price, 85900000) # 8590 만원 × 10000
self.assertEqual(record.mileage, 37923)
self.assertEqual(record.country, "KR")
self.assertEqual(record.currency, "KRW")
self.assertEqual(len(record.images), 2)
self.assertTrue(record.images[0].fullres_image.endswith("_001.jpg"))
def test_extract_vehicle_id_from_url(self):
url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41421262")
self.assertEqual(EncarMapper()._extract_vehicle_id(url), "41421262")
def test_map_to_car_record_uses_base_model_without_trim(self):
payload = {
"Id": 41854374,
"Manufacturer": "현대",
"Model": "Genesis",
"Badge": "BH330 Grand",
"BadgeDetail": "Prime팩",
"FormYear": "2011",
"Price": 1200,
"Mileage": 100000,
"Photos": [
{"location": "/carpicture01/pic4185/41854374_001.jpg", "ordering": 1},
],
}
record = EncarMapper().map_to_car_record(
ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41854374"),
payload,
)
self.assertEqual(record.brand, "Hyundai")
self.assertEqual(record.model, "Genesis")
self.assertEqual(record.origin_id, "encar:41854374")
def test_map_to_car_record_resolves_numeric_id_from_photos(self):
payload = {
"Id": "car-BOqLLkjkgbpStlVJEJvbRG",
"Manufacturer": "Kia",
"Model": "K3",
"FormYear": "2018",
"Price": 990,
"Mileage": 75000,
"Photos": [
{"location": "/carpicture01/pic4187/41878507_001.jpg", "ordering": 1},
],
}
record = EncarMapper().map_to_car_record("", payload)
self.assertEqual(record.origin_id, "encar:41878507")
self.assertEqual(record.parser_id, "encar:41878507")
self.assertIn("carid=41878507", record.origin_url)
def test_translate_model_keeps_english_text(self):
mapper = EncarMapper()
self.assertEqual(mapper._translate_model("All New Carnival"), "All New Carnival")
self.assertEqual(mapper._translate_model("New Kia Ray"), "New Kia Ray")
self.assertEqual(mapper._translate_model("K5 Hybrid 3rd gen"), "K5 Hybrid 3rd gen")
def test_translate_model_handles_korean_fragments(self):
mapper = EncarMapper()
cases = {
# Скобки и их содержимое отбрасываются как на сайте Encar.
"5시리즈 (G30)": "5 Series",
"S-클래스 W223": "S-Class W223",
"Sonata 디 엣지(DN8)": "Sonata The Edge",
"렉스턴 Sport 칸": "Rexton Sport Khan",
"티볼리 Air": "Tivoli Air",
"코란도 투리스모": "Korando Turismo",
"Kia Carnival 4세대": "Kia Carnival 4th gen",
"Cadillac 에스컬레이드 5세대": "Cadillac Escalade 5th gen",
"K7 프리미어": "K7 Premier",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(mapper._translate_model(raw), expected)
def test_translate_model_drops_untranslated_korean(self):
mapper = EncarMapper()
cases = {
# "레이" (Ray) не должен резать слово "슈팅브레이크" изнутри.
"슈팅브레이크": "",
"The New G70 슈팅브레이크": "The New G70",
"G70 (슈팅브레이크)": "G70",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(mapper._translate_model(raw), expected)
class TestEncarScraper(unittest.TestCase):
def test_collect_listing_limits_results(self):
scraper = EncarScraper()
scraper._fetch_listing_page = MagicMock(side_effect=[
{"SearchResults": [{"Id": 1}, {"Id": 2}]},
])
result = scraper.collect_listing(limit=1, page_size=2)
self.assertEqual(result["items_collected"], 1)
self.assertEqual(result["vehicle_urls"], [ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="1")])
def test_scrape_vehicle_detail_raises_without_id(self):
scraper = EncarScraper()
with self.assertRaises(ValueError):
scraper.scrape_vehicle_detail("https://www.encar.com/invalid")
if __name__ == "__main__":
unittest.main()

417
tests/test_load.py Normal file
View File

@@ -0,0 +1,417 @@
"""
Нагрузочные тесты: API endpoints, batch upsert, конкурентный доступ к БД.
Запуск:
pytest tests/test_load.py -v -s
"""
from __future__ import annotations
import concurrent.futures
import time
import random
import string
import unittest
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from encar_scraper.api.app import create_app
from encar_scraper.core.config import DatabaseConfig, RedisConfig, Settings
from encar_scraper.storage.db import PersistenceService
from encar_scraper.storage.schemas import CarRecord, ImageRecord
# ── helpers ──────────────────────────────────────────────────────────────
def _sqlite_settings() -> Settings:
return Settings(
database=DatabaseConfig(url="sqlite:///test_load.db", echo=False, auto_create_tables=True),
redis=RedisConfig(url="redis://localhost:6379/15"),
)
def _make_car(idx: int, *, images: int = 3) -> CarRecord:
uid = f"load-{idx}-{''.join(random.choices(string.ascii_lowercase, k=4))}"
return CarRecord(
parser_id=uid,
brand=random.choice(["Hyundai", "Kia", "BMW", "Mercedes", "Genesis"]),
model=f"Model-{idx % 50}",
year=random.randint(2010, 2025),
price=random.randint(5_000_000, 80_000_000),
currency="KRW",
mileage=random.randint(0, 300_000),
country="KR",
origin="ENCAR",
origin_url=f"https://encar.com/cars/{uid}",
origin_id=uid,
slug=uid,
selling_type="NA",
body_type="SEDAN",
last_seen_at=datetime.now(timezone.utc),
images=[
ImageRecord(
fullres_image=f"https://ci.encar.com/photo/{uid}/{j}.jpg",
preview_image=f"https://ci.encar.com/photo/{uid}/{j}_thumb.jpg",
order_index=j,
)
for j in range(images)
],
)
# ── DB load tests ───────────────────────────────────────────────────────
class TestDatabaseLoad(unittest.TestCase):
"""Тесты производительности PersistenceService на SQLite."""
@classmethod
def setUpClass(cls):
import os
if os.path.exists("test_load.db"):
os.remove("test_load.db")
cls.settings = _sqlite_settings()
cls.persistence = PersistenceService(cls.settings)
cls.persistence.create_tables()
@classmethod
def tearDownClass(cls):
cls.persistence.engine.dispose()
import os
if os.path.exists("test_load.db"):
os.remove("test_load.db")
def test_batch_upsert_500_cars(self):
"""Пакетная вставка 500 авто с изображениями — должна пройти за <10 сек."""
records = [_make_car(i, images=5) for i in range(500)]
t0 = time.perf_counter()
result = self.persistence.upsert_cars_batch(records)
elapsed = time.perf_counter() - t0
print(f"\n[batch_upsert_500] inserted={result['inserted']}, "
f"updated={result['updated']}, images={result['images_upserted']}, "
f"time={elapsed:.2f}s")
self.assertEqual(result["inserted"], 500)
self.assertEqual(result["images_upserted"], 2500)
self.assertLess(elapsed, 10, "Batch upsert 500 cars should complete in <10s")
def test_batch_upsert_update_existing(self):
"""Повторный upsert 200 существующих записей — обновление без дублей."""
records = [_make_car(i, images=3) for i in range(10000, 10200)]
self.persistence.upsert_cars_batch(records)
# Обновляем цену и перезаписываем
for r in records:
r.price = 99_999_999
t0 = time.perf_counter()
result = self.persistence.upsert_cars_batch(records)
elapsed = time.perf_counter() - t0
print(f"\n[batch_update_200] updated={result['updated']}, time={elapsed:.2f}s")
self.assertEqual(result["updated"], 200)
self.assertLess(elapsed, 10)
def test_concurrent_upsert_batches(self):
"""3 параллельных потока по 100 upsert-ов — нет deadlock / corruption."""
def worker(thread_id: int):
batch = [_make_car(thread_id * 10000 + i) for i in range(100)]
return self.persistence.upsert_cars_batch(batch)
t0 = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(worker, tid) for tid in range(3)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
elapsed = time.perf_counter() - t0
total_inserted = sum(r["inserted"] for r in results)
print(f"\n[concurrent_upsert] total_inserted={total_inserted}, time={elapsed:.2f}s")
self.assertEqual(total_inserted, 300)
self.assertLess(elapsed, 30)
def test_single_upsert_throughput(self):
"""50 последовательных upsert_car — замер throughput."""
records = [_make_car(50000 + i, images=2) for i in range(50)]
t0 = time.perf_counter()
for r in records:
self.persistence.upsert_car(r)
elapsed = time.perf_counter() - t0
rps = 50 / elapsed if elapsed > 0 else 0
print(f"\n[single_upsert_50] time={elapsed:.2f}s, rps={rps:.1f}")
self.assertLess(elapsed, 15, "50 individual upserts should complete in <15s")
def test_mark_sold_large_set(self):
"""mark_sold на 300 origin_id — должен быть быстрым."""
records = [_make_car(60000 + i, images=1) for i in range(300)]
self.persistence.upsert_cars_batch(records)
active_ids = {r.origin_id for r in records[:150]}
t0 = time.perf_counter()
sold = self.persistence.mark_sold_not_in_listing(active_origin_ids=active_ids, lane="encar")
elapsed = time.perf_counter() - t0
print(f"\n[mark_sold_300] marked_sold={sold}, time={elapsed:.2f}s")
self.assertLess(elapsed, 5)
def test_sync_run_lifecycle(self):
"""start → finish sync_run 50 раз — измеряем overhead."""
t0 = time.perf_counter()
for _ in range(50):
run_id = self.persistence.start_sync_run("encar")
self.persistence.finish_sync_run(
run_id,
status="completed",
ids_fetched=1000,
cars_upserted=950,
cars_failed=50,
images_upserted=15000,
)
elapsed = time.perf_counter() - t0
print(f"\n[sync_run_lifecycle_50] time={elapsed:.2f}s")
self.assertLess(elapsed, 5)
# ── API load tests ──────────────────────────────────────────────────────
class TestAPILoad(unittest.TestCase):
"""Нагрузочные тесты на FastAPI endpoints через TestClient."""
@classmethod
def setUpClass(cls):
import os
if os.path.exists("test_api_load.db"):
os.remove("test_api_load.db")
settings = Settings(
database=DatabaseConfig(url="sqlite:///test_api_load.db", echo=False, auto_create_tables=True),
redis=RedisConfig(url="redis://localhost:6379/15"),
)
app = create_app(settings)
app.state.persistence.create_tables()
# Предзаполняем БД 200 авто
persistence: PersistenceService = app.state.persistence
persistence.upsert_cars_batch([_make_car(i, images=4) for i in range(200)])
cls.app = app
cls.client = TestClient(app)
@classmethod
def tearDownClass(cls):
import os
cls.client.close()
cls.app.state.persistence.engine.dispose()
if os.path.exists("test_api_load.db"):
os.remove("test_api_load.db")
def test_health_check_rapid(self):
"""100 GET /health подряд — все 200, <5 сек."""
t0 = time.perf_counter()
for _ in range(100):
r = self.client.get("/health")
self.assertEqual(r.status_code, 200)
elapsed = time.perf_counter() - t0
print(f"\n[health_100] time={elapsed:.2f}s, rps={100 / elapsed:.0f}")
self.assertLess(elapsed, 5)
def test_list_cars_paginated_rapid(self):
"""100 GET /api/v1/cars с разными страницами — все 200, <10 сек."""
t0 = time.perf_counter()
for page in range(1, 101):
r = self.client.get("/api/v1/cars", params={"page": (page % 10) + 1, "per_page": 20})
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertIn("items", data)
elapsed = time.perf_counter() - t0
print(f"\n[list_cars_100] time={elapsed:.2f}s, rps={100 / elapsed:.0f}")
self.assertLess(elapsed, 10)
def test_list_cars_with_filters(self):
"""50 GET /api/v1/cars с brand/year фильтрами."""
t0 = time.perf_counter()
for i in range(50):
brand = random.choice(["Hyundai", "Kia", "BMW", "Mercedes", "Genesis"])
r = self.client.get(
"/api/v1/cars",
params={"brand": brand, "year_min": 2015, "year_max": 2023, "per_page": 50},
)
self.assertEqual(r.status_code, 200)
elapsed = time.perf_counter() - t0
print(f"\n[filtered_cars_50] time={elapsed:.2f}s, rps={50 / elapsed:.0f}")
self.assertLess(elapsed, 10)
def test_get_car_by_id_rapid(self):
"""50 GET /api/v1/cars/{id} — все 200."""
t0 = time.perf_counter()
for car_id in range(1, 51):
r = self.client.get(f"/api/v1/cars/{car_id}")
self.assertIn(r.status_code, (200, 404))
elapsed = time.perf_counter() - t0
print(f"\n[get_car_50] time={elapsed:.2f}s, rps={50 / elapsed:.0f}")
self.assertLess(elapsed, 10)
def test_stats_endpoint_rapid(self):
"""50 GET /api/v1/stats — агрегация по всей таблице."""
t0 = time.perf_counter()
for _ in range(50):
r = self.client.get("/api/v1/stats")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertGreater(data["total_cars"], 0)
elapsed = time.perf_counter() - t0
print(f"\n[stats_50] time={elapsed:.2f}s, rps={50 / elapsed:.0f}")
self.assertLess(elapsed, 10)
def test_concurrent_api_requests(self):
"""20 конкурентных запросов к разным endpoints — нет 500-х."""
def make_request(idx: int):
if idx % 3 == 0:
return self.client.get("/health")
elif idx % 3 == 1:
return self.client.get("/api/v1/cars", params={"page": 1, "per_page": 10})
else:
return self.client.get("/api/v1/stats")
t0 = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
futures = [pool.submit(make_request, i) for i in range(20)]
responses = [f.result() for f in concurrent.futures.as_completed(futures)]
elapsed = time.perf_counter() - t0
for r in responses:
self.assertNotEqual(r.status_code, 500, f"Got 500: {r.text}")
print(f"\n[concurrent_api_20] time={elapsed:.2f}s, all_ok={all(r.status_code == 200 for r in responses)}")
self.assertLess(elapsed, 10)
def test_large_page_size(self):
"""GET /api/v1/cars с per_page=100 — проверяем сериализацию больших ответов."""
t0 = time.perf_counter()
for _ in range(20):
r = self.client.get("/api/v1/cars", params={"page": 1, "per_page": 100})
self.assertEqual(r.status_code, 200)
data = r.json()
# Каждый car содержит images — проверяем что сериализация прошла
for item in data["items"]:
self.assertIn("images", item)
elapsed = time.perf_counter() - t0
print(f"\n[large_page_20] time={elapsed:.2f}s, rps={20 / elapsed:.0f}")
self.assertLess(elapsed, 10)
# ── Data integrity under load ───────────────────────────────────────────
class TestDataIntegrity(unittest.TestCase):
"""Проверка целостности данных при массовых операциях."""
@classmethod
def setUpClass(cls):
import os
if os.path.exists("test_integrity.db"):
os.remove("test_integrity.db")
cls.settings = Settings(
database=DatabaseConfig(url="sqlite:///test_integrity.db", echo=False, auto_create_tables=True),
redis=RedisConfig(url="redis://localhost:6379/15"),
)
cls.persistence = PersistenceService(cls.settings)
cls.persistence.create_tables()
@classmethod
def tearDownClass(cls):
cls.persistence.engine.dispose()
import os
if os.path.exists("test_integrity.db"):
os.remove("test_integrity.db")
def test_no_duplicates_after_repeated_upserts(self):
"""3 раза upsert одного батча — ровно N уникальных записей в БД."""
records = [_make_car(90000 + i) for i in range(100)]
for _ in range(3):
self.persistence.upsert_cars_batch(records)
from sqlalchemy import select, func
from encar_scraper.storage.models import Car
with self.persistence.session_scope() as session:
count = session.execute(
select(func.count(Car.id)).where(Car.origin_id.like("load-90%"))
).scalar()
self.assertEqual(count, 100, f"Expected 100 unique cars, got {count}")
def test_images_replaced_not_accumulated(self):
"""При upsert с новыми images — старые удаляются, а не копятся."""
rec = _make_car(80001, images=5)
self.persistence.upsert_car(rec)
# Обновляем с 2 изображениями
rec.images = [
ImageRecord(fullres_image="https://new/1.jpg", preview_image="https://new/1t.jpg", order_index=0),
ImageRecord(fullres_image="https://new/2.jpg", preview_image="https://new/2t.jpg", order_index=1),
]
self.persistence.upsert_car(rec)
from sqlalchemy import select, func
from encar_scraper.storage.models import Car, Image
with self.persistence.session_scope() as session:
car = session.execute(select(Car).where(Car.origin_id == rec.origin_id)).scalar_one()
img_count = session.execute(
select(func.count(Image.id)).where(Image.car_id == car.id)
).scalar()
self.assertEqual(img_count, 2, f"Expected 2 images after replace, got {img_count}")
def test_dedup_within_batch(self):
"""Батч с дубликатами origin_id — дедупликация работает."""
base = _make_car(70001)
dupe = _make_car(70001)
dupe.parser_id = base.parser_id
dupe.origin_id = base.origin_id
dupe.origin_url = base.origin_url
dupe.price = 12345
records = [base, dupe]
result = self.persistence.upsert_cars_batch(records)
self.assertEqual(result["inserted"] + result["updated"], 1,
"Dedup should reduce 2 records to 1")
def test_batch_1000_cars_integrity(self):
"""Вставка 1000 авто — проверяем что ВСЕ записи в БД с верными images."""
records = [_make_car(200000 + i, images=3) for i in range(1000)]
result = self.persistence.upsert_cars_batch(records)
self.assertEqual(result["inserted"], 1000)
self.assertEqual(result["images_upserted"], 3000)
from sqlalchemy import select, func
from encar_scraper.storage.models import Car, Image
with self.persistence.session_scope() as session:
car_count = session.execute(
select(func.count(Car.id)).where(Car.origin_id.like("load-20____-%"))
).scalar()
img_count = session.execute(
select(func.count(Image.id)).where(
Image.car_id.in_(
select(Car.id).where(Car.origin_id.like("load-20____-%"))
)
)
).scalar()
self.assertEqual(car_count, 1000)
self.assertEqual(img_count, 3000)
if __name__ == "__main__":
unittest.main()

31
tests/test_utils.py Normal file
View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import unittest
from encar_scraper.core.utils import deep_find_key
class TestDeepFindKey(unittest.TestCase):
def test_finds_key_in_nested_structure(self) -> None:
payload = {
"root": {
"target": "a",
"nested": [{"target": "b"}, {"x": 1}],
}
}
found = deep_find_key(payload, {"target"})
self.assertEqual(found, ["a", "b"])
def test_respects_max_depth(self) -> None:
payload = {"l1": {"l2": {"l3": {"target": "value"}}}}
found_too_shallow = deep_find_key(payload, {"target"}, max_depth=2)
found_enough_depth = deep_find_key(payload, {"target"}, max_depth=8)
self.assertEqual(found_too_shallow, [])
self.assertEqual(found_enough_depth, ["value"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from encar_scraper.worker import tasks
class TestWorkerTaskLockHelpers(unittest.TestCase):
def test_acquire_lock_returns_true_on_success(self) -> None:
redis_client = MagicMock()
redis_client.set.return_value = True
acquired = tasks._acquire_lock(redis_client, "lock:key", "owner-token", 120)
self.assertTrue(acquired)
redis_client.set.assert_called_once_with("lock:key", "owner-token", nx=True, ex=120)
def test_refresh_lock_if_owner_extends_ttl(self) -> None:
redis_client = MagicMock()
mock_script = MagicMock(return_value=1)
redis_client.register_script.return_value = mock_script
refreshed = tasks._refresh_lock_if_owner(redis_client, "lock:key", "owner-token", 120)
self.assertTrue(refreshed)
redis_client.register_script.assert_called_once()
mock_script.assert_called_once_with(keys=["lock:key"], args=["owner-token", 120])
def test_release_lock_if_owner_uses_owner_token(self) -> None:
redis_client = MagicMock()
mock_script = MagicMock(return_value=1)
redis_client.register_script.return_value = mock_script
tasks._release_lock_if_owner(redis_client, "lock:key", "owner-token")
redis_client.register_script.assert_called_once()
mock_script.assert_called_once_with(keys=["lock:key"], args=["owner-token"])
def test_encar_sync_listing_task_skips_when_lock_not_acquired(self) -> None:
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "_acquire_lock", return_value=False):
persistence = MagicMock()
get_persistence.return_value = persistence
get_redis.return_value = MagicMock()
tasks.encar_sync_listing_task.push_request(id="task-123")
try:
result = tasks.encar_sync_listing_task.run()
finally:
tasks.encar_sync_listing_task.pop_request()
persistence.create_tables.assert_called_once()
self.assertEqual(result["status"], "skipped")
self.assertEqual(result["reason"], "sync_already_running")
def test_encar_sync_listing_task_releases_owned_lock(self) -> None:
mock_scraper_instance = MagicMock()
mock_scraper_instance.sync_listing.return_value = {
"total_available": 100,
"items_collected": 10,
"cars_synced": 8,
"cars_failed": 2,
}
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch("encar_scraper.worker.tasks.EncarScraper", return_value=mock_scraper_instance):
persistence = MagicMock()
get_persistence.return_value = persistence
redis_client = MagicMock()
get_redis.return_value = redis_client
stop_event = MagicMock()
heartbeat_thread = MagicMock()
start_heartbeat.return_value = (stop_event, heartbeat_thread)
tasks.encar_sync_listing_task.push_request(id="task-123")
try:
with patch.object(tasks.encar_sync_listing_task, "update_state"):
result = tasks.encar_sync_listing_task.run(car_type="all")
finally:
tasks.encar_sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
stop_event.set.assert_called_once()
heartbeat_thread.join.assert_called_once()
release_lock.assert_called_once()
if __name__ == "__main__":
unittest.main()