feat(lps): NCP NAVER API HUB 쇼핑 인사이트 클라이언트 추가

네이버가 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 어댑터는 손대지 않았다(사문화 상태 유지).
This commit is contained in:
민헌 2026-08-04 11:23:26 +09:00
parent 3c3f60c7d4
commit 005ebc3d76
7 changed files with 503 additions and 2 deletions

View File

@ -78,6 +78,8 @@ block_sessions_6h = 1 # 최근 6h '예산 회전에도 차단된' IP
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ── # ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ──
# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함. # 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함.
# ⚠️ 2026-07-31 네이버가 쇼핑·책·전문자료 검색을 종료(유예·대체 없음) → shop.json 은 404 SE05.
# 이 섹션은 사문화 상태로 남겨둔다(어댑터 제거 결정 전까지).
[NaverConfig] [NaverConfig]
[[NaverConfig.keys]] [[NaverConfig.keys]]
id = "<NAVER_CLIENT_ID>" id = "<NAVER_CLIENT_ID>"
@ -87,6 +89,16 @@ secret = "<NAVER_CLIENT_SECRET>"
# id = "..." # id = "..."
# secret = "..." # secret = "..."
# NCP NAVER API HUB (https://www.ncloud.com → Application Services > NAVER API HUB).
# 검색(블로그·뉴스·지식iN·이미지·지역·웹문서·백과·카페·오타변환·성인판별)·검색어 트렌드·쇼핑 인사이트.
# 쇼핑 '검색'(상품/가격)은 허브에도 없다 — 최저가 소스로는 쓸 수 없음.
# 키는 NCP 콘솔 > NAVER API HUB > Application > API 관리 > [인증 정보] 에서 발급.
[NaverApiHubConfig]
base_url = "https://naverapihub.apigw.ntruss.com"
client_id = "<NCP_CLIENT_ID>"
client_secret = "<NCP_CLIENT_SECRET>"
timeout_sec = 10.0
# AI 유사도 판정/검색어 생성 (OpenAI) # AI 유사도 판정/검색어 생성 (OpenAI)
[OpenAIConfig] [OpenAIConfig]
api_key = "<OPENAI_API_KEY>" api_key = "<OPENAI_API_KEY>"

View File

@ -57,11 +57,34 @@ class NaverKey(BaseModel):
class NaverConfig(ConfigModel): class NaverConfig(ConfigModel):
"""네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함.""" """네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함.
⚠️ 2026-07-31 네이버가 검색 오픈API 중 쇼핑·책·전문자료를 종료했다(유예·대체 없음).
shop.json 은 정상 키로도 404 SE05 를 반환한다 → services/search/naver 는 사실상 사문화.
후속인 NCP NAVER API HUB 에도 쇼핑 '검색'은 없다(→ NaverApiHubConfig 는 인사이트/트렌드용).
"""
keys: list[NaverKey] = [] keys: list[NaverKey] = []
class NaverApiHubConfig(ConfigModel):
"""NCP NAVER API HUB(검색·검색어 트렌드·쇼핑 인사이트). 개발자센터 오픈API 의 후속.
옛 오픈API 와 인증 방식이 다르다 — X-Naver-Client-Id/Secret 이 아니라
X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY 헤더를 쓴다(발급처도 NCP 콘솔).
client_id/secret 이 다 차야 활성(enabled).
"""
base_url: str = "https://naverapihub.apigw.ntruss.com"
client_id: str = ""
client_secret: str = ""
timeout_sec: float = 10.0
@property
def enabled(self) -> bool:
return bool(self.base_url and self.client_id and self.client_secret)
class OpenAIConfig(ConfigModel): class OpenAIConfig(ConfigModel):
"""AI 유사도 판정/검색어 생성용 OpenAI.""" """AI 유사도 판정/검색어 생성용 OpenAI."""

View File

@ -2,7 +2,7 @@ import os
from config.config_loader import Configs from config.config_loader import Configs
from config.config_models import ( from config.config_models import (
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig, WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverApiHubConfig, OpenAIConfig, DecodoConfig,
WorkerConfig, AlertConfig, WorkerConfig, AlertConfig,
) )
@ -34,6 +34,7 @@ log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig)
# 섹션이 없으면 기본값(빈/비활성)으로 동작. # 섹션이 없으면 기본값(빈/비활성)으로 동작.
naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig() naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig()
naver_api_hub_config: NaverApiHubConfig = configs.get(NaverApiHubConfig) or NaverApiHubConfig()
openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig() openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig()
decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig() decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig()
worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig() worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig()

