'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 — 조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다. - NormalizedProduct.rating / review_count 추가. 두 소스 모두 카드에 노출하는 값만 담아 교차 비교가 되게 했다. **없으면 None 유지** — '리뷰 0개'와 '리뷰 정보 없음'은 다른 뜻이다 - 네이버: product_grade 의 <strong>평점</strong><em>리뷰수</em>. 텍스트를 통째로 정규식 돌리면 '평점4.7473' 이 4.74/73 인지 4.7/473 인지 못 가르므로 노드로 분리해 읽는다. '1.7만' 같은 축약은 parse_ko_count 로 푼다(그대로 int() 하면 1 이 된다) - 쿠팡: 별점은 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다 - price_history.final_rating/final_review_count 추가(+마이그레이션) → "리뷰 0인 최저가가 몇 %인가"를 SQL 로 물을 수 있다. NULL 과 0 을 구분해야 해서 기본값을 두지 않았다 정렬 점검(사용자 제기): 두 소스 다 정렬 파라미터 없이 **랭킹/추천순**이다(픽스처 가격이 오름차순이 아님으로 확인). 가격순(sort=price_asc)은 차단 없이 동작하고 실측상 더 싼 후보를 찾지만(15,400→10,900), 리뷰·평점 없는 유령상품을 위로 끌어올려 미채택 — 추천순 유지. 신뢰 신호가 쌓이면 "리뷰 N 이상" 가드를 걸고 가격순을 켜는 선택지가 열린다. e2e: TR-1/TR-2 최저가에 평점 4.89·리뷰 7,314/102,000 이 함께 기록됨. 테스트 5건 추가, 228 passed.
116 lines
5.4 KiB
Python
116 lines
5.4 KiB
Python
"""최저가 이력 — 소스별 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 # 미검증 오퍼임이 드러난다
|