"""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="service unavailable") 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)