"""검색 핸들러 테스트 — 병합·실패격리·AI판정·재정제 루프·not_found·네거티브 캐시 (fake 의존성).""" import asyncio import pytest from common.enums import JobType from services.search.contract import NormalizedProduct, AdapterError from worker.handlers import build_search_handler, _abandoned_fallbacks class FakeAdapter: def __init__(self, source, by_query=None, products=None, fail=False, uses_proxy=False, last_bytes=0, delay=0.0): self.source = source self._by_query = by_query # {query: [products]} self._products = products or [] self._fail = fail self._delay = delay # search 지연(초) — 데드라인 테스트용 self.uses_proxy = uses_proxy # DECODO 경유 여부(비용 귀속) self.last_bytes = last_bytes self.calls = [] async def search(self, query, limit=40): self.calls.append(query) if self._delay: await asyncio.sleep(self._delay) 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 _Usage: def __init__(self, prompt, completion): self.prompt_tokens, self.completion_tokens = prompt, completion class FakeJudge: def __init__(self, predicate, usage=None): self._pred = predicate self.last_usage = usage # 계측용(핸들러가 judge 후 읽음) 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 self.last_usage = None 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) class FakeHistory: """price_history 기록 관찰용 — '행이 남는가'가 프론트의 대기/표시를 가른다.""" def __init__(self): self.events = [] async def record(self, event): self.events.append(event) 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 == [] # 재검색 안 함 # ── 부분 결과: 한 소스가 막혀도 살아있는 소스로 답한다 (2026-08-06) ────── # 예전엔 한 소스라도 실패하면 무조건 raise → 재시도 → DEAD 였다. 그런데 DEAD 는 price_history 에 # 행을 남기지 않아, 이를 폴링하는 negodata 최저가 모달이 결과를 영영 못 받고 '탐색 중'에서 멈췄다 # (실측: 쿠팡이 막힌 동안 네이버가 40건씩 가져왔는데도 잡이 전부 DEAD). async def test_all_sources_failing_still_retries(): """전부 죽었으면 '없음'이라 단정할 수 없다 — 이때는 기존대로 재시도한다.""" adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True)} with pytest.raises(RuntimeError): await build_search_handler(adapters)(_job()) async def test_partial_failure_completes_with_the_surviving_source(): """쿠팡이 막혀도 네이버가 찾았으면 정상 종료해야 한다(사용자가 결과를 받는 게 우선).""" adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", products=[_np("naver", 9000)])} r = await build_search_handler(adapters, judge=FakeJudge(lambda c: True))(_job()) assert r["outcome"] == "found" and r["lowest"]["price"] == 9000 assert r["partial"] is True and r["sources_failed"] == ["coupang"] and r["sources_ok"] == ["naver"] async def test_partial_zero_match_ends_as_not_found_not_dead(): """살아있는 소스가 0매칭이면 not_found 로 **정상 종료**한다 — 잡을 죽이면 화면이 멈춘다.""" adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} r = await build_search_handler(adapters)(_job()) assert r["outcome"] == "not_found" and r["partial"] is True async def test_partial_not_found_does_not_poison_the_negative_cache(): """막힌 소스엔 있었을 수 있다 — '없음'으로 굳히면 TTL 동안 재검색이 막힌다.""" adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} neg = FakeNegCache() r = await build_search_handler(adapters, neg_cache=neg)(_job()) assert r["outcome"] == "not_found" assert neg.puts == [], "부분 실패 상태의 not_found 는 캐시하지 않는다" async def test_all_sources_failed_on_last_attempt_records_error_instead_of_dying(): """재시도가 소진되면 DEAD 대신 error 로 기록한다 — 이력이 남아야 화면이 '실패'를 보여준다.""" adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True)} hist = FakeHistory() job = _job() job.update(attempts=3, max_attempts=3) # 마지막 시도 r = await build_search_handler(adapters, history=hist)(job) assert r["outcome"] == "error" and r["sources_ok"] == [] assert [e["outcome"] for e in hist.events] == ["error"], "이력이 없으면 프론트가 계속 대기한다" # ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ────────────────────────── 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_deadline_skips_slow_mall(): # 느린 폴백(데드라인 초과)은 스킵되고, 빠른 폴백은 병합된다 — 전체 지연에 상한. adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 9000, mall="네이버")])} slow = FakeAdapter("gmarket", products=[_np("gmarket", 1000, mall="G마켓")], delay=1.0) # 데드라인 초과 fast = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")], delay=0.0) r = await build_search_handler( adapters, judge=FakeJudge(lambda c: True), fallback_adapters={"gmarket": slow, "st11": fast}, fallback_deadline_sec=0.2, )(_job()) malls = {m["mall_name"] for m in r["by_mall"]} assert "11번가" in malls # 빠른 폴백 병합됨 assert "G마켓" not in malls # 느린 폴백은 데드라인 초과로 스킵 assert r["lowest"]["price"] == 3000 # G마켓 1000은 스킵됐으므로 최저가 아님 # 버려진 크롤은 cancel 없이 백그라운드 종료된다 — 루프 닫기 전에 배수(pending 태스크 파괴 경고 방지) assert _abandoned_fallbacks # 느린 폴백이 버려짐 await asyncio.gather(*_abandoned_fallbacks, return_exceptions=True) assert not _abandoned_fallbacks # 종료 콜백이 집합에서 제거함 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 # 폴백 실패해도 정상 종료 # ── 검색 원가 계측(metrics) ──────────────────────────────────────── async def test_metrics_recorded_in_result(): adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 1000), _np("naver", 2000)])} adapters["naver"].last_bytes = 1234 judge = FakeJudge(lambda c: True, usage=_Usage(500, 40)) r = await build_search_handler(adapters, judge=judge, ai_model="gpt-4o-mini")(_job()) m = r["metrics"] assert m["ai"]["calls"] == 1 and m["ai"]["prompt_tokens"] == 500 and m["ai"]["completion_tokens"] == 40 assert m["ai"]["est_cost_usd"] == round(500/1e6*0.15 + 40/1e6*0.60, 6) # gpt-4o-mini 단가 assert m["crawl"]["fetches"] == 1 and m["crawl"]["html_bytes"] == 1234 assert "naver" in m["source_ms"] and "duration_ms" in m async def test_metrics_cost_split_ai_and_proxy(): # 네이버(직접, 프록시X) + 프록시 경유 크롤 폴백 → proxy_usd 는 프록시 바이트만, ai_usd 는 토큰만 adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="네이버")], last_bytes=2000)} st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")], uses_proxy=True, last_bytes=1024**3) # 1GB judge = FakeJudge(lambda c: True, usage=_Usage(1_000_000, 0)) # 1M prompt 토큰 handler = build_search_handler( adapters, judge=judge, ai_model="gpt-4o-mini", fallback_adapters={"st11": st11}, proxy_cost_per_gb=3.0, ) m = (await handler(_job()))["metrics"] # 네이버 2000B 는 프록시 경유 아님 → proxy_bytes = 1GB(st11)만 assert m["crawl"]["proxy_bytes"] == 1024**3 assert m["cost"]["proxy_usd"] == 3.0 # 1GB × $3 assert m["cost"]["ai_usd"] == round(m["ai"]["prompt_tokens"]/1e6*0.15, 6) # gpt-4o-mini input 단가 assert m["cost"]["total_usd"] == round(m["cost"]["ai_usd"] + 3.0, 6) async def test_metrics_counts_fallback_crawl(): adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="네이버")])} st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")]) st11.last_bytes = 9999 r = await build_search_handler( adapters, judge=FakeJudge(lambda c: True), fallback_adapters={"st11": st11}, )(_job()) m = r["metrics"] assert m["crawl"]["fetches"] == 2 # naver + st11 크롤 assert "st11" in m["crawl"]["malls_crawled"] # 폴백 크롤 몰 기록 assert m["crawl"]["html_bytes"] == 9999 # st11 바이트 포함 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