네이버가 2026-07-31 검색 오픈API 중 쇼핑·책·전문자료를 종료(유예·대체 없음)해 shop.json 이 404 SE05 를 반환한다. 후속 플랫폼인 NCP NAVER API HUB 를 붙인다. - NaverApiHubConfig: 게이트웨이 base_url + NCP Client ID/Secret(둘 다 차야 enabled) - services/naver_hub/client.py: X-NCP-APIGW-API-KEY-ID/KEY 인증, 오류 바디 3형식(게이트웨이/Search/인사이트)을 NaverApiHubError 로 정규화(auth_failed·retryable) - services/naver_hub/shopping_insight.py: POST /shopping/v1/categories. 문서 제약(기간 2017-08-01~, 분야 최대 3개, timeUnit·device·gender·ages)을 호출 전에 검증하고 카멜케이스 응답을 타입으로 변환 - tests: MockTransport 로 경로·헤더·오류형식 계약 검증 17건 + LPS_LIVE 스모크 주의: 허브에도 쇼핑 '검색'(상품명·가격·판매처)은 없다. 인사이트의 ratio 는 구간 내 최대값 100 기준 상대지표라 최저가 파이프라인 소스로는 쓸 수 없다. 기존 services/search/naver 어댑터는 손대지 않았다(사문화 상태 유지).
164 lines
8.0 KiB
Python
164 lines
8.0 KiB
Python
"""NAVER API HUB 쇼핑 인사이트 테스트 (네트워크 불필요 — MockTransport/순수함수).
|
|
|
|
+ 라이브 스모크: 실제 NCP 게이트웨이에 붙어 키·경로가 살아있는지 확인.
|
|
키가 필요하고 쿼터를 쓰므로 기본 skip — LPS_LIVE=1 로 명시 실행.
|
|
"""
|
|
|
|
import os
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from config.config_models import NaverApiHubConfig
|
|
from services.naver_hub.client import NaverApiHubClient, NaverApiHubError
|
|
from services.naver_hub.shopping_insight import (
|
|
ShoppingCategory, ShoppingInsightClient, build_categories_body, parse_categories_response,
|
|
)
|
|
|
|
_CFG = NaverApiHubConfig(client_id="ID", client_secret="SECRET")
|
|
_CAT = [ShoppingCategory(name="패션의류", param=["50000000"])]
|
|
|
|
_SAMPLE = { # 공식 문서 응답 예시 축약
|
|
"startDate": "2023-11-01", "endDate": "2023-11-07", "timeUnit": "date",
|
|
"results": [{"title": "패션의류", "category": ["50000000"],
|
|
"data": [{"period": "2023-11-01", "ratio": 76.30839},
|
|
{"period": "2023-11-02", "ratio": 70.00509}]}],
|
|
}
|
|
|
|
|
|
def _client(handler) -> ShoppingInsightClient:
|
|
return ShoppingInsightClient(NaverApiHubClient(_CFG, transport=httpx.MockTransport(handler)))
|
|
|
|
|
|
# ── 요청 바디 검증 (순수) ────────────────────────────────────────────────
|
|
def test_body_has_required_fields_and_omits_empty_optionals():
|
|
body = build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=_CAT)
|
|
assert body["startDate"] == "2026-07-01" and body["endDate"] == "2026-07-07"
|
|
assert body["timeUnit"] == "date"
|
|
assert body["category"] == [{"name": "패션의류", "param": ["50000000"]}]
|
|
# 선택 파라미터는 미지정 시 아예 실리지 않는다(빈 값 전송 = 검증 오류)
|
|
assert "device" not in body and "gender" not in body and "ages" not in body
|
|
|
|
|
|
def test_body_carries_optional_filters():
|
|
body = build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=_CAT,
|
|
time_unit="week", device="pc", gender="f", ages=["20", "30"])
|
|
assert body["timeUnit"] == "week" and body["device"] == "pc"
|
|
assert body["gender"] == "f" and body["ages"] == ["20", "30"]
|
|
|
|
|
|
@pytest.mark.parametrize("kwargs, msg", [
|
|
(dict(start_date="2017-07-31", end_date="2017-08-05"), "2017-08-01"), # 조회 가능 시작일 이전
|
|
(dict(start_date="2026-07-07", end_date="2026-07-01"), "늦습니다"), # 역전
|
|
(dict(start_date="2026/07/01", end_date="2026-07-07"), "yyyy-mm-dd"), # 형식
|
|
(dict(start_date="2026-07-01", end_date="2026-07-07", time_unit="day"), "timeUnit"),
|
|
(dict(start_date="2026-07-01", end_date="2026-07-07", device="mobile"), "device"),
|
|
(dict(start_date="2026-07-01", end_date="2026-07-07", gender="x"), "gender"),
|
|
(dict(start_date="2026-07-01", end_date="2026-07-07", ages=["15"]), "ages"),
|
|
])
|
|
def test_body_rejects_invalid_input(kwargs, msg):
|
|
with pytest.raises(ValueError, match=msg):
|
|
build_categories_body(categories=_CAT, **kwargs)
|
|
|
|
|
|
def test_body_rejects_bad_category_count():
|
|
with pytest.raises(ValueError, match="최소 1개"):
|
|
build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=[])
|
|
four = [ShoppingCategory(name=f"c{i}", param=[str(i)]) for i in range(4)]
|
|
with pytest.raises(ValueError, match="최대 3개"):
|
|
build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=four)
|
|
|
|
|
|
# ── 응답 파싱 (순수) ─────────────────────────────────────────────────────
|
|
def test_parse_response_maps_camel_case():
|
|
out = parse_categories_response(_SAMPLE)
|
|
assert out.start_date == "2023-11-01" and out.time_unit == "date"
|
|
assert len(out.results) == 1
|
|
s = out.results[0]
|
|
assert s.title == "패션의류" and s.category == ["50000000"]
|
|
assert s.data[0].period == "2023-11-01" and s.data[0].ratio == pytest.approx(76.30839)
|
|
|
|
|
|
def test_parse_response_tolerates_missing_sections():
|
|
out = parse_categories_response({})
|
|
assert out.results == [] and out.start_date == ""
|
|
|
|
|
|
# ── 호출 계약 (MockTransport) ────────────────────────────────────────────
|
|
@pytest.mark.asyncio
|
|
async def test_call_uses_hub_path_and_ncp_headers():
|
|
seen = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["headers"] = request.headers
|
|
seen["method"] = request.method
|
|
return httpx.Response(200, json=_SAMPLE)
|
|
|
|
out = await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT)
|
|
|
|
assert seen["method"] == "POST"
|
|
assert seen["url"] == "https://naverapihub.apigw.ntruss.com/shopping/v1/categories"
|
|
# 옛 오픈API 헤더가 아니라 NCP 게이트웨이 헤더여야 한다
|
|
assert seen["headers"]["X-NCP-APIGW-API-KEY-ID"] == "ID"
|
|
assert seen["headers"]["X-NCP-APIGW-API-KEY"] == "SECRET"
|
|
assert "X-Naver-Client-Id" not in seen["headers"]
|
|
assert out.results[0].title == "패션의류"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_error_shape_is_normalized():
|
|
def handler(request):
|
|
return httpx.Response(401, json={"error": {"errorCode": "200", "message": "Authentication Failed",
|
|
"details": "Authentication information are missing."}})
|
|
|
|
with pytest.raises(NaverApiHubError) as e:
|
|
await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT)
|
|
assert e.value.status == 401 and e.value.code == "200"
|
|
assert e.value.auth_failed and not e.value.retryable
|
|
assert "Authentication Failed" in str(e.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insight_error_shape_is_normalized():
|
|
def handler(request):
|
|
return httpx.Response(400, json={"errMsg": "잘못된 요청입니다", "errId": "E001"})
|
|
|
|
with pytest.raises(NaverApiHubError) as e:
|
|
await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT)
|
|
assert e.value.status == 400 and e.value.code == "E001" and "잘못된 요청" in str(e.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_5xx_is_retryable_and_non_json_body_survives():
|
|
def handler(request):
|
|
return httpx.Response(503, text="<html>service unavailable</html>")
|
|
|
|
with pytest.raises(NaverApiHubError) as e:
|
|
await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT)
|
|
assert e.value.retryable and e.value.status == 503
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_credentials_fails_before_network():
|
|
def handler(request): # 호출되면 안 됨
|
|
raise AssertionError("자격증명 없이 네트워크 호출이 발생했다")
|
|
|
|
client = ShoppingInsightClient(NaverApiHubClient(NaverApiHubConfig(), transport=httpx.MockTransport(handler)))
|
|
assert not client.enabled
|
|
with pytest.raises(NaverApiHubError, match="NaverApiHubConfig"):
|
|
await client.categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT)
|
|
|
|
|
|
# ── 라이브 스모크 (실제 키 필요) ─────────────────────────────────────────
|
|
@pytest.mark.skipif(not os.environ.get("LPS_LIVE"), reason="라이브 스모크 — LPS_LIVE=1 + 실제 NCP 키로 실행")
|
|
async def test_live_smoke_shopping_insight():
|
|
client = ShoppingInsightClient()
|
|
assert client.enabled, "config.local.toml [NaverApiHubConfig] 의 client_id/client_secret 이 비었습니다"
|
|
out = await client.categories(
|
|
start_date="2026-07-01", end_date="2026-07-07", time_unit="date",
|
|
categories=[ShoppingCategory(name="패션의류", param=["50000000"])],
|
|
)
|
|
assert out.results and out.results[0].data, "0건 — 키 권한·분야 코드 확인"
|
|
assert all(0 <= p.ratio <= 100 for p in out.results[0].data)
|