143 lines
7.2 KiB
Python
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
|