o2o-site-AEO/solution/backend/tests/test_naver.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

425 lines
17 KiB
Python

"""네이버 지역검색 클라이언트 — 동일 업소 판정과 응답 파싱."""
import httpx
import pytest
from services.external.kakao import MatchOutcome
from services.external.naver import (
NaverLocalClient,
NaverNotConfigured,
NaverPlace,
NaverRequestFailed,
normalize_name,
pick_match,
region_key,
reset_call_counts,
strip_tags,
)
# ── 네이버 실제 응답 모양 ──────────────────────────────── 서울특별시청: mapx=1269783882 mapy=375666103 → 126.97839, 37.56661 (실제 37.5663, 126.9779)
_SEOUL_CITY_HALL = {
"title": "<b>서울</b>특별시청",
"link": "https://www.seoul.go.kr/",
"category": "공공,사회기관>시청",
"description": "",
"telephone": "",
"address": "서울특별시 중구 태평로1가 31",
"roadAddress": "서울특별시 중구 세종대로 110",
"mapx": "1269783882",
"mapy": "375666103",
}
# 하조대 인근 펜션 — title 에 <b> 와 &amp; 가 함께 섞인 실제 모양
_PENSION = {
"title": "풀라운지<b>펜션</b>&amp;글램핑",
"link": "",
"category": "숙박>펜션",
"description": "",
"telephone": "",
"address": "강원특별자치도 양양군 현북면 하광정리 산 1",
"roadAddress": "강원특별자치도 양양군 현북면 하조대2길 48-69",
"mapx": "1287203824",
"mapy": "380236812",
}
def _handler(payload, status: int = 200, capture: dict | None = None):
"""MockTransport 핸들러."""
def _h(request: httpx.Request) -> httpx.Response:
if capture is not None:
capture["url"] = str(request.url)
capture["params"] = dict(request.url.params)
capture["headers"] = dict(request.headers)
if isinstance(payload, str):
return httpx.Response(status, text=payload)
return httpx.Response(status, json=payload)
return _h
def _client(handler, client_id="test-id", client_secret="test-secret") -> NaverLocalClient:
"""MockTransport 를 물린 클라이언트."""
return NaverLocalClient(
client_id=client_id, client_secret=client_secret, transport=httpx.MockTransport(handler)
)
def _place(name, road="강원특별자치도 양양군 현북면 하조대3길 25", addr=None) -> NaverPlace:
return NaverPlace(
name=name, road_address=road, address=addr, phone=None,
latitude=38.02, longitude=128.72, category_name="숙박>펜션",
place_url=None, naver_place_id=None,
)
@pytest.fixture(autouse=True)
def _reset():
reset_call_counts()
yield
reset_call_counts()
# ── 응답 파싱 ─────────────────────────────────────────────────────────────
async def test_parses_item_and_strips_highlight_tags():
"""검증: title 에 `<b>` 태그와 HTML 엔티티가 섞인 응답을 파싱한다."""
c = _client(_handler({"items": [_PENSION]}))
places = await c.search_local("하조대 펜션")
assert len(places) == 1
assert places[0].name == "풀라운지펜션&글램핑"
assert places[0].category_name == "숙박>펜션"
assert places[0].road_address == "강원특별자치도 양양군 현북면 하조대2길 48-69"
async def test_mapx_is_longitude_and_mapy_is_latitude():
"""뒤집히면 위도 126 도가 되어 지구 밖이다."""
c = _client(_handler({"items": [_SEOUL_CITY_HALL]}))
place = (await c.search_local("서울특별시청"))[0]
assert place.longitude == pytest.approx(126.9779, abs=0.01) # mapx = 경도
assert place.latitude == pytest.approx(37.5663, abs=0.01) # mapy = 위도
assert 33.0 < place.latitude < 39.0, "위도가 한반도 범위를 벗어났다(mapx/mapy 를 뒤집었을 가능성)"
assert 124.0 < place.longitude < 132.0
async def test_empty_telephone_becomes_none():
"""검증: 네이버가 telephone 을 빈 문자열로 주는 실제 동작."""
c = _client(_handler({"items": [_PENSION]}))
place = (await c.search_local("펜션"))[0]
assert place.phone is None
async def test_link_is_business_homepage_not_place_id():
"""검증: link 필드 처리."""
c = _client(_handler({"items": [_SEOUL_CITY_HALL]}))
place = (await c.search_local("서울특별시청"))[0]
assert place.place_url == "https://www.seoul.go.kr/"
assert place.naver_place_id is None
async def test_missing_coordinates_do_not_crash():
"""검증: mapx/mapy 가 비었거나 숫자가 아닌 응답."""
broken = {**_PENSION, "mapx": "", "mapy": None}
c = _client(_handler({"items": [broken]}))
place = (await c.search_local("펜션"))[0]
assert place.latitude is None and place.longitude is None
assert place.name == "풀라운지펜션&글램핑"
async def test_empty_items_returns_empty_list():
"""검증: 검색 결과가 0건인 응답."""
c = _client(_handler({"items": []}))
assert await c.search_local("없는가게") == []
# ── 네이버 제약: 5건 상한 ──────────────────────────────────────────────────
async def test_display_is_clamped_to_five():
"""검증: display 를 15 로 요청한다."""
cap: dict = {}
c = _client(_handler({"items": []}, capture=cap))
await c.search_local("카페", display=15)
assert cap["params"]["display"] == "5"
async def test_display_lower_bound_is_one():
"""검증: display 를 0 이하로 요청한다."""
cap: dict = {}
c = _client(_handler({"items": []}, capture=cap))
await c.search_local("카페", display=0)
assert cap["params"]["display"] == "1"
async def test_auth_headers_are_sent():
"""검증: 요청 헤더."""
cap: dict = {}
c = _client(_handler({"items": []}, capture=cap))
await c.search_local("카페")
assert cap["headers"]["x-naver-client-id"] == "test-id"
assert cap["headers"]["x-naver-client-secret"] == "test-secret"
# ── 동일 업소 판정 ────────────────────────────────────────────────────────
def test_pick_match_no_candidate():
"""검증: 후보가 0건이다."""
result = pick_match("하조대펜션", [])
assert result.outcome == MatchOutcome.NO_CANDIDATE
assert result.place is None
assert result.is_matched is False
def test_pick_match_single_exact_name():
"""검증: 상호명이 정확히 1건만 일치한다."""
candidates = [_place("하조대펜션"), _place("핑크비치펜션"), _place("부커스비치호텔")]
result = pick_match("하조대펜션", candidates)
assert result.outcome == MatchOutcome.MATCHED
assert result.reason == "name_exact"
assert result.place.name == "하조대펜션"
def test_pick_match_ignores_spacing_in_name():
"""검증: '하조대 펜션' 으로 찾고 후보는 '하조대펜션' 이다."""
result = pick_match("하조대 펜션", [_place("하조대펜션")])
assert result.outcome == MatchOutcome.MATCHED
def test_pick_match_duplicate_names_is_ambiguous():
"""검증: 상호명이 똑같은 업소가 2건이다(주소 힌트 없음)."""
candidates = [
_place("하조대펜션", road="강원특별자치도 양양군 현북면 하조대3길 25"),
_place("하조대펜션", road="강원특별자치도 양양군 손양면 도리단길 7"),
]
result = pick_match("하조대펜션", candidates)
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.reason == "name_duplicate"
assert result.place is None
assert len(result.candidates) == 2
assert "전화번호를 주지 않아" in result.detail
def test_pick_match_address_hint_narrows_duplicates():
"""검증: 동명 업소 2건에 주소 힌트를 준다."""
candidates = [
_place("하조대펜션", road="강원특별자치도 양양군 현북면 하조대3길 25"),
_place("하조대펜션", road="강원특별자치도 양양군 손양면 도리단길 7"),
]
result = pick_match("하조대펜션", candidates, address_hint="양양군 현북면 하조대3길")
assert result.outcome == MatchOutcome.MATCHED
assert result.reason == "name_address"
assert "현북면" in result.place.road_address
def test_pick_match_address_hint_that_narrows_nothing_stays_ambiguous():
"""검증: 동명 업소 2건인데 주소 힌트가 양쪽에 똑같이 걸린다."""
candidates = [
_place("하조대펜션", road="강원특별자치도 양양군 현북면 A길 1"),
_place("하조대펜션", road="강원특별자치도 양양군 현북면 B길 2"),
]
result = pick_match("하조대펜션", candidates, address_hint="양양군 현북면")
assert result.outcome == MatchOutcome.AMBIGUOUS
def test_pick_match_partial_name_is_ambiguous():
"""검증: 정확히 일치하는 상호명이 없고 부분일치만 있다."""
candidates = [_place("하조대펜션 별관"), _place("하조대펜션앤스파")]
result = pick_match("하조대펜션", candidates)
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.reason == "name_no_exact"
assert result.place is None
def test_pick_match_single_partial_candidate_is_still_ambiguous():
"""검증: 후보가 딱 1건인데 상호명이 정확히 일치하지 않는다."""
result = pick_match("하조대펜션", [_place("하조대풀빌라")])
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.place is None
async def test_verify_place_end_to_end():
"""검증: 상호명으로 검색해서 판정까지 한 번에 한다."""
item = {**_PENSION, "title": "하조대<b>펜션</b>"}
c = _client(_handler({"items": [item]}))
result = await c.verify_place("하조대펜션", address_hint="강원특별자치도 양양군")
assert result.outcome == MatchOutcome.MATCHED
assert result.place.name == "하조대펜션"
async def test_verify_place_retries_without_address_when_empty():
"""검증: 주소를 섞어 검색했더니 0건이다."""
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(200, json={"items": []})
return httpx.Response(200, json={"items": [{**_PENSION, "title": "하조대펜션"}]})
c = _client(handler)
result = await c.verify_place("하조대펜션", address_hint="강원 양양")
assert calls["n"] == 2
assert result.outcome == MatchOutcome.MATCHED
# ── 지역 캐시 키 ──────────────────────────────────────────────────────────
def test_region_key_basic():
"""검증: 도로명주소에서 시도 + 시군구를 뽑는다."""
assert region_key("강원특별자치도 양양군 현북면 하조대3길 25") == "51양양군"
def test_region_key_merges_sido_aliases():
"""검증: 같은 지역인데 시도 이름이 옛 이름/새 이름으로 다르게 온다."""
assert region_key("강원도 양양군 현북면 하조대3길 25") == region_key(
"강원특별자치도 양양군 현북면 하조대3길 25"
)
assert region_key("전라북도 전주시 완산구 A로 1") == region_key("전북특별자치도 전주시 완산구 A로 1")
assert region_key("제주도 제주시 A로 1") == region_key("제주특별자치도 제주시 A로 1")
def test_region_key_metropolitan_district():
"""검증: 특별시·광역시의 자치구."""
assert region_key("서울특별시 강남구 도산대로57길 24 (청담동)") == "11강남구"
assert region_key("부산광역시 해운대구 우동 1394") == "26해운대구"
def test_region_key_general_district_collapses_to_city():
"""검증: 일반구(성남시 분당구)를 가진 주소."""
assert region_key("경기도 성남시 분당구 판교역로 235") == "41성남시"
assert region_key("경기도 성남시 수정구 A로 1") == "41성남시"
def test_region_key_single_tier_sejong():
"""검증: 시군구가 없는 세종특별자치시."""
assert region_key("세종특별자치시 한누리대로 2130") == "36세종"
def test_region_key_returns_none_when_unmappable():
"""검증: 시도를 알 수 없는 주소(해외·빈 값)."""
assert region_key("Tokyo, Japan") is None
assert region_key("") is None
assert region_key(None) is None
assert region_key("강원특별자치도") is None # 시군구가 없다
def test_region_key_fits_db_column():
"""검증: 만들어지는 키의 길이."""
addresses = [
"강원특별자치도 양양군 현북면 하조대3길 25",
"서울특별시 강남구 도산대로57길 24",
"경기도 성남시 분당구 판교역로 235",
"세종특별자치시 한누리대로 2130",
"경상남도 창원시 마산합포구 A로 1",
"제주특별자치도 서귀포시 A로 1",
"경기도 동두천시 A로 1",
]
for addr in addresses:
key = region_key(addr)
assert key is not None, addr
assert len(key) <= 10, f"{addr} → {key} ({len(key)}자)"
def test_region_key_distinguishes_same_name_across_provinces():
"""검증: 이름이 같은 시군구가 다른 시도에 있다(고성군: 강원·경남)."""
assert region_key("강원특별자치도 고성군 A로 1") != region_key("경상남도 고성군 A로 1")
# ── 문자열 유틸 ───────────────────────────────────────────────────────────
def test_strip_tags_order_protects_literal_text():
"""검증: 본문에 이스케이프된 태그 문자열이 들어 있다."""
assert strip_tags("<b>카페</b>&amp;베이커리") == "카페&베이커리"
assert strip_tags("공식 &lt;b&gt; 표기") == "공식 <b> 표기"
def test_normalize_name_only_touches_space_and_case():
"""검증: 상호명 정규화 범위."""
assert normalize_name("하조대 펜션") == normalize_name("하조대펜션")
assert normalize_name("Blue Ocean") == normalize_name("blueocean")
assert normalize_name("하조대펜션") != normalize_name("하조대펜션별관")
# ── 설정·장애 처리 ────────────────────────────────────────────────────────
async def test_missing_keys_raise_not_configured():
c = NaverLocalClient(client_id="", client_secret="")
assert c.enabled is False
with pytest.raises(NaverNotConfigured):
await c.search_local("카페")
async def test_partial_keys_are_treated_as_unconfigured():
"""검증: ID 만 있고 Secret 이 없다."""
c = NaverLocalClient(client_id="only-id", client_secret="")
assert c.enabled is False
with pytest.raises(NaverNotConfigured):
await c.search_local("카페")
async def test_401_raises_not_configured():
"""검증: 키는 있는데 인증이 거부된다(401)."""
c = _client(_handler({"errorMessage": "Not Exist Client ID"}, status=401))
with pytest.raises(NaverNotConfigured):
await c.search_local("카페")
async def test_5xx_raises_request_failed():
"""검증: 네이버가 5xx 를 준다."""
c = _client(_handler("upstream error", status=503))
with pytest.raises(NaverRequestFailed):
await c.search_local("카페")
async def test_timeout_raises_request_failed():
"""검증: 요청이 타임아웃된다."""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("timed out", request=request)
c = _client(handler)
with pytest.raises(NaverRequestFailed):
await c.search_local("카페")
async def test_non_json_body_raises_request_failed():
"""검증: 200 인데 본문이 JSON 이 아니다."""
c = _client(_handler("<html>maintenance</html>", status=200))
with pytest.raises(NaverRequestFailed):
await c.search_local("카페")
async def test_call_counts_track_usage():
"""검증: 호출 횟수 누적."""
from services.external.naver import call_counts
c = _client(_handler({"items": []}))
await c.search_local("카페")
await c.search_local("펜션")
assert call_counts()["local"] == 2
async def test_search_nearby_builds_region_scoped_query():
"""검증: 주변 검색은 반경이 없어 '지역명 + 키워드' 로 찾는다."""
cap: dict = {}
c = _client(_handler({"items": []}, capture=cap))
await c.search_nearby("양양군", "맛집", region_key_hint="51양양군")
assert cap["params"]["query"] == "양양군 맛집"