o2o-negosium-original/backend/tests/test_chat.py
민헌 dcce82a630 feat(backend): 협상 채팅(chat) API + agent 위임
- /v1/negotiation/sessions/{id}/chat/{init,messages,send} 추가
- agent(9500) 위임 어댑터(IAgentClient) + mock(use_mock) 격리 → agent 미연동 시 1402 graceful degrade
- negotiation.chats 메시지 영속화(meta JSONB) + 종료 시 세션 입찰/거부 확정(단일 트랜잭션)
- 동시전송 가드(유저 메시지 pre-claim/CHAT_IN_PROGRESS) + 실패 시 롤백, 마감/만료 분기, init 만료 정리
- ChatSender enum, chat 에러코드(1400~1403), chats ORM 모델, AgentConfig
- 테스트 10건(test_chat.py), AGENT_INTEGRATION.md 연동 규약 문서

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:24:57 +09:00

236 lines
11 KiB
Python

"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료).
agent 는 config.use_mock=true 로 내장 MockAgentClient 를 쓴다(결정론적 플로우).
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
"""
import uuid
import bcrypt
import pytest_asyncio
from sqlalchemy import text
TEST_LOGIN_ID = "pytest_chat_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
MARK = "PYTESTCHAT-"
@pytest_asyncio.fixture
async def chat_seed(db_engine):
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
supplier_id = uuid.uuid4()
other_supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# (code, session.status, qt_type, 마감까지 h, quotation.status, 소속 공급사)
specs = [
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
]
sids, qids = {}, {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.chats WHERE session_id IN (SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"))
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text(
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '채팅담당자', now(), 1, 1)"
),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], qids[code] = session_id, qt_id
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, model_name, manufacturer, moq, spec) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 100000, :model, '테스트제조사', '10', '규격A')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, memo) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs), '메모')"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now() + make_interval(hours => 2))"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
def _h(token):
return {"Authorization": f"Bearer {token}"}
async def _init(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/init", headers=_h(token))
async def _messages(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/messages", headers=_h(token))
async def _send(client, token, sid, user_input, user_input_type=None):
body = {"user_input": user_input, "user_input_type": user_input_type}
return await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", headers=_h(token), json=body)
async def _session_status(db_engine, session_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
async def _session_bid(db_engine, session_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT bid_price FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
async def _session_reject(db_engine, session_id):
async with db_engine.begin() as conn:
r = (await conn.execute(text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).first()
return r[0], r[1]
# ---- init -------------------------------------------------------------------
async def test_chat_init_returns_meta(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert body["session_status"] == 2
assert body["item_name"] == "상품 P" and body["item_price"] == 100000
assert body["item_maker_name"] == "테스트제조사"
assert body["quotation_end_time"] # 타이머용 마감 시각
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
# ---- messages (오프닝 seed) -------------------------------------------------
async def test_messages_seeds_opening(client, chat_seed):
token = await _login_token(client)
body = (await _messages(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert len(body["items"]) == 1
msg = body["items"][0]
assert msg["sender"] == 1 # ChatSender.BOT (봇)
assert msg["next_input_mode"] == "confirm"
assert msg["script"]
# ---- send (협상 진행 → 종료) ------------------------------------------------
async def test_send_flow_to_completion(client, chat_seed, db_engine):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝(턴0) seed
r1 = (await _send(client, token, sid, "네, 시작할게요")).json()
assert r1["result"]["success"] is True
assert r1["message"]["next_input_mode"] == "confirm" # 품목안내
assert r1["session_status"] == 2
r2 = (await _send(client, token, sid, "가격 협상 진행")).json()
assert r2["message"]["next_input_mode"] == "price" # 가격입력 요청
r3 = (await _send(client, token, sid, "90000", user_input_type="price")).json()
assert r3["result"]["success"] is True
assert r3["message"]["chat_end"] is True
assert r3["session_status"] == 3 # 협상완료(DONE)
assert await _session_status(db_engine, sid) == 3
assert await _session_bid(db_engine, sid) == 90000 # 입찰가 확정
async def test_send_price_out_of_range(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid)
# 목표가 100000 → 허용 [30000, 170000]. 10 은 하한 미만.
body = (await _send(client, token, sid, "10", user_input_type="price")).json()
assert body["result"]["code"] == 1401 # CHAT_PRICE_OUT_OF_RANGE
async def test_send_not_in_progress(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["C"] # 협상생성(미참여 전 단계)
body = (await _send(client, token, sid, "네")).json()
assert body["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
async def test_send_requires_auth(client, chat_seed):
sid = chat_seed["sids"]["P"]
r = await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", json={"user_input": "네"})
assert r.status_code in (401, 403)
# ---- 보완: 거부 저장 / 동시전송 가드 / init 만료 정리 ------------------------
async def test_send_rejection_persists_reason(client, chat_seed, db_engine):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝
body = (await _send(client, token, sid, "협상 포기합니다")).json()
assert body["result"]["success"] is True
assert body["message"]["chat_end"] is True
assert body["session_status"] == 5 # 협상거부(REJECTED)
status, reason = await _session_reject(db_engine, sid)
assert status == 5 and reason == "협상 포기합니다" # 거부 사유 저장
async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine):
"""직전 메시지가 USER(이전 턴 처리 중)면 중복 전송을 거절한다 → CHAT_IN_PROGRESS."""
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝(seq=1, BOT)
# 봇 응답이 아직 안 온 상태를 모사: USER 메시지를 마지막(seq=2)으로 직접 삽입
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 2, 2, 0)"),
{"sid": sid},
)
body = (await _send(client, token, sid, "네")).json()
assert body["result"]["code"] == 1403 # CHAT_IN_PROGRESS
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
token = await _login_token(client)
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
body = (await _init(client, token, sid)).json()
assert body["result"]["success"] is True
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