"""견적 조회 게이팅 + 사용 카드 매칭 보강. - 조회 게이팅: 일반(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