o2o-negosium-original/lps/tests/test_search_handler.py
민헌 9557c1b1af feat(lps): 네이버 어댑터 + 다중 소스 병합 검색
네이버 쇼핑 오픈API 어댑터(크롤링 불필요) + 핸들러를 다중 소스로 확장.
쿠팡(브라우저)+네이버(API)를 동시 검색·병합해 교차 최저가를 뽑는다.

- search/naver/adapter: httpx + 오픈API + 키 로테이션(429/403 순환), .env 키 로드
- search/naver/transform: 순수 변환(태그/엔티티 정리, lprice). 가격비교(catalog) lprice 는
  '여러 판매자 중 최저가'라 최저가 솔루션엔 핵심 → 유지
- handler: asyncio.gather 동시 검색 + 소스별 실패 격리(일부 죽어도 결과) + 전체 실패 시 잡 실패
- worker_main: adapters={coupang, naver}
- tests: 네이버 변환 + 병합/실패격리/전체실패 4건 → 전체 31/31
- 라이브: 쿠팡30+네이버30 병합 top-N 최저가(소스 라벨 포함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:38:15 +09:00

59 lines
2.1 KiB
Python

"""다중 소스 검색 핸들러 테스트 — 병합·소스별 실패 격리·전체 실패 시 잡 실패 (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())