"""다중 소스 검색 핸들러 테스트 — 병합·소스별 실패 격리·전체 실패 시 잡 실패 (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, products=None, fail=False): self.source = source self._products = products or [] self._fail = fail async def search(self, query, limit=40): if self._fail: raise AdapterError("boom", source=self.source, blocked=True) return self._products def _np(source, price): return NormalizedProduct(source=source, name=f"{source}-{price}", price=price) def _job(): return {"job_type": JobType.SEARCH.value, "attempts": 1, "payload": {"product_name": "x"}} async def test_merges_and_ranks_across_sources(): adapters = { "coupang": FakeAdapter("coupang", [_np("coupang", 3000), _np("coupang", 1000)]), "naver": FakeAdapter("naver", [_np("naver", 2000), _np("naver", 500)]), } r = await build_search_handler(adapters, top_n=3)(_job()) assert r["lowest"]["price"] == 500 and r["lowest"]["source"] == "naver" assert [p["price"] for p in r["top"]] == [500, 1000, 2000] assert r["sources"]["coupang"]["count"] == 2 and r["sources"]["naver"]["count"] == 2 async def test_isolates_single_source_failure(): adapters = { "coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", [_np("naver", 900)]), } r = await build_search_handler(adapters)(_job()) assert "error" in r["sources"]["coupang"] # 실패 격리 assert r["sources"]["naver"]["count"] == 1 assert r["lowest"]["price"] == 900 # 성공 소스로 결과 산출 async def test_all_sources_fail_raises(): adapters = { "coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True), } with pytest.raises(RuntimeError): await build_search_handler(adapters)(_job()) class FakeJudge: """가격 조건으로 매칭을 흉내내는 판정기(실제 OpenAI 호출 없음).""" 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)] async def test_ai_judge_filters_non_matches(): adapters = {"naver": FakeAdapter("naver", [_np("naver", 1000), _np("naver", 2000), _np("naver", 3000)])} judge = FakeJudge(lambda c: c.price == 2000) # 2000 만 '같은 상품' r = await build_search_handler(adapters, judge=judge)(_job()) assert [p["price"] for p in r["top"]] == [2000] # 비매칭 제거됨 ai_stage = next(s for s in r["stages"] if s["stage"] == "ai_match") assert ai_stage["in"] == 3 and ai_stage["out"] == 1