o2o-negosium-original/lps/tests/test_search_handler.py
민헌 f9714c3f06 feat(lps): 오픈마켓 폴백 오케스트레이션 + 몰 dedup
사용자 규칙 '네이버로 그 몰 값 확보 성공→그 값, 실패(몰 없음)→실사이트 크롤' 구현.

- handler: found 라운드 뒤 _enrich_with_fallback — 네이버 매칭이 커버 못 한 타깃 몰만
  (canonical_mall 로 판정) 크롤 어댑터로 검색→같은상품 판정→병합. 크롤 실패는 격리(무시).
  fallback_crawl STAGE 기록.
- summarize_by_mall: 키를 (source,mall)→몰명(canonical)으로 — 네이버노출 vs 직접크롤 같은 몰
  중복을 최저가 1건으로 병합(dedup).
- card_parser: MALL_BY_SOURCE + canonical_mall() 추가.
- worker_main: gmarket/auction/st11 폴백 어댑터 등록(lazy 기동, 리소스차단 OFF).
- 테스트: 커버몰 크롤생략·미커버 크롤·실패격리·몰 dedup 4종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:39 +09:00

164 lines
7.5 KiB
Python

"""검색 핸들러 테스트 — 병합·실패격리·AI판정·재정제 루프·not_found·네거티브 캐시 (fake 의존성)."""
import pytest
from common.enums import JobType
from services.search.contract import NormalizedProduct, AdapterError
from worker.handlers import build_search_handler
class FakeAdapter:
def __init__(self, source, by_query=None, products=None, fail=False):
self.source = source
self._by_query = by_query # {query: [products]}
self._products = products or []
self._fail = fail
self.calls = []
async def search(self, query, limit=40):
self.calls.append(query)
if self._fail:
raise AdapterError("boom", source=self.source, blocked=True)
if self._by_query is not None:
return self._by_query.get(query, [])
return self._products
class FakeJudge:
def __init__(self, predicate):
self._pred = predicate
async def judge(self, target, candidates):
from services.ai.similarity import Judgment
return [Judgment(index=i + 1, is_match=self._pred(c), score=100 if self._pred(c) else 0)
for i, c in enumerate(candidates)]
class FakeKeywordGen:
def __init__(self, precise="", broad=""):
self._p, self._b = precise, broad
async def generate(self, target):
from services.ai.keyword import Keywords
return Keywords(precise=self._p, broad=self._b)
class FakeNegCache:
def __init__(self, negative=False):
self._neg = negative
self.puts = []
async def is_negative(self, key):
return self._neg
async def put(self, key, ttl_sec=86400, reason="x"):
self.puts.append(key)
def _np(source, price, mall=None):
return NormalizedProduct(source=source, name=f"{source}-{price}", price=price, mall_name=mall)
def _job(**payload):
payload.setdefault("product_name", "x")
return {"job_type": JobType.SEARCH.value, "attempts": 1, "payload": payload}
# ── 병합 / 실패격리 / AI 판정 (round 0) ─────────────────────────────
async def test_merges_and_ranks_across_sources():
adapters = {
"coupang": FakeAdapter("coupang", products=[_np("coupang", 3000), _np("coupang", 1000)]),
"naver": FakeAdapter("naver", products=[_np("naver", 2000), _np("naver", 500)]),
}
r = await build_search_handler(adapters, top_n=3)(_job())
assert r["outcome"] == "found" and r["lowest"]["price"] == 500
assert [p["price"] for p in r["top"]] == [500, 1000, 2000]
async def test_isolates_single_source_failure_but_still_found():
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", products=[_np("naver", 900)])}
r = await build_search_handler(adapters)(_job())
assert r["outcome"] == "found" and r["lowest"]["price"] == 900
assert "error" in r["sources"]["coupang"]
async def test_ai_judge_filters_non_matches():
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 1000), _np("naver", 2000), _np("naver", 3000)])}
r = await build_search_handler(adapters, judge=FakeJudge(lambda c: c.price == 2000))(_job())
assert [p["price"] for p in r["top"]] == [2000]
assert next(s for s in r["stages"] if s["stage"] == "ai_match")["out"] == 1
# ── 재정제 루프 ────────────────────────────────────────────────────
async def test_refines_to_precise_query_when_original_empty():
adapters = {"naver": FakeAdapter("naver", by_query={"스탠리 퀜처 887ml": [_np("naver", 40000)]})} # 원본은 0건
kw = FakeKeywordGen(precise="스탠리 퀜처 887ml", broad="스탠리 텀블러")
r = await build_search_handler(adapters, keyword_gen=kw)(_job(product_name="스탠리 텀블러"))
assert r["outcome"] == "found" and r["round"] == "precise" and r["rounds_tried"] == 2
assert r["lowest"]["price"] == 40000
async def test_not_found_after_all_rounds_and_caches():
adapters = {"naver": FakeAdapter("naver", by_query={})} # 어떤 쿼리든 0건
kw = FakeKeywordGen(precise="P", broad="B")
neg = FakeNegCache()
r = await build_search_handler(adapters, keyword_gen=kw, neg_cache=neg)(_job(product_code="PC1", product_name="없는상품"))
assert r["outcome"] == "not_found" and r["rounds_tried"] == 3
assert r["lowest"] is None and r["top"] == []
assert neg.puts == ["PC1"] # 네거티브 캐시에 기록
async def test_negative_cache_short_circuits():
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 100)])}
neg = FakeNegCache(negative=True)
r = await build_search_handler(adapters, neg_cache=neg)(_job(product_code="PC9"))
assert r["outcome"] == "not_found" and r["cached"] is True
assert adapters["naver"].calls == [] # 재검색 안 함
async def test_technical_failure_with_zero_match_raises():
adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})}
with pytest.raises(RuntimeError):
await build_search_handler(adapters)(_job()) # 0매칭 + 차단 → 기술 재시도
# ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ──────────────────────────
async def test_fallback_crawls_only_uncovered_malls():
# 네이버 매칭에 G마켓은 있고(→크롤 생략), 11번가는 없음(→크롤). 옥션도 없음(→크롤).
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="G마켓")])}
gmarket = FakeAdapter("gmarket", products=[_np("gmarket", 4000, mall="G마켓")])
auction = FakeAdapter("auction", products=[_np("auction", 4500, mall="옥션")])
st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")])
handler = build_search_handler(
adapters, judge=FakeJudge(lambda c: True),
fallback_adapters={"gmarket": gmarket, "auction": auction, "st11": st11},
)
r = await handler(_job())
assert gmarket.calls == [] # 네이버가 G마켓 커버 → 크롤 생략
assert auction.calls and st11.calls # 미커버 → 크롤함
assert r["lowest"]["price"] == 3000 # 11번가 크롤가가 전체 최저
malls = {m["mall_name"] for m in r["by_mall"]}
assert malls == {"G마켓", "옥션", "11번가"} # 네이버 G마켓 + 크롤 옥션·11번가
async def test_fallback_failure_is_isolated():
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 9000, mall="네이버")])}
st11 = FakeAdapter("st11", fail=True) # 크롤 실패
r = await build_search_handler(
adapters, judge=FakeJudge(lambda c: True),
fallback_adapters={"st11": st11},
)(_job())
assert r["outcome"] == "found" and r["lowest"]["price"] == 9000 # 폴백 실패해도 정상 종료
async def test_fallback_dedup_same_mall_keeps_lowest():
# 네이버 매칭에 G마켓 없음 → 크롤. 크롤 G마켓이 네이버 '네이버몰'보다 싸면 최저가 갱신.
adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 8000, mall="네이버")])}
gmarket = FakeAdapter("gmarket", products=[_np("gmarket", 6000, mall="G마켓"), _np("gmarket", 7000, mall="G마켓")])
r = await build_search_handler(
adapters, judge=FakeJudge(lambda c: True),
fallback_adapters={"gmarket": gmarket},
)(_job())
gm = [m for m in r["by_mall"] if m["mall_name"] == "G마켓"]
assert len(gm) == 1 and gm[0]["price"] == 6000 # 몰별 1건(최저)로 dedup