"""카카오 로그인 — 인가 코드를 서버가 교환하고 회원번호를 꺼낸다. ★ 회원번호(`id`)가 챗봇 웹훅의 `appUserId` 와 같은 값이라는 것이 이 모듈의 존재 이유다. 그 값을 문자열로 유지하는지(정수로 흘리면 매핑 조회가 조용히 빗나간다)를 여기서 못 박는다. """ import httpx import pytest from config import config_models from config.server_configs import kakao_login_config from services.external import kakao_identity from services.external.kakao_identity import KakaoNotConfigured, KakaoTokenInvalid pytestmark = pytest.mark.asyncio REST_KEY = "test-rest-key" SECRET = "test-secret" REDIRECT = "https://example.test/auth/kakao/callback" @pytest.fixture(autouse=True) def _configured(monkeypatch): monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", REST_KEY) monkeypatch.setenv("KAKAO_LOGIN_CLIENT_SECRET", SECRET) monkeypatch.setenv("KAKAO_LOGIN_REDIRECT_URI", REDIRECT) config_models.get_kakao_login_config.cache_clear() fresh = config_models.get_kakao_login_config() # 모듈이 import 시점에 잡아 둔 싱글턴을 본다 — 그 객체를 갈아 끼운다. monkeypatch.setattr(kakao_identity, "kakao_login_config", fresh) yield config_models.get_kakao_login_config.cache_clear() def _client(handler) -> httpx.AsyncClient: return httpx.AsyncClient(transport=httpx.MockTransport(handler)) async def test_인가코드를_시크릿과_함께_교환한다(): seen = {} def handler(request: httpx.Request) -> httpx.Response: seen["url"] = str(request.url) seen["form"] = dict(httpx.QueryParams(request.content.decode())) return httpx.Response(200, json={"access_token": "at-1"}) async with _client(handler) as client: token = await kakao_identity.exchange_code("code-1", client=client) assert token == "at-1" assert seen["url"] == "https://kauth.kakao.com/oauth/token" assert seen["form"] == { "grant_type": "authorization_code", "client_id": REST_KEY, "client_secret": SECRET, "redirect_uri": REDIRECT, "code": "code-1", } async def test_호출부가_준_리다이렉트가_설정값을_이긴다(): """로컬처럼 주소가 다른 환경 때문에 열어 둔 구멍이다 — 실제로 그 값이 나가는지 본다.""" seen = {} def handler(request: httpx.Request) -> httpx.Response: seen["form"] = dict(httpx.QueryParams(request.content.decode())) return httpx.Response(200, json={"access_token": "at-2"}) async with _client(handler) as client: await kakao_identity.exchange_code("code-2", "http://localhost/cb", client=client) assert seen["form"]["redirect_uri"] == "http://localhost/cb" async def test_교환_거절은_KakaoTokenInvalid(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"error": "invalid_grant", "error_code": "KOE320"}) async with _client(handler) as client: with pytest.raises(KakaoTokenInvalid): await kakao_identity.exchange_code("used-code", client=client) async def test_설정이_비면_호출하지_않고_KakaoNotConfigured(monkeypatch): """키가 없으면 카카오 로그인만 꺼진다 — 다른 외부 어댑터와 같은 규칙이다.""" monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", "") config_models.get_kakao_login_config.cache_clear() monkeypatch.setattr(kakao_identity, "kakao_login_config", config_models.get_kakao_login_config()) def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover - 불리면 실패다 raise AssertionError("설정이 없는데 카카오를 호출했다") async with _client(handler) as client: with pytest.raises(KakaoNotConfigured): await kakao_identity.exchange_code("code", client=client) async def test_회원번호는_문자열이다(): """★ 카카오는 `id` 를 **정수**로 준다. 챗봇의 appUserId 는 문자열이라, 여기서 문자열로 맞춰 두지 않으면 `link_by_app_user_id` 조회가 조용히 빗나간다.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={ "id": 1234567890, "kakao_account": {"email": "owner@example.test", "profile": {"nickname": "사장님"}}, }) async with _client(handler) as client: account = await kakao_identity.fetch_account("at-1", client=client) assert account.uid == "1234567890" assert account.email == "owner@example.test" assert account.name == "사장님" async def test_이메일_이름이_없어도_신원은_성립한다(): """동의 항목이라 비어 올 수 있다. 판정 키는 회원번호뿐이다.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": 7, "kakao_account": {}}) async with _client(handler) as client: account = await kakao_identity.fetch_account("at-1", client=client) assert (account.uid, account.email, account.name) == ("7", "", "") @pytest.mark.parametrize("flag", ["is_email_valid", "is_email_verified"]) async def test_미인증_이메일은_신원으로_쓰지_않는다(flag): """★ auth_service 가 이 값으로 다른 수단 가입과의 충돌을 판정한다 — 확인 안 된 주소가 들어가면 남의 계정을 막거나 가로채는 데 쓰일 수 있다.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={ "id": 8, "kakao_account": {"email": "someone@example.test", flag: False}, }) async with _client(handler) as client: account = await kakao_identity.fetch_account("at-1", client=client) assert account.email == "" assert account.uid == "8"