o2o-site-AEO/solution/backend/tests/test_kakao_webhook.py
hbyang 1300652b09 [feat] solution/backend: 카톡 대화에 홈페이지 목록·가게 고르기
실제로 붙여 보니 빠진 것이 드러났다 — 연결은 됐는데 **어느 홈페이지를 다루는
대화인지** 말해 주지 않았다. 가게가 하나면 말없이 자동 선택돼 더 모호했다.

- 연결 직후 목록을 보여준다. 하나면 이름+발행 여부를, 여럿이면 바로가기 버튼으로
- 목록 줄에 발행 여부를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다
- "목록"·"가게 바꿔줘" 로 언제든 돌아와 바꾼다. ★ 이 경로는 LLM 을 부르지 않는다:
  대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록에 돈을 쓸 이유가 없다
- 사업장 목록이 아니라 list_my_sites 를 쓴다 — 사장님이 알아야 하는 건
  "가게가 있다" 가 아니라 "발행돼 있나" 다(/sites 화면이 같은 이유로 그걸 쓴다)

test_kakao_webhook.py 21 passed(목록·전환 4건 추가).
전체 845 passed / 53 failed — 53 은 이번 변경 전과 동일

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:26:40 +09:00

321 lines
15 KiB
Python

"""카카오톡 채널 웹훅.
여기서 지키는 것 셋:
1. 시크릿 없는 요청은 아무것도 하지 못한다 — 오픈빌더가 서명을 주지 않으므로 이게 유일한 문이다
2. 연결되지 않은 발화자는 어떤 사장님도 되지 못한다
3. 확인(SEMI)은 **만료되면 안 먹는다** — 한참 뒤의 "네" 한 마디에 묵은 발행이 돌면 안 된다
"""
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import text
from services import kakao_link_service as link_service
from services.agent import channel, runtime
SECRET = "test-webhook-secret-0123456789"
PATH = "/v1/agent/kakao/webhook"
@pytest.fixture(autouse=True)
def secret(monkeypatch):
monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", SECRET)
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
monkeypatch.setenv("AGENT_CHAT_ENABLED", "1")
monkeypatch.delenv("KAKAO_BOT_ID", raising=False)
def body(utterance, speaker="kakao-speaker-1", bot_id="bot-1"):
return {
"userRequest": {"utterance": utterance, "user": {"id": speaker, "type": "botUserKey"}},
"bot": {"id": bot_id, "name": "ADO2"},
}
def said(res) -> str:
return res.json()["template"]["outputs"][0]["simpleText"]["text"]
def quick(res) -> list:
return [q["label"] for q in res.json()["template"].get("quickReplies", [])]
async def owner_with_place(client, auth_headers, name="대화숙소"):
h = await auth_headers(f"kakao-{uuid.uuid4().hex[:8]}")
res = await client.post("/v1/place", headers=h, json={"name": name, "category": 1})
return h, res.json()["place"]["place_id"]
async def user_id_of(db_engine, place_id):
async with db_engine.begin() as c:
return (
await c.execute(text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": uuid.UUID(place_id)})
).scalar_one()
async def link(db_engine, client, auth_headers, speaker, name="대화숙소"):
"""사장님 하나 + 가게 하나 + 그 사장님에 묶인 카톡 발화자."""
h, pid = await owner_with_place(client, auth_headers, name)
uid = await user_id_of(db_engine, pid)
await link_service.redeem((await link_service.issue_code(uid))["code"], speaker)
return h, pid, uid
# ── 1. 시크릿이 유일한 문이다 ────────────────────────────────────────────
async def test_시크릿이_없으면_존재를_알리지_않는다(client, monkeypatch):
"""★ 401 이 아니라 404 다. 401 은 '여기 뭔가 있다' 를 알려 준다."""
monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", "")
assert (await client.post(PATH, json=body("안녕"))).status_code == 404
async def test_틀린_시크릿도_404(client):
res = await client.post(PATH, headers={"X-Agent-Secret": "wrong-secret"}, json=body("안녕"))
assert res.status_code == 404
res = await client.post(PATH, json=body("안녕")) # 헤더 없음
assert res.status_code == 404
async def test_경로_시크릿도_받는다(client, db_engine):
res = await client.post(f"{PATH}/{SECRET}", json=body("안녕"))
assert res.status_code == 200
res = await client.post(f"{PATH}/wrong-secret", json=body("안녕"))
assert res.status_code == 404
async def test_다른_봇의_요청은_거절한다(client, monkeypatch, db_engine):
monkeypatch.setenv("KAKAO_BOT_ID", "bot-1")
ok = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="bot-1"))
assert ok.status_code == 200
bad = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="남의봇"))
assert bad.status_code == 404
# ── 2. 연결되지 않은 발화자 ──────────────────────────────────────────────
async def test_연결_전에는_어떤_도구도_돌지_않는다(client, monkeypatch, db_engine):
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("체크인 3시로 바꿔줘", "모르는-키"))
assert res.status_code == 200
assert "연결" in said(res)
called.assert_not_awaited()
async def test_발화자_id_를_위조해도_남의_가게에_닿지_않는다(client, auth_headers, db_engine, monkeypatch):
"""★ 신원 연결이 없으면 카톡 진입점만 소유자 범위 밖에 놓인다 — 그걸 막는 자리다."""
await link(db_engine, client, auth_headers, "진짜-사장님-키")
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", "위조한-키"))
assert "연결" in said(res)
called.assert_not_awaited()
async def test_코드를_보내면_연결된다(client, auth_headers, db_engine):
h, pid = await owner_with_place(client, auth_headers)
uid = await user_id_of(db_engine, pid)
code = (await link_service.issue_code(uid))["code"]
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "새-발화자"))
assert "연결됐습니다" in said(res)
assert await link_service.resolve("새-발화자") == uid
async def test_틀린_코드는_이유를_구분해_말하지_않는다(client, db_engine):
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("ZZZZZZ", "새-발화자2"))
assert "코드가 맞지 않거나" in said(res)
# ── 3. 확인은 만료되면 안 먹는다 ─────────────────────────────────────────
async def test_발행은_묻고_바로가기를_준다(client, auth_headers, db_engine, monkeypatch):
speaker = "확인-테스트-키"
await link(db_engine, client, auth_headers, speaker)
# 테스트는 실제 모델을 부르지 않는다 — 런타임만 열고 선택 결과를 대신 준다.
monkeypatch.setattr(runtime, "is_configured", lambda: True)
monkeypatch.setattr(runtime, "_choose", AsyncMock(return_value={"tool": "publish", "args": {}, "message": ""}))
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("발행해줘", speaker))
assert channel.CONFIRM_LABEL in quick(res)
async with db_engine.begin() as c:
pending = (
await c.execute(text("SELECT pending_tool FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker})
).scalar_one()
assert pending == "publish"
async def test_만료된_확인에_네_라고_해도_실행되지_않는다(client, auth_headers, db_engine, monkeypatch):
"""★ 이게 없으면 한참 뒤의 '네' 한 마디에 **묵은 발행**이 돈다."""
speaker = "만료-테스트-키"
await link(db_engine, client, auth_headers, speaker)
async with db_engine.begin() as c:
await c.execute(
text("""UPDATE owner_kakao_links
SET pending_tool='publish', pending_args='{}'::jsonb,
pending_expires_at = now() - interval '1 minute'
WHERE channel_user_key=:k"""),
{"k": speaker},
)
ran = AsyncMock()
monkeypatch.setattr(runtime, "chat", ran)
await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker))
# 확인으로 실행된 적이 없다(다른 말로 취급돼 일반 경로로 갔을 수는 있다).
for call in ran.await_args_list:
assert call.kwargs.get("confirm") is None
async def test_바로가기_라벨은_예로_읽히는_말에_들어_있다():
"""★ 라벨과 _YES 가 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다."""
assert channel.CONFIRM_LABEL in channel._YES
assert channel.PUBLISH_LABEL in channel._YES
assert channel.DECLINE_LABEL in channel._NO
# ── 가게 고르기 ──────────────────────────────────────────────────────────
async def test_가게가_여럿이면_추측하지_않고_되묻는다(client, auth_headers, db_engine, monkeypatch):
"""★ 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다."""
speaker = "다가게-키"
h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게")
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", speaker))
assert "어느 가게" in said(res)
assert set(quick(res)) == {"첫째가게", "둘째가게"}
called.assert_not_awaited()
# 고르면 기억한다.
picked = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("둘째가게", speaker))
assert "둘째가게" in said(picked)
async with db_engine.begin() as c:
current = (
await c.execute(
text("SELECT current_place_id FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker}
)
).scalar_one()
assert current is not None
# ── 5초 벽 · 실패 ────────────────────────────────────────────────────────
async def test_느리면_침묵_대신_안내로_끊는다(client, monkeypatch, db_engine):
"""넘기면 카카오가 연결을 끊는다 — 사장님에게는 말없이 실패하는 봇이 된다."""
import router.v1.agent.kakao_bot as bot
async def slow(*_a, **_kw):
import asyncio
await asyncio.sleep(1)
monkeypatch.setattr(bot, "DEADLINE_SEC", 0.01)
monkeypatch.setattr(bot.channel, "handle", slow)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕"))
assert res.status_code == 200
assert "잠시 뒤" in said(res)
async def test_내부_오류도_200_으로_답한다(client, monkeypatch, db_engine):
"""메신저에서는 500 도 침묵으로 보인다 — 무슨 일이 있었는지 한 줄은 말해야 한다."""
import router.v1.agent.kakao_bot as bot
monkeypatch.setattr(bot.channel, "handle", AsyncMock(side_effect=RuntimeError("어딘가 터짐")))
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕"))
assert res.status_code == 200
assert "어딘가 터짐" not in res.text
async def test_발화자가_없으면_거기서_끝낸다(client, db_engine):
res = await client.post(
PATH, headers={"X-Agent-Secret": SECRET}, json={"userRequest": {"utterance": "안녕", "user": {}}}
)
assert res.status_code == 200
assert "확인하지 못했" in said(res)
# ── 응답 형식 ────────────────────────────────────────────────────────────
def test_카카오_형식은_이_파일_밖으로_나가지_않는다():
"""★ 서비스 계층에 새면 다른 채널을 붙일 때 전부 걷어내야 한다."""
import ast
import inspect
# ★ 주석·docstring 에 이름이 나오는 것은 '샌' 것이 아니다 — 실제 코드만 본다.
tree = ast.parse(inspect.getsource(channel))
for node in ast.walk(tree):
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
node.value.value = "" # docstring 비우기
dumped = ast.dump(tree)
for token in ("simpleText", "quickReplies", "userRequest", "2.0"):
assert token not in dumped, token
def test_바로가기는_열_개를_넘기지_않는다():
import router.v1.agent.kakao_bot as bot
out = bot._reply("안녕", [f"라벨{i}" for i in range(20)])
assert len(out["template"]["quickReplies"]) == 10
assert out["version"] == "2.0"
assert out["template"]["outputs"][0]["simpleText"]["text"] == "안녕"
# ── 사이트 목록 · 고르기 ─────────────────────────────────────────────────
async def test_연결되자마자_홈페이지_목록을_알려준다(client, auth_headers, db_engine):
"""★ 연결만 알리고 끝내면 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 말을 건다."""
h, pid = await owner_with_place(client, auth_headers, "첫째가게")
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
uid = await user_id_of(db_engine, pid)
code = (await link_service.issue_code(uid))["code"]
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "목록-발화자"))
text_out = said(res)
assert "연결됐습니다" in text_out
assert "첫째가게" in text_out and "둘째가게" in text_out
# 바로 고를 수 있어야 한다 — 이름을 외워 치게 하지 않는다.
assert set(quick(res)) == {"첫째가게", "둘째가게"}
async def test_가게가_하나면_그_이름을_말해_준다(client, auth_headers, db_engine):
h, pid = await owner_with_place(client, auth_headers, "혼자가게")
uid = await user_id_of(db_engine, pid)
code = (await link_service.issue_code(uid))["code"]
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "하나-발화자"))
assert "혼자가게" in said(res)
assert "발행" in said(res) # 발행 여부를 같이 말한다
async def test_목록이라고_하면_언제든_다시_보여주고_가게를_바꿀_수_있다(client, auth_headers, db_engine, monkeypatch):
"""★ 대화가 막혔을 때 사장님이 처음 찾는 길이다. LLM 을 부르지 않는다."""
speaker = "전환-발화자"
h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게")
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
# 먼저 한 곳을 고른다.
await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("첫째가게", speaker))
# 목록으로 돌아온다.
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("가게 바꿔줘", speaker))
assert "첫째가게" in said(res) and "둘째가게" in said(res)
assert set(quick(res)) == {"첫째가게", "둘째가게"}
called.assert_not_awaited() # 목록 보기에 모델을 부르지 않는다
# 다른 곳으로 바꾼다.
picked = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("둘째가게", speaker))
assert "둘째가게" in said(picked)
async def test_목록은_발행_여부를_같이_말한다(client, auth_headers, db_engine):
"""★ 안 그러면 사장님은 고친 것이 손님에게 보이는 줄 안다."""
speaker = "발행표시-발화자"
await link(db_engine, client, auth_headers, speaker, "미발행가게")
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker))
assert "아직 발행 전" in said(res)