"""사장님 에이전트 런타임. 여기서 지키는 것 셋 — 나머지 검사는 전부 이 셋을 지탱한다. 1. 도구는 서비스 계층을 통과한다(게이트가 살아 있다) 2. 등급은 레지스트리가 정한다 — 모델이 확인 절차를 건너뛸 수 없다 3. 모호하면 실행하지 않고 되묻는다 """ import uuid from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from sqlalchemy import text from common.enums import PlaceCategory from services.agent import runtime, tools from services.agent.tools import ToolGrade from services.llm.errors import LlmError @pytest.fixture def choose(monkeypatch): """LLM 을 대신한다 — 테스트는 절대 실제 모델을 부르지 않는다.""" def _set(payload): monkeypatch.setattr(runtime, "_choose", AsyncMock(return_value=payload)) monkeypatch.setenv("AGENT_CHAT_ENABLED", "1") monkeypatch.setattr(runtime, "is_configured", lambda: True) return _set async def seed(client, auth_headers, name="대화숙소"): h = await auth_headers(f"agent-{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_of(client, headers, place_id): """라우터를 거치지 않고 런타임을 직접 부르기 위한 UserInfo.""" me = (await client.get("/v1/place", headers=headers)).json() del me from router.v1.validator.dependencies import decode_access_token token = headers["Authorization"].split(" ", 1)[1] return decode_access_token(token) # ── 1. 게이트가 살아 있다 ──────────────────────────────────────────────── def test_모든_도구는_서비스_계층을_통과한다(): """★ 도구가 crud 를 직접 부르면 스키마 검증·출처·정정본 보호가 조용히 사라진다. 소스에 `_crud.` 직접 호출이 없는지 본다 — 주석이 아니라 코드로 못 박는 자리다.""" import inspect source = inspect.getsource(tools) body = source[source.index("# ── 읽기"):source.index("class ToolRejected")] assert "fact_crud." not in body assert "place_crud." not in body assert "DB_SESSION_MNG" not in body def test_없는_항목은_스키마가_막는다(db_engine): schema_keys = {f["key"] for f in tools.fields_of(SimpleNamespace(category=PlaceCategory.LODGING.value))} assert "check_in_time" in schema_keys assert "고르곤졸라피자" not in schema_keys # ── 2. 등급은 레지스트리가 정한다 ──────────────────────────────────────── def test_등급은_프롬프트에_실리지_않는다(): """모델이 등급을 알면 그 값을 골라 보려 한다. 알 필요도, 정할 이유도 없다.""" described = tools.describe() assert described for row in described: assert "grade" not in row and "등급" not in row async def test_발행은_묻기_전에_실행되지_않는다(client, auth_headers, choose, db_engine): h, pid = await seed(client, auth_headers) choose({"tool": "publish", "args": {}, "message": ""}) started = AsyncMock() tools.REGISTRY["publish"].run, original = started, tools.REGISTRY["publish"].run try: res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "발행해줘"}) finally: tools.REGISTRY["publish"].run = original body = res.json() assert body["needs_confirm"] is True assert body["tool"] == "publish" # ★ 실행되지 않았다. 확인 문구만 돌아왔다. started.assert_not_awaited() async def test_모델이_확인을_건너뛰려_해도_소용없다(client, auth_headers, choose, db_engine): """응답에 needs_confirm 을 흉내 낼 칸을 주지 않았고, 등급은 레지스트리에서만 읽는다.""" h, pid = await seed(client, auth_headers) choose({"tool": "publish", "args": {}, "message": "", "needs_confirm": False, "grade": "READ"}) res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "그냥 바로 발행해"}) assert res.json()["needs_confirm"] is True async def test_확인_경로로_읽기_도구를_밀어넣을_수_없다(client, auth_headers, db_engine): h, pid = await seed(client, auth_headers) res = await client.post( f"/v1/agent/chat/{pid}", headers=h, json={"confirm": {"tool": "없는도구", "args": {}}} ) assert res.status_code == 409 assert res.json()["detail"] == "AGENT_UNKNOWN_TOOL" # ── 3. 모호하면 실행하지 않는다 ────────────────────────────────────────── async def test_도구를_못_고르면_되묻는다(client, auth_headers, choose, db_engine): h, pid = await seed(client, auth_headers) choose({"tool": "", "message": "어느 항목을 바꿀까요?"}) body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "그거 좀 고쳐줘"})).json() assert body["tool"] is None assert body["reply"] == "어느 항목을 바꿀까요?" assert body["needs_confirm"] is False async def test_모델이_지어낸_도구는_실행되지_않는다(client, auth_headers, choose, db_engine): h, pid = await seed(client, auth_headers) choose({"tool": "delete_everything", "args": {}, "message": ""}) body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "다 지워"})).json() assert body["tool"] is None async def test_없는_항목을_고르면_거절하고_이유를_말한다(client, auth_headers, choose, db_engine): h, pid = await seed(client, auth_headers) choose({"tool": "set_fact", "args": {"key": "메뉴명", "value": "고르곤졸라"}, "message": ""}) body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "메뉴명 바꿔줘"})).json() assert body.get("rejected") is True assert "고칠 수 없" in body["reply"] # ── 소유자 범위 ────────────────────────────────────────────────────────── async def test_남의_가게는_없는_것과_똑같이_답한다(client, auth_headers, choose, db_engine): """★ 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.""" _mine, pid = await seed(client, auth_headers, "내가게") other = await auth_headers("agent-outsider") choose({"tool": "list_facts", "args": {}, "message": ""}) res = await client.post(f"/v1/agent/chat/{pid}", headers=other, json={"message": "정보 보여줘"}) assert res.status_code == 404 assert res.json()["detail"] == "PLACE_NOT_FOUND" async def test_로그인_없이는_열리지_않는다(client): res = await client.post(f"/v1/agent/chat/{uuid.uuid4()}", json={"message": "안녕"}) assert res.status_code in (401, 403) # ── 실행 결과 문구 ─────────────────────────────────────────────────────── async def test_값을_바꾸면_재발행이_필요하다고_말한다(client, auth_headers, choose, db_engine): """★ 이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 본다.""" h, pid = await seed(client, auth_headers) choose({"tool": "set_fact", "args": {"key": "check_in_time", "value": "15:00"}, "message": ""}) body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "체크인 3시로"})).json() assert body.get("rejected") is not True, body["reply"] assert "체크인 시간" in body["reply"] assert "발행" in body["reply"] async with db_engine.begin() as c: stored = ( await c.execute( text("SELECT value FROM place_facts WHERE place_id=:p AND key='check_in_time' AND deleted=false"), {"p": uuid.UUID(pid)}, ) ).scalars().all() assert "15:00" in stored async def test_결과_문구는_모델이_쓰지_않는다(client, auth_headers, choose, db_engine): """모델이 결과를 쓰면 하지 않은 일을 했다고 말할 수 있다.""" h, pid = await seed(client, auth_headers) choose({ "tool": "set_fact", "args": {"key": "check_in_time", "value": "15:00"}, "message": "사이트까지 전부 반영을 끝냈습니다!", }) body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "체크인 3시로"})).json() assert "전부 반영을 끝냈습니다" not in body["reply"] # ── 실패 처리 ──────────────────────────────────────────────────────────── async def test_LLM_실패는_502_로_나가고_원문을_흘리지_않는다(client, auth_headers, monkeypatch, db_engine): h, pid = await seed(client, auth_headers) monkeypatch.setattr(runtime, "is_configured", lambda: True) monkeypatch.setattr(runtime, "_choose", AsyncMock(side_effect=LlmError("키가 sk-1234 라서 실패"))) res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"}) assert res.status_code == 502 assert res.json()["detail"] == "AGENT_CALL_FAILED" assert "sk-1234" not in res.text async def test_키가_없으면_대화창을_열지_않는다(client, auth_headers, monkeypatch, db_engine): h, pid = await seed(client, auth_headers) monkeypatch.setattr(runtime, "is_configured", lambda: False) res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"}) assert res.status_code == 409 assert res.json()["detail"] == "AGENT_NOT_CONFIGURED" assert (await client.get("/v1/agent/status", headers=h)).json()["enabled"] is False async def test_너무_긴_발화는_모델을_부르기_전에_끊는다(client, auth_headers, monkeypatch, db_engine): h, pid = await seed(client, auth_headers) called = AsyncMock() monkeypatch.setattr(runtime, "_choose", called) res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "가" * (runtime.MAX_MESSAGE + 1)}) assert res.status_code == 422 # pydantic 이 먼저 막는다 called.assert_not_awaited() def test_읽기_도구는_확인을_요구하지_않는다(): for name in ("get_site_status", "list_facts"): assert tools.REGISTRY[name].grade == ToolGrade.READ assert tools.REGISTRY["set_fact"].grade == ToolGrade.REVERSIBLE assert tools.REGISTRY["publish"].grade == ToolGrade.SEMI assert tools.REGISTRY["publish"].confirm # ── 보류 스위치 ───────────────────────────────────────────────────────── def test_스위치와_키를_둘_다_본다(monkeypatch): """★ 키만 보면 '잠시 닫아 두기' 를 키를 지워서 해야 하고, 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다.""" monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: True)) monkeypatch.delenv("AGENT_CHAT_ENABLED", raising=False) assert runtime.is_configured() is True # 기본은 켜짐(2026-09-22) monkeypatch.setenv("AGENT_CHAT_ENABLED", "0") assert runtime.is_configured() is False # 스위치로 닫을 수 있다 monkeypatch.setenv("AGENT_CHAT_ENABLED", "1") monkeypatch.setattr(runtime.provider, "active", lambda: SimpleNamespace(is_configured=lambda: False)) assert runtime.is_configured() is False # 켜 뒀어도 키가 없으면 안 열린다 async def test_닫아_두면_대화_요청은_거절된다(client, auth_headers, monkeypatch, db_engine): monkeypatch.setenv("AGENT_CHAT_ENABLED", "0") h, pid = await seed(client, auth_headers) res = await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "안녕"}) assert res.status_code == 409 assert res.json()["detail"] == "AGENT_NOT_CONFIGURED" assert (await client.get("/v1/agent/status", headers=h)).json()["enabled"] is False