o2o-site-AEO/solution/backend/tests/test_kakao.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

382 lines
15 KiB
Python

"""카카오 로컬 클라이언트 — 동일 업소 판정과 응답 파싱."""
import httpx
import pytest
from services.external.kakao import (
CATEGORY_RESTAURANT,
KakaoLocalClient,
KakaoNotConfigured,
KakaoPlace,
KakaoRequestFailed,
MatchOutcome,
normalize_name,
normalize_phone,
pick_match,
)
# ── 카카오 실제 응답 모양 ─────────────────────────────────────────────────
_KEYWORD_DOC = {
"id": "26338954",
"place_name": "하조대펜션",
"category_name": "가정,생활 > 숙박 > 펜션",
"category_group_code": "AD5",
"phone": "033-672-0000",
"address_name": "강원 양양군 현북면 하광정리 3-1",
"road_address_name": "강원 양양군 현북면 하조대해안길 3",
"x": "128.6712345", # 경도
"y": "38.0451234", # 위도
"place_url": "http://place.map.kakao.com/26338954",
}
_COORD2REGION_DOCS = [
{
"region_type": "B",
"code": "4283025021",
"address_name": "강원도 양양군 현북면 하광정리",
"region_1depth_name": "강원도",
"region_2depth_name": "양양군",
"region_3depth_name": "현북면 하광정리",
},
{
"region_type": "H",
"code": "4283025000",
"address_name": "강원도 양양군 현북면",
"region_1depth_name": "강원도",
"region_2depth_name": "양양군",
"region_3depth_name": "현북면",
},
]
def _client(handler, api_key: str = "test-kakao-key") -> KakaoLocalClient:
"""MockTransport 를 물린 클라이언트."""
return KakaoLocalClient(api_key=api_key, transport=httpx.MockTransport(handler))
def _place(name, kakao_id="1", phone=None, road="강원 양양군 A로 1") -> KakaoPlace:
return KakaoPlace(
kakao_place_id=kakao_id, name=name, road_address=road, address=None,
phone=phone, latitude=38.0, longitude=128.6, category_name=None,
category_group_code=None, place_url=None,
)
# ── 1) 키워드 검색 파싱 ───────────────────────────────────────────────────
async def test_search_keyword_parses_documents():
"""검증: 키워드 검색 응답을 KakaoPlace 로 파싱한다."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["auth"] = request.headers.get("Authorization")
return httpx.Response(200, json={"documents": [_KEYWORD_DOC], "meta": {"total_count": 1}})
places = await _client(handler).search_keyword("하조대펜션")
assert len(places) == 1
p = places[0]
assert p.kakao_place_id == "26338954"
assert p.name == "하조대펜션"
assert p.phone == "033-672-0000"
assert p.road_address == "강원 양양군 현북면 하조대해안길 3"
assert p.longitude == pytest.approx(128.6712345) # x
assert p.latitude == pytest.approx(38.0451234) # y
assert captured["auth"] == "KakaoAK test-kakao-key"
assert "search/keyword.json" in captured["url"]
async def test_search_keyword_sends_coordinates_when_given():
"""검증: 좌표를 주고 키워드 검색한다."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json={"documents": []})
await _client(handler).search_keyword("펜션", x=128.67, y=38.04)
assert captured["params"]["x"] == "128.67" # 경도
assert captured["params"]["y"] == "38.04" # 위도
async def test_search_keyword_empty_result():
"""검증: 후보가 없는 검색."""
def handler(request):
return httpx.Response(200, json={"documents": []})
assert await _client(handler).search_keyword("없는가게") == []
# ── 2) 좌표 → 행정구역 코드 ───────────────────────────────────────────────
async def test_coord_to_region_prefers_administrative_dong():
"""검증: 좌표를 행정구역 코드로 바꾼다."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json={"documents": _COORD2REGION_DOCS})
region = await _client(handler).coord_to_region(lat=38.0451234, lon=128.6712345)
assert region.code == "4283025000" # H 문서
assert region.region_type == "H"
assert region.region_1depth_name == "강원도"
assert region.full_name == "강원도 양양군 현북면"
assert captured["params"]["x"] == "128.6712345" # 경도
assert captured["params"]["y"] == "38.0451234" # 위도
async def test_coord_to_region_falls_back_to_first_document():
"""검증: 행정동(H) 문서가 없는 응답."""
def handler(request):
return httpx.Response(200, json={"documents": [_COORD2REGION_DOCS[0]]})
region = await _client(handler).coord_to_region(38.0, 128.6)
assert region.code == "4283025021"
assert region.region_type == "B"
async def test_coord_to_region_without_result_raises():
"""검증: 좌표 변환 결과가 비어 있다."""
def handler(request):
return httpx.Response(200, json={"documents": []})
with pytest.raises(KakaoRequestFailed):
await _client(handler).coord_to_region(38.0, 128.6)
# ── 3) 주변 카테고리 검색 ─────────────────────────────────────────────────
async def test_search_category_sends_expected_params():
"""검증: 주변 맛집(FD6) 검색."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json={"documents": [_KEYWORD_DOC]})
places = await _client(handler).search_category(
region_x=128.67, region_y=38.04, category_group_code=CATEGORY_RESTAURANT,
radius=1500, region_code="4283025000",
)
assert len(places) == 1
assert captured["params"]["category_group_code"] == "FD6"
assert captured["params"]["radius"] == "1500"
assert captured["params"]["x"] == "128.67"
assert captured["params"]["y"] == "38.04"
async def test_search_category_warns_without_region_code(capsys):
"""검증: 행정구역 코드 없이 카테고리 검색을 부른다."""
def handler(request):
return httpx.Response(200, json={"documents": []})
await _client(handler).search_category(128.67, 38.04, CATEGORY_RESTAURANT)
assert "region_code 없이" in capsys.readouterr().err
async def test_search_category_clamps_radius():
"""검증: 카카오 상한(20km)을 넘는 반경을 넘긴다."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["radius"] = dict(request.url.params)["radius"]
return httpx.Response(200, json={"documents": []})
await _client(handler).search_category(128.6, 38.0, CATEGORY_RESTAURANT, radius=99999, region_code="X")
assert captured["radius"] == "20000"
# ── 4) ★ 동일 업소 판정 ───────────────────────────────────────────────────
def test_pick_match_no_candidate():
"""검증: 후보가 0건이다."""
result = pick_match("하조대펜션", [])
assert result.outcome == MatchOutcome.NO_CANDIDATE
assert result.place is None
assert result.reason == "no_candidate"
def test_pick_match_single_exact_name():
"""검증: 상호명이 정확히 일치하는 후보가 1건이다."""
only = _place("하조대펜션", "111")
result = pick_match("하조대펜션", [only])
assert result.outcome == MatchOutcome.MATCHED
assert result.place is only
assert result.reason == "name_exact"
assert result.is_matched is True
def test_pick_match_ignores_whitespace_and_case():
"""검증: '하조대 펜션' 으로 검색하고 후보는 '하조대펜션' 이다."""
result = pick_match("하조대 펜션", [_place("하조대펜션", "111")])
assert result.outcome == MatchOutcome.MATCHED
def test_pick_match_duplicate_names_is_ambiguous():
"""검증: ★ 상호명이 같은 업소가 2건이다(동명 업소)."""
a = _place("하조대펜션", "111", road="강원 양양군 A로 1")
b = _place("하조대펜션", "222", road="강원 양양군 B로 2")
result = pick_match("하조대펜션", [a, b])
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.place is None
assert result.reason == "name_duplicate"
assert len(result.candidates) == 2
# 근거에 주소가 실려 사람이 고를 수 있어야 한다.
assert "A로 1" in result.detail and "B로 2" in result.detail
def test_pick_match_partial_name_is_ambiguous():
"""검증: 후보가 1건뿐이지만 상호명이 정확히 일치하지 않는다('하조대펜션' vs '하조대펜션 별관')."""
result = pick_match("하조대펜션", [_place("하조대펜션 별관", "111")])
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.reason == "name_no_exact"
assert result.place is None
def test_pick_match_phone_resolves_duplicate_names():
"""검증: 동명 업소 2건인데 전화번호가 주어졌고 1건만 일치한다."""
a = _place("하조대펜션", "111", phone="033-672-0000")
b = _place("하조대펜션", "222", phone="033-672-9999")
result = pick_match("하조대펜션", [a, b], phone="0336720000")
assert result.outcome == MatchOutcome.MATCHED
assert result.place is a
assert result.reason == "phone_exact"
def test_pick_match_phone_mismatch_falls_back_to_name():
"""검증: 전화번호를 줬지만 어느 후보와도 안 맞는다(동명 2건)."""
a = _place("하조대펜션", "111", phone="033-672-0000")
b = _place("하조대펜션", "222", phone="033-672-9999")
result = pick_match("하조대펜션", [a, b], phone="02-000-0000")
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.reason == "name_duplicate"
def test_pick_match_phone_narrows_then_name_decides():
"""검증: 전화번호가 같은 후보가 2건이고 그중 상호명이 정확히 맞는 게 1건이다(지점 등록)."""
a = _place("하조대펜션", "111", phone="033-672-0000")
b = _place("하조대펜션 카페", "222", phone="033-672-0000")
c = _place("다른펜션", "333", phone="033-999-9999")
result = pick_match("하조대펜션", [a, b, c], phone="033-672-0000")
assert result.outcome == MatchOutcome.MATCHED
assert result.place is a
assert "좁힘" in result.detail
def test_pick_match_never_guesses_from_many_unrelated():
"""검증: 상호명이 정확히 맞는 후보 없이 비슷한 이름만 여럿이다."""
candidates = [_place(f"하조대펜션{i}", str(i)) for i in range(5)]
result = pick_match("하조대펜션", candidates)
assert result.outcome == MatchOutcome.AMBIGUOUS
assert result.place is None
assert len(result.candidates) == 5
def test_normalize_helpers():
"""검증: 비교용 정규화."""
assert normalize_name("하조대 펜션") == normalize_name("하조대펜션")
assert normalize_name("Beach House") == "beachhouse"
assert normalize_name("하조대펜션") != normalize_name("하조대펜션별관")
assert normalize_phone("033-672-0000") == "0336720000"
assert normalize_phone("") == ""
# ── 5) 조합: verify_place ─────────────────────────────────────────────────
async def test_verify_place_matches_single_candidate():
"""검증: 상호명으로 검색해 동일 업소까지 한 번에 판정한다."""
def handler(request):
return httpx.Response(200, json={"documents": [_KEYWORD_DOC]})
result = await _client(handler).verify_place("하조대펜션", phone="033-672-0000")
assert result.outcome == MatchOutcome.MATCHED
assert result.place.kakao_place_id == "26338954"
async def test_verify_place_ambiguous_when_duplicate_names():
"""검증: 검색 결과에 동명 업소가 2건이다."""
doc2 = {**_KEYWORD_DOC, "id": "999", "phone": "033-000-0000", "road_address_name": "강원 양양군 B로 2"}
def handler(request):
return httpx.Response(200, json={"documents": [_KEYWORD_DOC, doc2]})
result = await _client(handler).verify_place("하조대펜션")
assert result.outcome == MatchOutcome.AMBIGUOUS
assert len(result.candidates) == 2
# ── 6) 설정·장애 ──────────────────────────────────────────────────────────
async def test_missing_api_key_raises_not_configured():
"""검증: KAKAO_REST_API_KEY 가 비어 있다."""
client = KakaoLocalClient(api_key="")
assert client.enabled is False
with pytest.raises(KakaoNotConfigured):
await client.search_keyword("아무거나")
async def test_invalid_key_401_raises_not_configured():
def handler(request):
return httpx.Response(401, json={"errorType": "AccessDeniedError"})
with pytest.raises(KakaoNotConfigured):
await _client(handler).search_keyword("하조대펜션")
async def test_server_error_raises_request_failed():
"""검증: 카카오가 5xx 를 돌려준다."""
def handler(request):
return httpx.Response(503, text="service unavailable")
with pytest.raises(KakaoRequestFailed):
await _client(handler).search_keyword("하조대펜션")
async def test_timeout_raises_request_failed():
"""검증: 요청이 타임아웃된다."""
def handler(request):
raise httpx.ReadTimeout("timed out", request=request)
with pytest.raises(KakaoRequestFailed):
await _client(handler).coord_to_region(38.0, 128.6)
async def test_malformed_json_raises_request_failed():
"""검증: 200 인데 본문이 JSON 이 아니다."""
def handler(request):
return httpx.Response(200, text="<html>not json</html>")
with pytest.raises(KakaoRequestFailed):
await _client(handler).search_keyword("하조대펜션")
async def test_call_counts_track_cost():
"""검증: 호출할 때마다 누적 카운터가 오른다."""
from services.external import kakao as kakao_mod
kakao_mod.reset_call_counts()
def handler(request):
if "coord2regioncode" in str(request.url):
return httpx.Response(200, json={"documents": _COORD2REGION_DOCS})
return httpx.Response(200, json={"documents": [_KEYWORD_DOC]})
client = _client(handler)
await client.search_keyword("하조대펜션")
await client.search_keyword("하조대펜션")
await client.coord_to_region(38.0, 128.6)
counts = kakao_mod.call_counts()
assert counts["keyword"] == 2
assert counts["coord2region"] == 1
kakao_mod.reset_call_counts()