- DONE 세션의 participate 는 결과 열람용 재진입(무변경)이므로 견적마감(NEGO_QUOTATION_CLOSED)·마감시간 검증을 건너뛴다 - reject 는 blocked_statuses 로 DONE 을 이미 차단하므로 이 분기는 participate 에만 적용 - 테스트: 견적마감+마감시간 경과 상태에서도 완료 세션 참여 성공, 세션·견적 상태 무변경 검증 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
285 lines
13 KiB
Python
285 lines
13 KiB
Python
"""협상 도메인 e2e 테스트 (세션 목록 + 참여).
|
|
|
|
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
|
|
목록의 qt_end_time 은 quotation.end_time 기준이라 세션마다 견적을 함께 시드한다.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import bcrypt
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
|
|
TEST_LOGIN_ID = "pytest_nego_user"
|
|
TEST_PW = "pytest1234"
|
|
TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
|
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def nego_seed(db_engine):
|
|
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
|
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 = [
|
|
("A", 1, 2, 2, 1, supplier_id), # 협상생성 / 재견적 / +2h / 견적생성
|
|
("B", 2, 1, 1, 2, supplier_id), # 협상중 / 재협상 / +1h / 견적진행중
|
|
("C", 3, 2, 3, 2, supplier_id), # 협상완료 / 재견적 / +3h / 견적진행중
|
|
("X", 1, 1, 1, 1, other_supplier_id), # 타 공급사 → 목록/참여에서 제외/차단
|
|
]
|
|
sids, qids = {}, {}
|
|
|
|
async def _cleanup(conn):
|
|
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, model_name, manufacturer) "
|
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
|
),
|
|
{"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) "
|
|
"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())"
|
|
),
|
|
{"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"]
|
|
|
|
|
|
async def _list(client, token, **params):
|
|
return await client.get("/v1/negotiation/sessions", headers={"Authorization": f"Bearer {token}"}, params=params)
|
|
|
|
|
|
async def _participate(client, token, session_id):
|
|
return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"})
|
|
|
|
|
|
async def _reject(client, token, session_id, reason):
|
|
return await client.post(
|
|
f"/v1/negotiation/sessions/{session_id}/reject",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"reject_reason": reason},
|
|
)
|
|
|
|
|
|
async def _session_reject(db_engine, session_id):
|
|
async with db_engine.begin() as conn:
|
|
return (await conn.execute(
|
|
text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"),
|
|
{"sid": session_id},
|
|
)).first()
|
|
|
|
|
|
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 _quotation_status(db_engine, qt_id):
|
|
async with db_engine.begin() as conn:
|
|
return (await conn.execute(text("SELECT status FROM quotation.quotations WHERE qt_id = :qid"), {"qid": qt_id})).scalar()
|
|
|
|
|
|
# ---- 목록 -------------------------------------------------------------------
|
|
async def test_list_returns_only_own_supplier_sessions(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token)).json()
|
|
assert body["result"]["success"] is True
|
|
assert body["total"] == 3 # 본인 공급사 3건만 (타 공급사 X 제외)
|
|
one = next(i for i in body["items"] if i["item_code"] == f"{MARK}B")
|
|
assert one["session_status"] == 2 and one["qt_type"] == 1
|
|
assert one["model_name"] == "MODEL-B" and one["maker_name"] == "테스트제조사"
|
|
assert one["session_id"] and one["qt_end_time"]
|
|
|
|
|
|
async def test_list_filter_status(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, status=2)).json()
|
|
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
|
|
|
|
|
async def test_list_filter_qt_type(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, qt_type=2)).json()
|
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A", f"{MARK}C"}
|
|
|
|
|
|
async def test_list_order_by_quotation_end_time(client, nego_seed):
|
|
token = await _login_token(client)
|
|
asc = (await _list(client, token, order="asc")).json()["items"]
|
|
desc = (await _list(client, token, order="desc")).json()["items"]
|
|
assert asc[0]["item_code"] == f"{MARK}B" # +1h 가 가장 임박
|
|
assert desc[0]["item_code"] == f"{MARK}C" # +3h 가 가장 멈
|
|
|
|
|
|
async def test_list_pagination(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, page=1, page_size=2)).json()
|
|
assert body["total"] == 3 and len(body["items"]) == 2
|
|
|
|
|
|
async def test_list_requires_auth(client):
|
|
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
|
|
|
|
|
|
# ---- 참여 -------------------------------------------------------------------
|
|
async def test_participate_success(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
assert await _session_status(db_engine, sid) == 2 # 협상중
|
|
assert await _quotation_status(db_engine, qid) == 2 # 견적진행중
|
|
|
|
|
|
async def test_participate_forbidden_other_supplier(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _participate(client, token, nego_seed["sids"]["X"]) # 타 공급사 세션
|
|
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
|
|
|
|
|
async def test_participate_not_participable(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["A"]
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("UPDATE negotiation.sessions SET status = 4 WHERE session_id = :sid"), {"sid": sid}) # 미참여
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
|
|
|
|
|
async def test_participate_quotation_closed(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"]
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("UPDATE quotation.quotations SET status = 3 WHERE qt_id = :qid"), {"qid": qid}) # 견적마감
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["code"] == 1302 # NEGO_QUOTATION_CLOSED
|
|
|
|
|
|
async def test_participate_deadline_passed_sets_not_participated(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
|
|
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})
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["code"] == 1303 # NEGO_DEADLINE_PASSED
|
|
assert await _session_status(db_engine, sid) == 4 # 협상생성이었으므로 미참여로 정리됨
|
|
assert await _quotation_status(db_engine, qid) == 1 # 견적은 변경 안 됨
|
|
|
|
|
|
async def test_participate_in_progress_no_state_change(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["B"], nego_seed["qids"]["B"] # 이미 협상중
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
assert await _session_status(db_engine, sid) == 2 # 무변경 (협상중 유지)
|
|
assert await _quotation_status(db_engine, qid) == 2 # 무변경
|
|
|
|
|
|
async def test_participate_done_bypasses_quotation_closed(client, nego_seed, db_engine):
|
|
"""협상완료 세션은 결과 열람용 재진입이므로 견적마감·마감시간이 지나도 참여(진입) 가능."""
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["C"], nego_seed["qids"]["C"] # 협상완료
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(
|
|
text("UPDATE quotation.quotations SET status = 3, end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"),
|
|
{"qid": qid},
|
|
) # 견적마감 + 마감시간 경과
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
assert await _session_status(db_engine, sid) == 3 # 무변경 (협상완료 유지)
|
|
assert await _quotation_status(db_engine, qid) == 3 # 무변경 (견적마감 유지)
|
|
|
|
|
|
async def test_participate_session_not_found(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _participate(client, token, str(uuid.uuid4()))
|
|
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
|
|
|
|
|
# ---- 거부 -------------------------------------------------------------------
|
|
async def test_reject_success(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["B"] # 협상중 → 거부 가능
|
|
r = await _reject(client, token, sid, "단종 상품입니다")
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
status, reason = await _session_reject(db_engine, sid)
|
|
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
|
|
|
|
|
async def test_reject_empty_reason(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
|
assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA
|
|
|
|
|
|
async def test_reject_forbidden_other_supplier(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, nego_seed["sids"]["X"], "사유") # 타 공급사 세션
|
|
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
|
|
|
|
|
async def test_reject_not_participable_when_done(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, nego_seed["sids"]["C"], "사유") # 협상완료(3) → 거부 불가
|
|
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
|
|
|
|
|
async def test_reject_session_not_found(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, str(uuid.uuid4()), "사유")
|
|
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
|
|
|
|
|
async def test_reject_requires_auth(client, nego_seed):
|
|
sid = nego_seed["sids"]["B"]
|
|
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
|
|
assert r.status_code in (401, 403)
|