"""카카오톡 채널 웹훅.""" 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 다.""" 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={"actions": [{"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): """대화가 막혔을 때 사장님이 처음 찾는 길이다.""" 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) # ── 콜백 (5초 벽 넘기) ─────────────────────────────────────────────────── async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든다(client, auth_headers, db_engine, monkeypatch): """오픈빌더 스킬 타임아웃은 5초다.""" import router.v1.agent.kakao_bot as bot sent = {} async def fake_push(callback_url, utterance, speaker, app_user_id=""): sent["url"] = callback_url sent["payload"] = await bot._answer(utterance, speaker, 5.0) monkeypatch.setattr(bot, "_push", fake_push) payload = body("안녕", "콜백-발화자") payload["userRequest"]["callbackUrl"] = "https://callback.example/one-shot" res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=payload) # 즉답은 useCallback 이다 — template 을 싣지 않는다. assert res.json()["useCallback"] is True assert res.json()["data"]["text"] assert "template" not in res.json() # 실제 답은 콜백으로 간다. assert sent["url"] == "https://callback.example/one-shot" assert "연결" in sent["payload"]["template"]["outputs"][0]["simpleText"]["text"] async def test_콜백이_없으면_예전처럼_동기로_답한다(client, db_engine): res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", "동기-발화자")) assert "useCallback" not in res.json() assert res.json()["template"]["outputs"] async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch): """주소는 1분 · 1회다.""" import router.v1.agent.kakao_bot as bot monkeypatch.setattr(bot, "_answer", AsyncMock(return_value=bot._reply("답"))) class Boom: async def __aenter__(self): raise RuntimeError("네트워크 끊김") async def __aexit__(self, *_): return False monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom()) await bot._push("https://callback.example/x", "안녕", "누구") # 예외가 새 나오지 않는다 # ── 여러 가게 ──────────────────────────────────────────────────────────── 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) await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("첫째가게", speaker)) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("둘째가게 체크인 3시로 바꿔줘", speaker)) assert "지금은 '첫째가게'" in said(res) assert "둘째가게" in quick(res) called.assert_not_awaited() 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}) async with db_engine.begin() as c: await c.execute( text("UPDATE owner_kakao_links SET current_place_id=:g WHERE channel_user_key=:k"), {"g": uuid.uuid4(), "k": speaker}, ) called = AsyncMock() monkeypatch.setattr(runtime, "chat", called) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", speaker)) assert "어느 가게" in said(res) called.assert_not_awaited() 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', current_place_id=:g WHERE channel_user_key=:k"""), {"g": uuid.uuid4(), "k": speaker}, ) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker)) assert said(res) == channel._ERRORS["PLACE_NOT_FOUND"] # ── 승인 알림(Event API 로 시작된 스킬 요청) ───────────────────────────── # ★ 이 자리가 지키는 것: 글 본문은 **연결된 본인의 글일 때만** 나간다. 이벤트 요청의 # params 는 카카오가 채우지만, 발화자 키로 사장님을 찾고 그 글이 그 사장님 가게 것인지 # 서버가 다시 본다 — 글 ID 만 알면 남의 글이 보이는 구멍이 되면 안 된다. POST_BODY = "비가 그친 뒤 마당 돌이 촉촉합니다. 대청마루에 앉아 빗소리를 들어 보세요." def event_body(post_id, edit_token="EDITCODE1234", speaker="kakao-speaker-1"): payload = body("", speaker) payload["userRequest"]["params"] = {"post_id": str(post_id)} if edit_token: payload["userRequest"]["params"]["edit_token"] = edit_token return payload async def seed_post(db_engine, place_id, status=3, expires=None): """status 3 = SENT. expires 는 tz 를 단 UTC 로 넣는다(raw text() 로 timestamptz 를 바인딩할 때의 함정).""" post_id = uuid.uuid4() async with db_engine.begin() as c: await c.execute( text("INSERT INTO place_posts (post_id, place_id, body, topic_kind, topic_key, status, token_expires_at) " "VALUES (:id, :pid, :body, 1, :key, :st, :exp)"), {"id": post_id, "pid": uuid.UUID(str(place_id)), "body": POST_BODY, "key": f"weather:{post_id.hex[:6]}", "st": status, "exp": expires}, ) return post_id def outputs(res): return res.json()["template"]["outputs"] async def test_승인_알림은_본인_글이면_본문과_수정_링크를_그린다(client, auth_headers, db_engine): h, pid, uid = await link(db_engine, client, auth_headers, "승인-발화자", "알림숙소") post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="승인-발화자")) assert res.status_code == 200 first = outputs(res)[0]["simpleText"]["text"] assert "알림숙소" in first and POST_BODY in first button = outputs(res)[1]["textCard"]["buttons"][0] assert button["label"] == "수정하기" and button["action"] == "webLink" assert button["webLinkUrl"].endswith("/v1/site/post/edit?t=EDITCODE1234") async def test_승인_알림은_수정_코드가_없으면_버튼_없이_본문만_준다(client, auth_headers, db_engine): h, pid, uid = await link(db_engine, client, auth_headers, "코드없는-발화자") post_id = await seed_post(db_engine, pid) res = await client.post( PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, edit_token=None, speaker="코드없는-발화자") ) assert POST_BODY in outputs(res)[0]["simpleText"]["text"] assert len(outputs(res)) == 1 async def test_남의_글은_본문을_주지_않는다(client, auth_headers, db_engine): """★ 글 ID 를 알아도 그 글의 주인에게 연결된 발화자가 아니면 아무것도 안 나온다.""" _h1, pid1, _uid1 = await link(db_engine, client, auth_headers, "주인-발화자", "주인숙소") await link(db_engine, client, auth_headers, "남-발화자", "남의숙소") post_id = await seed_post(db_engine, pid1) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="남-발화자")) assert res.status_code == 200 assert POST_BODY not in res.text and "주인숙소" not in res.text assert "EDITCODE1234" not in res.text async def test_연결되지_않은_발화자에게는_본문을_주지_않는다(client, auth_headers, db_engine): _h, pid, _uid = await link(db_engine, client, auth_headers, "연결된-발화자") post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="모르는-발화자")) assert res.status_code == 200 assert POST_BODY not in res.text and "EDITCODE1234" not in res.text async def test_이미_처리했거나_기한이_지난_글은_안내로_끝난다(client, auth_headers, db_engine): from datetime import datetime, timedelta, timezone h, pid, uid = await link(db_engine, client, auth_headers, "지난-발화자") approved = await seed_post(db_engine, pid, status=4) expired = await seed_post(db_engine, pid, expires=datetime.now(timezone.utc) - timedelta(hours=1)) for post_id in (approved, expired): res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="지난-발화자")) assert res.status_code == 200 assert POST_BODY not in res.text assert "기한" in said(res) or "처리" in said(res) async def test_글_id_가_이상해도_터지지_않는다(client, auth_headers, db_engine): await link(db_engine, client, auth_headers, "이상한-발화자") res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body("not-a-uuid", speaker="이상한-발화자")) assert res.status_code == 200 assert said(res) # ── [승인] 버튼 ────────────────────────────────────────────────────────── # ★ 승인 권한은 링크가 아니라 **연결된 계정**이다. 버튼이 들고 오는 post_id 는 믿지 않고, # 누른 발화자 → 사장님 → 그 글이 그 사장님 가게의 미처리·기한 전 글인지를 서버가 다시 본다. # 틀리면 아무것도 승인되지 않고, 어느 쪽이 틀렸는지는 구분해 답하지 않는다. APPROVE_BLOCK = "6abb6a319e1c4d176fa81ff4" BUILD = 4 # JobType.BUILD def click_body(post_id, speaker, kind="approve"): """[승인] 버튼(action: block)을 누르면 다음 스킬 요청의 action.clientExtra 로 extra 가 돌아온다.""" payload = body("승인", speaker) payload["action"] = {"clientExtra": {"kind": kind, "post_id": str(post_id)}} return payload async def status_of(db_engine, post_id): async with db_engine.begin() as c: return (await c.execute(text("SELECT status FROM place_posts WHERE post_id=:p"), {"p": post_id})).scalar_one() async def build_jobs(db_engine, place_id): async with db_engine.begin() as c: return ( await c.execute( text("SELECT count(*) FROM jobs WHERE job_type=:t AND payload->>'place_id'=:p"), {"t": BUILD, "p": str(place_id)}, ) ).scalar_one() async def publish_site(db_engine, place_id, domain="approve-click-test"): async with db_engine.begin() as c: await c.execute( text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, 3)"), {"sid": uuid.uuid4(), "pid": uuid.UUID(str(place_id)), "dom": domain}, ) def card_buttons(res): return outputs(res)[1]["textCard"]["buttons"] async def test_승인_알림에_블록_id_가_있으면_승인_버튼을_그린다(client, auth_headers, db_engine, monkeypatch): monkeypatch.setenv("KAKAO_APPROVE_BLOCK_ID", APPROVE_BLOCK) h, pid, uid = await link(db_engine, client, auth_headers, "버튼-발화자", "버튼숙소") post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="버튼-발화자")) buttons = card_buttons(res) approve = next(b for b in buttons if b["label"] == "승인") assert approve["action"] == "block" and approve["blockId"] == APPROVE_BLOCK assert approve["extra"] == {"kind": "approve", "post_id": str(post_id)} assert any(b["label"] == "수정하기" and b["action"] == "webLink" for b in buttons) async def test_블록_id_가_없으면_죽은_승인_버튼을_그리지_않는다(client, auth_headers, db_engine, monkeypatch): """콘솔 준비 전에 켜도 눌러도 안 되는 버튼이 사장님 카톡에 나가지 않는다.""" monkeypatch.setenv("KAKAO_APPROVE_BLOCK_ID", "") h, pid, uid = await link(db_engine, client, auth_headers, "버튼없는-발화자") post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="버튼없는-발화자")) assert [b["label"] for b in card_buttons(res)] == ["수정하기"] async def test_승인_버튼을_누르면_올라가고_사이트_보기를_준다(client, auth_headers, db_engine): h, pid, uid = await link(db_engine, client, auth_headers, "승인-클릭자", "클릭숙소") await publish_site(db_engine, pid) post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "승인-클릭자")) assert res.status_code == 200 assert "올렸습니다" in said(res) assert await status_of(db_engine, post_id) == 4 # APPROVED assert await build_jobs(db_engine, pid) == 1 button = card_buttons(res)[0] assert button["label"] == "사이트 보기" and button["action"] == "webLink" assert button["webLinkUrl"].endswith("/s/approve-click-test#blog") async def test_발행된_사이트가_없어도_승인은_되고_링크만_빠진다(client, auth_headers, db_engine): h, pid, uid = await link(db_engine, client, auth_headers, "사이트없는-클릭자") post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "사이트없는-클릭자")) assert "올렸습니다" in said(res) assert await status_of(db_engine, post_id) == 4 assert len(outputs(res)) == 1 async def test_남의_글의_승인_버튼은_아무것도_승인하지_않는다(client, auth_headers, db_engine): """★ post_id 를 알아도 그 글 주인에게 연결된 발화자가 아니면 승인되지 않는다.""" _h1, pid1, _uid1 = await link(db_engine, client, auth_headers, "글주인-발화자", "글주인숙소") await link(db_engine, client, auth_headers, "다른사람-발화자", "다른숙소") post_id = await seed_post(db_engine, pid1) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "다른사람-발화자")) assert res.status_code == 200 assert "올렸습니다" not in said(res) assert await status_of(db_engine, post_id) == 3 # 그대로 SENT assert await build_jobs(db_engine, pid1) == 0 async def test_연결되지_않은_발화자의_승인_버튼은_먹지_않는다(client, auth_headers, db_engine): _h, pid, _uid = await link(db_engine, client, auth_headers, "연결된-사장님") post_id = await seed_post(db_engine, pid) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "모르는-클릭자")) assert "올렸습니다" not in said(res) assert await status_of(db_engine, post_id) == 3 assert await build_jobs(db_engine, pid) == 0 async def test_이미_승인한_글을_또_눌러도_두_번_올라가지_않는다(client, auth_headers, db_engine): h, pid, uid = await link(db_engine, client, auth_headers, "두번-클릭자") post_id = await seed_post(db_engine, pid) first = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "두번-클릭자")) second = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "두번-클릭자")) assert "올렸습니다" in said(first) assert "올렸습니다" not in said(second) assert await build_jobs(db_engine, pid) == 1 async def test_기한이_지난_글의_승인_버튼은_먹지_않는다(client, auth_headers, db_engine): from datetime import datetime, timedelta, timezone h, pid, uid = await link(db_engine, client, auth_headers, "만료-클릭자") post_id = await seed_post(db_engine, pid, expires=datetime.now(timezone.utc) - timedelta(hours=1)) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "만료-클릭자")) assert "올렸습니다" not in said(res) assert await status_of(db_engine, post_id) == 3 async def test_승인_클릭은_모델을_부르지_않는다(client, auth_headers, db_engine, monkeypatch): """버튼 처리는 결정적이어야 한다 — 유료 호출도 없고, 모델이 다른 도구를 고를 여지도 없다.""" h, pid, uid = await link(db_engine, client, auth_headers, "모델없는-클릭자") post_id = await seed_post(db_engine, pid) called = AsyncMock() monkeypatch.setattr(runtime, "chat", called) await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "모델없는-클릭자")) called.assert_not_awaited() async def test_모르는_종류나_이상한_글_id_의_클릭은_터지지_않는다(client, auth_headers, db_engine): await link(db_engine, client, auth_headers, "이상한-클릭자") bad_id = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body("not-a-uuid", "이상한-클릭자")) unknown = await client.post( PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(uuid.uuid4(), "이상한-클릭자", kind="delete") ) assert bad_id.status_code == 200 and said(bad_id) assert "올렸습니다" not in said(bad_id) assert unknown.status_code == 200