G마켓 챌린지가 안 풀린 채 25~70초를 먹던 문제에 상한을 씌운다. 폴백은 '있으면 좋은' 보강이라 데드라인으로 잘라도 안전(그 몰만 스킵). - handler: 각 폴백 크롤에 fallback_deadline_sec(기본 15s) wait_for. 초과 시 스킵. _timed_search 예외를 BaseException 로(CancelledError 계측 포함). - 폴백 어댑터 max_block_retries=0(챌린지 대기 2배 방지), ESM ready_timeout 22→12s, st11 20→15s. - 테스트: 느린 폴백 데드라인 스킵 1종. 실측(동시성2·4상품): 상품당 p50 84.6s→34.2s(~2.5x), 벽시계 181s→65s(~2.8x), 처리량 1.3→3.7 상품/분, 1000건 $7.12→$5.06(~29%↓, 실패 챌린지 대역폭 절감). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
12 KiB
Python
238 lines
12 KiB
Python
"""검색 핸들러 테스트 — 병합·실패격리·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
|
||
|
||
|
||
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)
|
||
|
||
|
||
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_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은 스킵됐으므로 최저가 아님
|
||
|
||
|
||
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
|