"""카카오 챗봇 Event API — 연결된 사장님에게 챗봇이 먼저 말을 거는 통로. 여기서 지키는 것 셋: 1. 요청 모양(주소·인증 헤더·본문)이 공식 규격 그대로다 — 어긋나면 카카오가 조용히 거절한다 2. 실패는 예외로 올라온다 — 알림이 안 간 것을 성공으로 넘기면 승인 알림이 통째로 사라진다 3. 예외 문구에 키·발화자 ID 가 없다 — 이 문자열은 로그로 가고 로그는 우리만 보지 않는다 """ import json import httpx import pytest from services.external import kakao_event BOT_ID = "0123456789abcdef01234567" REST_KEY = "test-rest-api-key-0123456789" SPEAKER = "test-bot-user-key-abc" @pytest.fixture(autouse=True) def configured(monkeypatch): monkeypatch.setenv("KAKAO_BOT_ID", BOT_ID) monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", REST_KEY) monkeypatch.delenv("KAKAO_EVENT_DEV", raising=False) def _client(handler) -> httpx.AsyncClient: return httpx.AsyncClient(transport=httpx.MockTransport(handler)) def test_not_configured_without_bot_id_or_rest_key(monkeypatch): assert kakao_event.is_configured() is True monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", "") assert kakao_event.is_configured() is False monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", REST_KEY) monkeypatch.setenv("KAKAO_BOT_ID", "") assert kakao_event.is_configured() is False async def test_send_posts_the_documented_request_shape(): seen = {} def handler(request: httpx.Request) -> httpx.Response: seen["url"] = str(request.url) seen["auth"] = request.headers["Authorization"] seen["body"] = json.loads(request.content) return httpx.Response(200, json={"taskId": "task-1", "status": "SUCCESS", "message": ""}) async with _client(handler) as client: task_id = await kakao_event.send( SPEAKER, "post_approval", data={"text": "hello"}, params={"post_id": "p-1"}, client=client ) assert task_id == "task-1" assert seen["url"] == f"https://bot-api.kakao.com/v2/bots/{BOT_ID}/talk" assert seen["auth"] == f"KakaoAK {REST_KEY}" assert seen["body"] == { "event": {"name": "post_approval", "data": {"text": "hello"}}, "user": [{"type": "botUserKey", "id": SPEAKER}], "params": {"post_id": "p-1"}, } async def test_send_omits_data_and_params_when_not_given(): seen = {} def handler(request: httpx.Request) -> httpx.Response: seen["body"] = json.loads(request.content) return httpx.Response(200, json={"taskId": "task-2", "status": "SUCCESS"}) async with _client(handler) as client: await kakao_event.send(SPEAKER, "post_approval", client=client) assert seen["body"]["event"] == {"name": "post_approval"} assert "params" not in seen["body"] async def test_dev_channel_appends_bang_to_bot_id(monkeypatch): monkeypatch.setenv("KAKAO_EVENT_DEV", "1") seen = {} def handler(request: httpx.Request) -> httpx.Response: seen["url"] = str(request.url) return httpx.Response(200, json={"taskId": "task-3", "status": "SUCCESS"}) async with _client(handler) as client: await kakao_event.send(SPEAKER, "post_approval", client=client) assert seen["url"] == f"https://bot-api.kakao.com/v2/bots/{BOT_ID}!/talk" async def test_http_error_raises_without_leaking_secrets(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"message": "invalid key"}) async with _client(handler) as client: with pytest.raises(kakao_event.KakaoEventError) as info: await kakao_event.send(SPEAKER, "post_approval", client=client) assert "401" in str(info.value) assert REST_KEY not in str(info.value) and SPEAKER not in str(info.value) # 진단용 원문은 str 이 아니라 속성에만 — 로그로 가는 건 str(ex) 뿐이다. assert "invalid key" in info.value.detail async def test_non_success_status_raises(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"taskId": "task-4", "status": "FAIL", "message": "no event"}) async with _client(handler) as client: with pytest.raises(kakao_event.KakaoEventError): await kakao_event.send(SPEAKER, "post_approval", client=client) async def test_send_refuses_when_not_configured(monkeypatch): monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", "") async with _client(lambda request: httpx.Response(200, json={})) as client: with pytest.raises(kakao_event.KakaoEventError, match="NOT_CONFIGURED"): await kakao_event.send(SPEAKER, "post_approval", client=client)