View File

@ -0,0 +1,31 @@
"""NCP NAVER API HUB 클라이언트.
네이버 개발자센터 오픈API 의 후속 플랫폼(2026-06-25 출시). 게이트웨이가 앞단에 있어
인증 헤더·경로·오류 형식이 옛 오픈API 와 다르다 — 자세한 건 client.py 참고.
⚠️ 허브에도 쇼핑 '검색'(상품명·가격·판매처)은 없다. 여기 있는 쇼핑 인사이트는
클릭 추이의 **상대 지표(0~100)** 라 최저가 파이프라인의 소스로는 쓸 수 없다.
"""
from services.naver_hub.client import NaverApiHubClient, NaverApiHubError
from services.naver_hub.shopping_insight import (
ShoppingCategory,
ShoppingInsightClient,
ShoppingInsightResult,
InsightPoint,
InsightSeries,
build_categories_body,
parse_categories_response,
)
__all__ = [
"NaverApiHubClient",
"NaverApiHubError",
"ShoppingInsightClient",
"ShoppingCategory",
"ShoppingInsightResult",
"InsightSeries",
"InsightPoint",
"build_categories_body",
"parse_categories_response",
]

View File

@ -0,0 +1,111 @@
"""NAVER API HUB 공통 호출 계층 — 인증 헤더·요청·오류 해석을 한곳에 모은다.
옛 오픈API(openapi.naver.com) 와 달라진 점:
엔드포인트 https://naverapihub.apigw.ntruss.com
인증 헤더 X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY (NCP 콘솔 발급 Client ID/Secret)
경로 규칙 /search/v1/{type} · /shopping/v1/{...} (옛 /v1/search/{type}.json 과 순서가 반대)
오류 바디가 계층마다 다르다(공식 문서 'NAVER API HUB 개요' 기준). 호출부가 세 형식을 다 알
필요는 없으므로 _parse_error 가 하나의 NaverApiHubError 로 정규화한다:
1) 게이트웨이(인증 실패·라우팅 실패) {"error": {"errorCode", "message", "details"}}
2) Search API 파라미터 검증 {"errorCode", "errorMessage"}
3) 검색어 트렌드·쇼핑 인사이트 검증 {"errMsg", "errId"}
"""
import json
from typing import Any, Optional
import httpx
from common.logger import LOG
from config.config_models import NaverApiHubConfig
from config.server_configs import naver_api_hub_config
class NaverApiHubError(Exception):
"""허브 호출 실패. status/code 로 재시도 가치를 구분한다.
auth_failed(401·403) 와 not_found(404) 는 재시도해도 그대로다 — 키 설정이나 경로가 틀린 것.
쿼터 소진(429)·5xx 만 재시도 가치가 있다(retryable).
"""
def __init__(self, message: str, *, status: int, code: Optional[str] = None, path: str = ""):
super().__init__(message)
self.status = status
self.code = code
self.path = path
@property
def auth_failed(self) -> bool:
return self.status in (401, 403)
@property
def not_found(self) -> bool:
return self.status == 404
@property
def retryable(self) -> bool:
return self.status == 429 or self.status >= 500
class NaverApiHubClient:
"""허브 공통 클라이언트. 개별 API(쇼핑 인사이트 등)는 이 위에 얹는다.
transport 는 테스트에서 httpx.MockTransport 를 꽂기 위한 주입점이다(네트워크 없이 계약 검증).
"""
def __init__(self, cfg: Optional[NaverApiHubConfig] = None, *, transport: Any = None):
self._cfg = cfg if cfg is not None else naver_api_hub_config
self._transport = transport
@property
def enabled(self) -> bool:
return self._cfg.enabled
def _headers(self) -> dict:
return {
"X-NCP-APIGW-API-KEY-ID": self._cfg.client_id,
"X-NCP-APIGW-API-KEY": self._cfg.client_secret,
"Content-Type": "application/json",
}
async def post(self, path: str, body: dict) -> dict:
"""POST 요청 1건. 성공이면 파싱된 JSON, 실패면 NaverApiHubError."""
if not self.enabled:
raise NaverApiHubError(
"NAVER API HUB 미설정 — config.local.toml [NaverApiHubConfig] 의 client_id/client_secret 을 채우세요",
status=0, path=path,
)
url = f"{self._cfg.base_url.rstrip('/')}{path}"
async with httpx.AsyncClient(timeout=self._cfg.timeout_sec, transport=self._transport) as client:
r = await client.post(url, headers=self._headers(), json=body)
if r.status_code != 200:
raise self._parse_error(r, path)
return r.json()
@staticmethod
def _parse_error(r: httpx.Response, path: str) -> NaverApiHubError:
"""세 가지 오류 바디 형식을 하나로 정규화. JSON 이 아니면 본문 앞부분을 그대로 싣는다."""
code = None
message = r.text[:200]
try:
body = r.json()
except (json.JSONDecodeError, ValueError):
body = None
if isinstance(body, dict):
if isinstance(body.get("error"), dict): # 1) 게이트웨이
err = body["error"]
code = str(err.get("errorCode") or "") or None
message = " ".join(str(v) for v in (err.get("message"), err.get("details")) if v)
elif "errorMessage" in body or "errorCode" in body: # 2) Search API
code = str(body.get("errorCode") or "") or None
message = str(body.get("errorMessage") or message)
elif "errMsg" in body or "errId" in body: # 3) 트렌드·인사이트
code = str(body.get("errId") or "") or None
message = str(body.get("errMsg") or message)
LOG.w(f"[naver-hub] {r.status_code} {path} code={code} {message}")
return NaverApiHubError(message, status=r.status_code, code=code, path=path)

