o2o-negosium-original/lps/tests/test_price_history.py
민헌 74db218410 feat(lps): 2단계 — price_history 에 몰별 확인 상태 저장
1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는
몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다.

- price_history.sources (JSONB): 몰별 상태를 그대로 담는다.
  {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
  열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침).
- price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 —
  소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로
  흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다.
- 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용).
- _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는
  sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정).
- 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**)

검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만
partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다.
JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정.

테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:57:19 +09:00

155 lines
7.5 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 # 미검증 오퍼임이 드러난다
# ── 몰별 확인 상태 (2026-08-07, 2단계) ──────────────────────────────────
# by_mall 은 '가격이 있는 몰'만 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지
# '거기를 못 봤다'인지 구분되지 않았다 — sources/partial 이 그 자리를 메운다.
async def test_records_source_states_and_partial(ph, db_engine):
await ph.record({
"product_code": "SRC1", "outcome": "found", "final_lowest": 9000, "partial": True,
"sources": {"naver": {"state": "matched", "count": 40},
"coupang": {"state": "blocked", "error": "AdapterError: 차단"}},
})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT partial, sources FROM price_history WHERE product_code='SRC1'"))).first()
assert row.partial is True
assert row.sources["coupang"]["state"] == "blocked"
assert row.sources["naver"]["count"] == 40
async def test_partial_defaults_to_false_when_absent(ph, db_engine):
"""옛 호출부(값을 안 주는 경로)도 깨지지 않아야 한다 — 기본은 '완전한 결과'."""
await ph.record({"product_code": "SRC2", "outcome": "not_found"})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT partial, sources FROM price_history WHERE product_code='SRC2'"))).first()
assert row.partial is False and row.sources is None
async def test_source_state_survives_korean_text(ph, db_engine):
"""error 메시지에 한글이 섞여도 JSONB 가 깨지지 않아야 한다(ensure_ascii=False)."""
await ph.record({
"product_code": "SRC3", "outcome": "found", "partial": True,
"sources": {"coupang": {"state": "env_blocked", "error": "사용권한이 제한된 페이지"}},
})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT sources FROM price_history WHERE product_code='SRC3'"))).first()
assert "사용권한이 제한된" in row.sources["coupang"]["error"]