"""검색 핸들러 테스트 — 병합·실패격리·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): return NormalizedProduct(source=source, name=f"{source}-{price}", price=price) 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매칭 + 차단 → 기술 재시도