o2o-negosium-original/negodata/backend/tests/test_quotation_read_gating.py

143 lines
7.2 KiB
Python

"""견적 조회 게이팅 + 사용 카드 매칭 보강.
- 조회 게이팅: 일반(USER)은 본인 견적만 — 목록은 서버가 강제 필터, 상세는 남의 견적의 존재 자체를
숨긴다(QUOTATION_NOT_FOUND). OWNER 이상은 회사 전체 조회. 판정은 common.authz.is_owner_or_admin 공용.
- 사용 카드: 봇은 버전 카드셋 밖에서도 카드를 고르므로(RL 런타임 미제한) 버전 목록에 없는
실사용 카드(chats.card_id)도 /cards 응답에 합쳐 내려준다 — 협상로그 화면·JSON 내보내기의
카드 매칭 실패(card=null) 복구.
"""
import uuid
from datetime import datetime
from sqlalchemy import text
from common.enums import ChatSender, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
from crud.quotation_crud import QuotationCRUD
from services.quotation import QuotationService
PAST = datetime(2020, 1, 1)
# ===== 목록 — HTTP e2e: 같은 회사, 담당자 2명 =====
async def test_list_forced_to_own_for_user_role(client, auth_headers, db_engine):
"""검증: 같은 회사 담당자 A/B 가 견적을 하나씩 가진 상태에서 일반유저 B 가 목록 조회(mine 미지정).
기대결과: B 본인 견적만 내려온다 — 서버가 mine 을 강제해 A 견적은 목록에서 제외."""
ha = await auth_headers("read_user_a")
hb = await auth_headers("read_user_b")
qt_a = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_a"), number="READ-A")
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_b"), number="READ-B")
numbers = [q["number"] for q in (await client.get("/v1/quotation/list", headers=hb)).json()["quotations"]]
assert numbers == ["READ-B"]
# 소유자 A 본인은 당연히 자기 견적을 본다(대조군)
assert (await client.get(f"/v1/quotation/{qt_a}", headers=ha)).json()["result"]["success"] is True
async def test_list_full_company_for_owner_role(client, auth_headers, db_engine):
"""검증: 최고관리자(OWNER)가 목록 조회(mine 미지정).
기대결과: 회사 전체 — 다른 담당자들 견적까지 모두 내려온다."""
await auth_headers("read_user_c")
hadmin = await auth_headers("read_admin", role=UserRole.OWNER.value)
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_c"), number="READ-C")
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_admin"), number="READ-D")
numbers = {q["number"] for q in (await client.get("/v1/quotation/list", headers=hadmin)).json()["quotations"]}
assert numbers == {"READ-C", "READ-D"}
# ===== 상세 — HTTP e2e: 남의 견적 =====
async def test_detail_hidden_from_other_user(client, auth_headers, db_engine):
"""검증: A 의 견적 상세(/{qt_id}·/sessions·/cards)를 같은 회사 일반유저 B 가 조회.
기대결과: 모두 거부 — 존재를 숨기는 QUOTATION_NOT_FOUND(권한 코드가 아니라 미존재로 응답)."""
await auth_headers("read_owner")
hb = await auth_headers("read_other")
qt = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_owner"), number="READ-HIDE")
for path in (f"/v1/quotation/{qt}", f"/v1/quotation/{qt}/sessions", f"/v1/quotation/{qt}/cards"):
body = (await client.get(path, headers=hb)).json()
assert body["result"]["success"] is False
assert body["result"]["code"] == ErrorType.QUOTATION_NOT_FOUND.value
async def test_detail_visible_to_owner_role(client, auth_headers, db_engine):
"""검증: A 의 견적 상세를 같은 회사 최고관리자(OWNER)가 조회.
기대결과: 성공 — 소유자가 아니어도 OWNER 는 조회 가능."""
await auth_headers("read_owner2")
hadmin = await auth_headers("read_admin2", role=UserRole.OWNER.value)
qt = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_owner2"), number="READ-SHOW")
body = (await client.get(f"/v1/quotation/{qt}", headers=hadmin)).json()
assert body["result"]["success"] is True
assert body["quotation"]["number"] == "READ-SHOW"
# ===== 사용 카드 — 서비스 직접: 버전 밖 카드 매칭 =====
async def test_list_cards_includes_used_card_outside_version(db_engine):
"""검증: 버전 카드셋엔 없는 카드를 채팅에서 사용(chats.card_used_yn=true, card_id=카드 PK)한 견적의 카드 조회.
기대결과: 응답에 그 카드가 session_card_id=카드 PK 로 포함 — 화면·내보내기 매칭이 성립한다."""
user = uuid.uuid4()
qt = await _seed_quotation(db_engine, user_id=user, number="CARD-USED")
card = uuid.uuid4()
session = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
"VALUES (:c, :u, '실사용카드', 'NGC-X1', '멘트', 1)"),
{"c": card, "u": user},
)
await conn.execute(
text(
"INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, end_time) VALUES "
"(:sid, :qt, :item, :sup, 'CARD-USED', 1, :type, 0, :st, :t)"
),
{"sid": session, "qt": qt, "item": uuid.uuid4(), "sup": uuid.uuid4(),
"type": QuotationType.REQUOTE.value, "st": SessionStatus.DONE.value, "t": PAST},
)
await conn.execute(
text(
"INSERT INTO chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, card_type) "
"VALUES (:cid, :sid, :card, 1, :sender, 0, true, 1)"
),
{"cid": uuid.uuid4(), "sid": session, "card": card, "sender": ChatSender.BOT.value},
)
res = await QuotationService(QuotationCRUD()).list_cards(str(qt))
assert res.result.success is True
got = {str(c.session_card_id): c for c in res.cards}
assert str(card) in got
assert got[str(card)].number == "NGC-X1"
assert got[str(card)].nego_card_id == card # nego 카드로 분류(type=1)
# ===== 헬퍼 =====
async def _user_id(engine, login_id) -> uuid.UUID:
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT user_id FROM users WHERE id = :i"), {"i": login_id}
)).scalar_one()
async def _seed_quotation(engine, *, user_id, number):
"""견적 1건 시드(조회 게이팅 확인용 — 상태는 무관하므로 CLOSED 고정)."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적', :number, :type, :status, "
" 1, 0, :t, :t, false)"
),
{
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
"status": QuotationStatus.CLOSED.value, "t": PAST,
},
)
return qt_id