네이버 쇼핑 오픈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>
59 lines
2.1 KiB
Python
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())
|