View File

@ -0,0 +1,160 @@
"""쇼핑 인사이트 — 분야별 트렌드 조회 (POST /shopping/v1/categories).
네이버 데이터랩 쇼핑인사이트의 **분야별 검색 클릭 추이**를 조회한다.
반환값 ratio 는 절대 클릭수가 아니라 **구간 내 최대값을 100 으로 둔 상대 지표**다
(같은 응답 안에서만 비교 가능 — 다른 조회 결과와 절대 비교하면 안 된다).
⇒ 상품명·가격·판매처가 없으므로 최저가 검색 소스로는 쓸 수 없다. 수요 추이 분석용.
요청 제약은 전부 호출 전에 검증한다 — 게이트웨이 왕복 없이 바로 틀린 곳을 알려주는 게 낫다.
"""
from datetime import date
from typing import Optional, Sequence
from pydantic import BaseModel, Field
from common.logger import LOG
from services.naver_hub.client import NaverApiHubClient
_PATH = "/shopping/v1/categories"
_MAX_CATEGORIES = 3 # 문서: 최대 3개 쌍
_MIN_START = date(2017, 8, 1) # 문서: 2017년 8월 1일부터 조회 가능
_TIME_UNITS = ("date", "week", "month")
_DEVICES = ("pc", "mo")
_GENDERS = ("m", "f")
_AGES = ("10", "20", "30", "40", "50", "60")
class ShoppingCategory(BaseModel):
"""조회할 쇼핑 분야. param 은 네이버쇼핑 카테고리 URL 의 cat_id 값."""
name: str = Field(description="쇼핑 분야 이름(응답 title 로 되돌아옴)")
param: list[str] = Field(description="쇼핑 분야 코드 목록(cat_id)")
class InsightPoint(BaseModel):
period: str = Field(description="구간 시작 날짜(yyyy-mm-dd)")
ratio: float = Field(description="구간별 클릭량의 상대 비율 — 결과 내 최대값이 100")
class InsightSeries(BaseModel):
title: str = Field(description="쇼핑 분야 이름")
category: list[str] = Field(default_factory=list, description="쇼핑 분야 코드")
data: list[InsightPoint] = Field(default_factory=list, description="구간별 추이")
class ShoppingInsightResult(BaseModel):
start_date: str
end_date: str
time_unit: str
results: list[InsightSeries] = Field(default_factory=list)
class ShoppingInsightClient:
"""쇼핑 인사이트 조회기. 허브 공통 클라이언트를 감싼다."""
def __init__(self, client: Optional[NaverApiHubClient] = None):
self._client = client or NaverApiHubClient()
@property
def enabled(self) -> bool:
return self._client.enabled
async def categories(
self,
*,
start_date: str,
end_date: str,
categories: Sequence[ShoppingCategory],
time_unit: str = "date",
device: Optional[str] = None,
gender: Optional[str] = None,
ages: Optional[Sequence[str]] = None,
) -> ShoppingInsightResult:
"""분야별 트렌드 조회. 검증 실패는 ValueError, 호출 실패는 NaverApiHubError."""
body = build_categories_body(
start_date=start_date, end_date=end_date, categories=categories,
time_unit=time_unit, device=device, gender=gender, ages=ages,
)
data = await self._client.post(_PATH, body)
result = parse_categories_response(data)
LOG.d(f"[naver-hub] 쇼핑인사이트 {start_date}~{end_date} {time_unit} → {len(result.results)}개 분야")
return result
def build_categories_body(
*,
start_date: str,
end_date: str,
categories: Sequence[ShoppingCategory],
time_unit: str = "date",
device: Optional[str] = None,
gender: Optional[str] = None,
ages: Optional[Sequence[str]] = None,
) -> dict:
"""요청 바디 생성 + 문서상의 제약 검증(순수 함수 — 네트워크 없이 테스트 가능)."""
start = _parse_date(start_date, "startDate")
end = _parse_date(end_date, "endDate")
if start < _MIN_START:
raise ValueError(f"startDate 는 {_MIN_START.isoformat()} 이후여야 합니다(문서 제약): {start_date}")
if start > end:
raise ValueError(f"startDate 가 endDate 보다 늦습니다: {start_date} > {end_date}")
if time_unit not in _TIME_UNITS:
raise ValueError(f"timeUnit 은 {_TIME_UNITS} 중 하나여야 합니다: {time_unit!r}")
if not categories:
raise ValueError("category 는 최소 1개가 필요합니다")
if len(categories) > _MAX_CATEGORIES:
raise ValueError(f"category 는 최대 {_MAX_CATEGORIES}개입니다(요청 {len(categories)}개)")
for c in categories:
if not c.name or not c.param:
raise ValueError(f"category 의 name/param 이 비었습니다: {c!r}")
body: dict = {
"startDate": start_date,
"endDate": end_date,
"timeUnit": time_unit,
"category": [{"name": c.name, "param": list(c.param)} for c in categories],
}
# 선택 파라미터는 값이 있을 때만 싣는다 — 빈 값을 보내면 게이트웨이가 검증 오류로 되돌린다.
if device is not None:
if device not in _DEVICES:
raise ValueError(f"device 는 {_DEVICES} 중 하나여야 합니다: {device!r}")
body["device"] = device
if gender is not None:
if gender not in _GENDERS:
raise ValueError(f"gender 는 {_GENDERS} 중 하나여야 합니다: {gender!r}")
body["gender"] = gender
if ages:
bad = [a for a in ages if a not in _AGES]
if bad:
raise ValueError(f"ages 는 {_AGES} 중에서 골라야 합니다: {bad}")
body["ages"] = list(ages)
return body
def parse_categories_response(data: dict) -> ShoppingInsightResult:
"""응답 → 타입 있는 결과. 응답 키가 카멜케이스라 여기서 한 번만 변환한다."""
return ShoppingInsightResult(
start_date=data.get("startDate", ""),
end_date=data.get("endDate", ""),
time_unit=data.get("timeUnit", ""),
results=[
InsightSeries(
title=r.get("title", ""),
category=list(r.get("category") or []),
data=[InsightPoint(period=p.get("period", ""), ratio=float(p.get("ratio", 0)))
for p in (r.get("data") or [])],
)
for r in (data.get("results") or [])
],
)
def _parse_date(value: str, field: str) -> date:
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
raise ValueError(f"{field} 형식은 yyyy-mm-dd 여야 합니다: {value!r}") from None

163
lps/tests/test_naver_hub.py Normal file
View File

@ -0,0 +1,163 @@
"""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)