최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
477 lines
21 KiB
Python
477 lines
21 KiB
Python
"""네이버 지역검색 클라이언트 — 동일 업소 판정과 응답 파싱.
|
|
|
|
★ 실제 API 를 호출하지 않는다(httpx.MockTransport). 테스트가 외부 쿼터를 태우면 안 된다.
|
|
|
|
여기서 고정하는 것:
|
|
1. mapx=경도 / mapy=위도, 1e7 로 나눈 값이 WGS84 — 뒤집거나 잘못 나누면 엉뚱한 지역이 붙는다
|
|
2. 네이버 제약(5건 상한 · 전화번호 없음)을 코드가 실제로 인지하는지
|
|
3. 동일 업소 판정이 애매할 때 **반드시 ambiguous 로 떨어지는지** — 이 서비스에서 가장 비싼 실수
|
|
4. 지역 캐시 키가 시도 별칭(강원도/강원특별자치도)을 하나로 모으는지, 그리고 10자를 안 넘는지
|
|
5. 키 미설정·장애가 조용히 빈 값으로 새지 않는지
|
|
"""
|
|
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,
|
|
)
|
|
|
|
# ── 네이버 실제 응답 모양 (2026-08-27 실측) ────────────────────────────────
|
|
# 서울특별시청: 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> 와 & 가 함께 섞인 실제 모양
|
|
_PENSION = {
|
|
"title": "풀라운지<b>펜션</b>&글램핑",
|
|
"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 핸들러. capture 를 주면 요청 정보를 담아준다."""
|
|
|
|
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():
|
|
"""검증: 실측 응답(서울특별시청)의 mapx/mapy 를 좌표로 바꾼다.
|
|
기대결과: 1e7 로 나눈 값이 실제 위경도(37.5663, 126.9779)와 오차 0.01 이내로 일치한다.
|
|
★ 뒤집히면 위도 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 을 빈 문자열로 주는 실제 동작.
|
|
기대결과: phone 이 None 이다 — 빈 문자열이 '전화번호가 있다'로 오해되면 안 된다."""
|
|
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 필드 처리.
|
|
기대결과: place_url 에는 담기지만 naver_place_id 는 None —
|
|
네이버 지역검색의 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 가 비었거나 숫자가 아닌 응답.
|
|
기대결과: 좌표만 None 이고 나머지는 정상 파싱된다(파싱 실패로 전체가 죽지 않는다)."""
|
|
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 로 요청한다.
|
|
기대결과: 실제 요청 파라미터가 5 로 잘린다 — 네이버 상한이 5이고, 더 줘도 조용히 5건만 온다."""
|
|
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 이하로 요청한다.
|
|
기대결과: 1 로 올라간다(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():
|
|
"""검증: 요청 헤더.
|
|
기대결과: X-Naver-Client-Id / X-Naver-Client-Secret 가 실린다."""
|
|
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건이다.
|
|
기대결과: NO_CANDIDATE — 확정도 애매도 아니고 '못 찾음'이다."""
|
|
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건만 일치한다.
|
|
기대결과: MATCHED(name_exact) — 확정."""
|
|
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():
|
|
"""검증: '하조대 펜션' 으로 찾고 후보는 '하조대펜션' 이다.
|
|
기대결과: MATCHED — 공백 차이는 같은 가게로 본다."""
|
|
result = pick_match("하조대 펜션", [_place("하조대펜션")])
|
|
|
|
assert result.outcome == MatchOutcome.MATCHED
|
|
|
|
|
|
def test_pick_match_duplicate_names_is_ambiguous():
|
|
"""검증: 상호명이 똑같은 업소가 2건이다(주소 힌트 없음).
|
|
기대결과: ★ AMBIGUOUS — 전화번호가 없어 가를 근거가 없으니 사람이 골라야 한다."""
|
|
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건에 주소 힌트를 준다.
|
|
기대결과: MATCHED(name_address) — 주소로 1건으로 좁혀지면 확정한다."""
|
|
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건인데 주소 힌트가 양쪽에 똑같이 걸린다.
|
|
기대결과: ★ 여전히 AMBIGUOUS — 애매한 걸 억지로 좁혀 확정하지 않는다."""
|
|
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():
|
|
"""검증: 정확히 일치하는 상호명이 없고 부분일치만 있다.
|
|
기대결과: ★ 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건인데 상호명이 정확히 일치하지 않는다.
|
|
기대결과: ★ AMBIGUOUS — 후보가 하나뿐이라고 그게 맞는 가게는 아니다."""
|
|
result = pick_match("하조대펜션", [_place("하조대풀빌라")])
|
|
|
|
assert result.outcome == MatchOutcome.AMBIGUOUS
|
|
assert result.place is None
|
|
|
|
|
|
async def test_verify_place_end_to_end():
|
|
"""검증: 상호명으로 검색해서 판정까지 한 번에 한다.
|
|
기대결과: 지역검색 1회로 MATCHED 가 나온다."""
|
|
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건이다.
|
|
기대결과: 상호명만으로 한 번 더 찾아본다 — 주소 표기 차이로 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():
|
|
"""검증: 도로명주소에서 시도 + 시군구를 뽑는다.
|
|
기대결과: '51양양군' — 이 키 단위로 지역 정보를 캐싱한다."""
|
|
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():
|
|
"""검증: 특별시·광역시의 자치구.
|
|
기대결과: 구 단위로 남는다('11강남구') — 생활권이 실제로 다르다."""
|
|
assert region_key("서울특별시 강남구 도산대로57길 24 (청담동)") == "11강남구"
|
|
assert region_key("부산광역시 해운대구 우동 1394") == "26해운대구"
|
|
|
|
|
|
def test_region_key_general_district_collapses_to_city():
|
|
"""검증: 일반구(성남시 분당구)를 가진 주소.
|
|
기대결과: 시 단위로 묶인다('41성남시') — 날씨·축제는 구 단위로 다르지 않아 캐시 히트율을 올린다."""
|
|
assert region_key("경기도 성남시 분당구 판교역로 235") == "41성남시"
|
|
assert region_key("경기도 성남시 수정구 A로 1") == "41성남시"
|
|
|
|
|
|
def test_region_key_single_tier_sejong():
|
|
"""검증: 시군구가 없는 세종특별자치시.
|
|
기대결과: '36세종' — 두 번째 토큰이 도로명이라 그대로 쓰면 안 된다."""
|
|
assert region_key("세종특별자치시 한누리대로 2130") == "36세종"
|
|
|
|
|
|
def test_region_key_returns_none_when_unmappable():
|
|
"""검증: 시도를 알 수 없는 주소(해외·빈 값).
|
|
기대결과: None — 억지로 키를 만들지 않고 호출측에 넘긴다."""
|
|
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():
|
|
"""검증: 만들어지는 키의 길이.
|
|
기대결과: 전부 10자 이내 — local_contents.region_code 가 VARCHAR(10) 이라 넘치면 잘린다."""
|
|
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():
|
|
"""검증: 본문에 이스케이프된 태그 문자열이 들어 있다.
|
|
기대결과: 태그를 먼저 지우고 엔티티를 나중에 풀어야 리터럴 '<b>' 가 살아남는다."""
|
|
assert strip_tags("<b>카페</b>&베이커리") == "카페&베이커리"
|
|
assert strip_tags("공식 <b> 표기") == "공식 <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():
|
|
"""검증: 클라이언트 ID/Secret 이 비었다.
|
|
기대결과: NaverNotConfigured — 이 어댑터만 비활성이고 부팅은 안 막는다."""
|
|
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 이 없다.
|
|
기대결과: NaverNotConfigured — 반쪽 설정으로 401 을 맞느니 미설정으로 본다."""
|
|
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).
|
|
기대결과: NaverNotConfigured — 설정 문제라 재시도해도 소용없다."""
|
|
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 를 준다.
|
|
기대결과: NaverRequestFailed — ★ 빈 리스트로 둔갑시키지 않는다.
|
|
빈 값을 내보내면 '주변에 맛집이 없다'가 되어 사이트에 거짓이 실린다."""
|
|
c = _client(_handler("upstream error", status=503))
|
|
|
|
with pytest.raises(NaverRequestFailed):
|
|
await c.search_local("카페")
|
|
|
|
|
|
async def test_timeout_raises_request_failed():
|
|
"""검증: 요청이 타임아웃된다.
|
|
기대결과: NaverRequestFailed — 조용히 빈 값으로 넘어가지 않는다."""
|
|
|
|
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 이 아니다.
|
|
기대결과: NaverRequestFailed — 파싱 실패를 빈 결과로 삼키지 않는다."""
|
|
c = _client(_handler("<html>maintenance</html>", status=200))
|
|
|
|
with pytest.raises(NaverRequestFailed):
|
|
await c.search_local("카페")
|
|
|
|
|
|
async def test_call_counts_track_usage():
|
|
"""검증: 호출 횟수 누적.
|
|
기대결과: 호출한 만큼 세어진다 — 생성 1건당 검색 횟수를 세는 근거(일 25,000 쿼터)."""
|
|
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"] == "양양군 맛집"
|