최상단을 프로젝트 단위로 평평하게 둔다 — 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
417 lines
18 KiB
Python
417 lines
18 KiB
Python
"""카카오 로컬 클라이언트 — 동일 업소 판정과 응답 파싱.
|
|
|
|
★ 실제 API 를 호출하지 않는다(httpx.MockTransport). 카카오 호출은 유료라 테스트가 때리면 안 된다.
|
|
|
|
여기서 고정하는 것:
|
|
1. x=경도 / y=위도 — 뒤집으면 엉뚱한 지역의 주변 정보가 붙는다
|
|
2. 동일 업소 판정이 애매할 때 **반드시 ambiguous 로 떨어지는지** — 이 서비스에서 가장 비싼 실수
|
|
3. 키 미설정·장애가 조용히 빈 값으로 새지 않는지
|
|
"""
|
|
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, place_url=None,
|
|
)
|
|
|
|
|
|
# ── 1) 키워드 검색 파싱 ───────────────────────────────────────────────────
|
|
async def test_search_keyword_parses_documents():
|
|
"""검증: 키워드 검색 응답을 KakaoPlace 로 파싱한다.
|
|
기대결과: ★ x 가 경도(longitude), y 가 위도(latitude) 로 들어간다 — 뒤집히면 다른 지역이 된다."""
|
|
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():
|
|
"""검증: 좌표를 주고 키워드 검색한다.
|
|
기대결과: x=경도, y=위도 로 쿼리에 실린다(지역을 알면 후보가 깨끗해진다)."""
|
|
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():
|
|
"""검증: 후보가 없는 검색.
|
|
기대결과: 빈 리스트 — 예외가 아니다(판정은 pick_match 가 한다)."""
|
|
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():
|
|
"""검증: 좌표를 행정구역 코드로 바꾼다.
|
|
기대결과: 행정동(region_type=H)을 우선 고른다 — 생활권 기준이라 주변 정보와 맞는다."""
|
|
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) 문서가 없는 응답.
|
|
기대결과: 첫 문서(법정동 B)로 폴백한다."""
|
|
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():
|
|
"""검증: 좌표 변환 결과가 비어 있다.
|
|
기대결과: KakaoRequestFailed — ★ 빈 값을 조용히 내보내지 않는다."""
|
|
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) 검색.
|
|
기대결과: 카테고리 코드·반경·좌표(x=경도, y=위도)가 그대로 실린다."""
|
|
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):
|
|
"""검증: 행정구역 코드 없이 카테고리 검색을 부른다.
|
|
기대결과: 경고 로그 — 캐시를 안 거치면 같은 지역을 사이트 수만큼 반복 조회하게 된다(건당 2원)."""
|
|
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)을 넘는 반경을 넘긴다.
|
|
기대결과: 20000 으로 잘린다(400 응답을 미리 막는다)."""
|
|
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건이다.
|
|
기대결과: NO_CANDIDATE — 호출측이 PLACE_VERIFY_NO_CANDIDATE 로 응답한다."""
|
|
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건이다.
|
|
기대결과: MATCHED — 근거(name_exact)가 함께 남는다."""
|
|
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():
|
|
"""검증: '하조대 펜션' 으로 검색하고 후보는 '하조대펜션' 이다.
|
|
기대결과: MATCHED — 공백/대소문자까지만 정규화한다."""
|
|
result = pick_match("하조대 펜션", [_place("하조대펜션", "111")])
|
|
assert result.outcome == MatchOutcome.MATCHED
|
|
|
|
|
|
def test_pick_match_duplicate_names_is_ambiguous():
|
|
"""검증: ★ 상호명이 같은 업소가 2건이다(동명 업소).
|
|
기대결과: AMBIGUOUS — 억지로 하나 고르면 남의 가게 정보가 섞인다. 사람이 주소로 골라야 한다."""
|
|
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 '하조대펜션 별관').
|
|
기대결과: ★ AMBIGUOUS — 부분일치만으로 확정하지 않는다. 별관은 다른 가게일 수 있다."""
|
|
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건만 일치한다.
|
|
기대결과: MATCHED — 전화번호가 가장 강한 근거다."""
|
|
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건).
|
|
기대결과: AMBIGUOUS — 전화번호가 안 맞았다고 아무거나 고르지 않는다."""
|
|
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건이다(지점 등록).
|
|
기대결과: MATCHED — 전화번호로 좁힌 뒤 상호명으로 확정하고, 근거에 좁힌 사실이 남는다."""
|
|
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():
|
|
"""검증: 상호명이 정확히 맞는 후보 없이 비슷한 이름만 여럿이다.
|
|
기대결과: ★ AMBIGUOUS — 이 서비스에서 자동 판정으로 넘어가면 안 되는 구간이다."""
|
|
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():
|
|
"""검증: 상호명으로 검색해 동일 업소까지 한 번에 판정한다.
|
|
기대결과: 키워드 검색 1회로 MATCHED. 반환된 place 에 카카오 장소 ID 가 실린다."""
|
|
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건이다.
|
|
기대결과: AMBIGUOUS + 후보 목록 — 호출측이 PLACE_VERIFY_AMBIGUOUS 로 사람에게 넘긴다."""
|
|
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 가 비어 있다.
|
|
기대결과: KakaoNotConfigured — 이 어댑터만 비활성이고 서버 부팅은 막지 않는다."""
|
|
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():
|
|
"""검증: 키가 있지만 잘못됐다(401).
|
|
기대결과: KakaoNotConfigured — 설정 문제라 재시도해도 소용없다."""
|
|
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 를 돌려준다.
|
|
기대결과: KakaoRequestFailed — ★ 빈 결과로 둔갑시키지 않는다(직전 값 유지는 호출측 책임)."""
|
|
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():
|
|
"""검증: 요청이 타임아웃된다.
|
|
기대결과: KakaoRequestFailed — 도메인 예외로 감싸 호출측이 LOCAL_FETCH_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 이 아니다.
|
|
기대결과: KakaoRequestFailed — 파싱 실패가 조용히 빈 결과가 되지 않는다."""
|
|
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():
|
|
"""검증: 호출할 때마다 누적 카운터가 오른다.
|
|
기대결과: 생성 1건당 검색 횟수를 셀 수 있다(초과 시 키워드 2원 / 좌표변환 0.5원)."""
|
|
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()
|