"""최저가 이력 — 소스별 min 스냅샷 계산 + 기록/조회 CRUD + 핸들러 기록 규칙.""" import pytest_asyncio from sqlalchemy import text from common.enums import JobType from crud.price_history import PriceHistory from services.search.contract import NormalizedProduct from worker.handlers import _price_snapshot, build_search_handler def _np(source, price, name=None): return NormalizedProduct(source=source, name=name or f"{source}-{price}", price=price, detail_url=f"http://{source}/{price}") # ── 스냅샷 계산 (순수) ───────────────────────────────────────────── def test_snapshot_source_lowest_and_final(): matched = [_np("naver", 3000), _np("naver", 2000), _np("coupang", 2500)] s = _price_snapshot(matched) assert s["naver_lowest"] == 2000 and s["coupang_lowest"] == 2500 assert s["final_lowest"] == 2000 and s["final_source"] == "naver" assert s["matched_count"] == 3 def test_snapshot_single_source_only(): s = _price_snapshot([_np("coupang", 1500)]) assert s["naver_lowest"] is None and s["coupang_lowest"] == 1500 assert s["final_lowest"] == 1500 and s["final_source"] == "coupang" def test_snapshot_empty_is_all_null(): s = _price_snapshot([]) assert s["final_lowest"] is None and s["naver_lowest"] is None and s["matched_count"] == 0 # ── CRUD (실 DB) ─────────────────────────────────────────────────── @pytest_asyncio.fixture async def ph(db_engine): async with db_engine.begin() as conn: await conn.execute(text("TRUNCATE price_history")) return PriceHistory() async def test_record_and_list_time_ordered(ph): await ph.record({"product_code": "P1", "outcome": "found", "final_lowest": 2000, "final_source": "naver", "naver_lowest": 2000, "coupang_lowest": 2500, "matched_count": 3}) await ph.record({"product_code": "P1", "outcome": "found", "final_lowest": 1900, "final_source": "coupang", "naver_lowest": 2100, "coupang_lowest": 1900, "matched_count": 2}) await ph.record({"product_code": "P2", "outcome": "found", "final_lowest": 999}) # 다른 상품 points = await ph.list_by_product("P1") assert len(points) == 2 # P2 제외 assert [p["final_lowest"] for p in points] == [2000, 1900] # 시각 오름차순 assert points[0]["triggered_at"] <= points[1]["triggered_at"] # ── 핸들러 기록 규칙 ─────────────────────────────────────────────── class _Rec: def __init__(self): self.events = [] async def record(self, e): self.events.append(e) class _FakeAdapter: def __init__(self, source, products): self.source = source; self._p = products async def search(self, q, limit=40): return self._p class _Neg: def __init__(self, neg): self._neg = neg async def is_negative(self, k): return self._neg async def put(self, *a, **k): pass def _job(**p): p.setdefault("product_name", "x"); p.setdefault("product_code", "PC1") return {"job_type": JobType.SEARCH.value, "attempts": 1, "job_id": "j1", "payload": p} async def test_handler_records_found_snapshot(): rec = _Rec() adapters = {"naver": _FakeAdapter("naver", [_np("naver", 2000)]), "coupang": _FakeAdapter("coupang", [_np("coupang", 1800)])} await build_search_handler(adapters, history=rec)(_job()) assert len(rec.events) == 1 e = rec.events[0] assert e["product_code"] == "PC1" and e["outcome"] == "found" assert e["final_lowest"] == 1800 and e["final_source"] == "coupang" async def test_handler_records_on_negative_cache_hit(): """캐시 히트도 '이 잡의 결과'라 이력을 남겨야 한다. 예전엔 남기지 않았는데, 그러면 잡은 DONE 인데 price_history 에 새 행이 없어 이걸 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다(실측 버그). 검색을 생략했을 뿐 결과는 not_found 로 확정된 것이므로 기록이 맞다.""" rec = _Rec() adapters = {"naver": _FakeAdapter("naver", [_np("naver", 100)])} out = await build_search_handler(adapters, neg_cache=_Neg(True), history=rec)(_job()) assert out["outcome"] == "not_found" and out["cached"] is True assert len(rec.events) == 1 e = rec.events[0] assert e["product_code"] == "PC1" and e["outcome"] == "not_found" assert e["final_lowest"] is None and e["matched_count"] == 0 # 검색을 안 했으니 가격도 없다 async def test_snapshot_carries_trust_of_the_lowest_offer(): """최저가 오퍼의 평점·리뷰가 이력에 함께 남아야 '살 수 있는 가격이었나'를 사후에 물을 수 있다.""" rec = _Rec() cheap_ghost = _np("naver", 900) # 가장 싸지만 리뷰·평점 없음 trusted = _np("coupang", 1800) trusted.rating, trusted.review_count = 4.8, 1200 adapters = {"naver": _FakeAdapter("naver", [cheap_ghost]), "coupang": _FakeAdapter("coupang", [trusted])} await build_search_handler(adapters, history=rec)(_job()) e = rec.events[0] assert e["final_lowest"] == 900 and e["final_source"] == "naver" assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다