o2o-negosium-original/lps/tests/test_price_history.py
민헌 5906acc48a feat(lps): 최저가 이력(price_history) — 트리거 기반 시계열 그래프
같은 상품 반복 검색 시 최저가를 스냅샷으로 적재 → 네이버/쿠팡/최종 3개 선 그래프.
배치 아님(조회된 상품만, 실제 검색 시각에 기록) — 트래픽/리소스 절약.

- price_history 테이블: product_code·triggered_at(X축)·naver/coupang/final 최저가+상세·outcome
- crud/price_history: record() + list_by_product(시각 오름차순)
- handler: AI 매칭 후 소스별 min + 전체 min 스냅샷 기록(_price_snapshot).
  found/not_found 기록, 네거티브 캐시 히트·기술실패는 미기록
- API: GET /v1/lps/products/{product_code}/history → 그래프 데이터(시각 오름차순)
- tests: 스냅샷 계산/기록·조회/핸들러 기록규칙/API → 전체 54/54

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:13:28 +09:00

94 lines
4.0 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_skips_record_on_negative_cache_hit():
rec = _Rec()
adapters = {"naver": _FakeAdapter("naver", [_np("naver", 100)])}
await build_search_handler(adapters, neg_cache=_Neg(True), history=rec)(_job())
assert rec.events == [] # 캐시 히트 → 새 관측 없음 → 미기록