'못 찾음'을 유한하게 종료. 두 재시도 축을 분리(기술=큐 attempts/백오프, 검색어=refine 라운드). not_found 는 정상 종료(DONE)지 dead-letter 아님. - ai/keyword: LLM 검색어 생성(정밀/광역) — 원본 0매칭 시에만 지연 호출(비용 절약) - handler: 한정 재정제 루프(원본→정밀→광역, max_rounds=3) + 명시적 outcome(found/not_found) · 0매칭+소스정상 → 다음 라운드, 0매칭+기술실패 → raise(큐 재시도) · 라운드 소진 → not_found + 네거티브 캐시 기록 - negative_cache: search_negative 테이블 + TTL(24h) upsert — 같은 상품 재요청 재검색 차단 - worker_main: OPENAI 있으면 judge+keyword_gen ON, neg_cache 상시 - tests: 재정제/not_found/캐시히트/기술실패/캐시CRUD 11건 → 전체 39/39 - 라이브: 없는상품 3라운드→not_found(30s), 재요청 캐시히트(0.00s) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
5.1 KiB
Python
123 lines
5.1 KiB
Python
"""검색 핸들러 테스트 — 병합·실패격리·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매칭 + 차단 → 기술 재시도
|