o2o-negosium-original/lps/tests/test_search_handler.py
민헌 152aed9908 fix(lps): 한 소스가 막혀도 잡을 정상 종료 — '탐색 중'에서 멈추던 원인
운영 로그(2026-08-06)에서 드러난 문제. 쿠팡이 막힌 동안 **네이버가 매번 40건을 가져왔는데도**
잡이 전부 DEAD 로 갔다:

  fail 3c0bcaff → DEAD ({'coupang': {'error': 환경 차단}, 'naver': {'count': 40}})

그리고 DEAD 는 price_history 에 행을 남기지 않는다. 이를 폴링하는 negodata 최저가 모달은
받을 결과가 영영 없어 '탐색 중'에서 멈춘다(사용자 화면 확인). 즉 프론트 버그가 아니라
**백엔드가 답을 안 준 것**이다 — 두 증상이 같은 뿌리였다.

원인: 핸들러가 '하나라도 실패했나'(bool)만 보고 무조건 raise 했다. 한 소스가 막혔다고
다른 소스가 멀쩡히 가져온 결과까지 버린 셈이다.

바꾼 것:
- _search_round 가 **성공한 소스 목록**을 돌려준다(bool → list).
- 살아있는 소스가 하나라도 있으면 그 결과로 진행한다. **전부 죽었을 때만** 재시도한다
  ('없음'이라 단정할 수 없는 건 그때뿐이다).
- 결과에 sources_ok/sources_failed/partial 을 실어 커버리지를 드러낸다(부분 결과 로그도 남김).
- 부분 실패 상태의 not_found 는 **네거티브 캐시에 넣지 않는다** — 막힌 소스엔 있었을 수 있는데
  '없음'으로 굳히면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트).
- 전부 실패 + 재시도 소진이면 DEAD 대신 outcome='error' 로 **이력을 남기고** 종료한다.
  화면이 무한 대기 대신 결과를 받는 게 중요하다.

negodata 는 변경 없이 받는다(outcome='error' → success_yn=false, fail_reason='error').

테스트 5건 추가·1건 갱신(옛 계약 '한 소스 실패 시 raise' 를 새 계약으로 교체), 전체 274 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:34:36 +09:00

293 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""검색 핸들러 테스트 — 병합·실패격리·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