"""공급사 재협상 요청 심사(IMK #15) — RenegotiationService 단위 테스트. 요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다(여기선 조회·승인·반려만). 승인은 새 로직이 아니라 기존 regenerate_quotation 을 호출하므로, 라운드 생성 machinery 는 스텁 QuotationService 로 격리하고 #15 고유 계약만 본다: · 목록 = 회사 스코프 + 상태 필터 (남의 회사 요청은 안 보임) · 승인 = 대기 요청만 → APPROVED 박제 + next_quotation_id 저장, 요청 공급사는 항상 재생성에 포함 · 승인 재생성 실패 → 상태 PENDING 유지(성급히 APPROVED 로 넘기지 않음) · 반려 = 대기 요청만 → REJECTED + 사유(memo) 저장 · 대기 아닌 요청(이미 승인/반려)엔 승인·반려 재시도 거부(멱등 가드) 세션의 custom.renegotiation 은 포털에서만 생기는 값이라 SQL 로 직접 넣는다. """ import json import uuid from datetime import datetime import pytest_asyncio from sqlalchemy import text from common.enums import ( CloseReason, ErrorType, QuotationStatus, QuotationType, RenegotiationStatus, SessionStatus, UserRole, ) from common.models.gmodel import PageParams from crud.renegotiation_crud import RenegotiationCRUD from router.v1.quotation.protocol import Res_CreateQuotation from services.renegotiation_service import RenegotiationService PAST = datetime(2020, 1, 1) PG = PageParams(1, 20) class _StubQuotation: """regenerate_quotation 을 대신한다 — 인자를 기록하고 정해진 결과만 돌려준다. ok=False 면 재생성 실패를 흉내낸다(승인이 상태를 넘기면 안 되는 경로 검증).""" def __init__(self, *, ok=True, qt_id=None): self.ok = ok self.qt_id = qt_id or uuid.uuid4() self.calls = [] async def regenerate_quotation(self, qt_id, company_id, supplier_ids, user_id=None, role=None, regen_label=None): self.calls.append( {"qt_id": qt_id, "company_id": company_id, "supplier_ids": list(supplier_ids), "user_id": user_id, "role": role, "regen_label": regen_label} ) res = Res_CreateQuotation() if self.ok: res.qt_id = self.qt_id else: res.result.SetResult(ErrorType.FAIL) res.msg = "stub 재생성 실패" return res # ===== 목록 ===== async def test_list_scoped_to_company(db_engine, company_id, other_company_id): """검증: 내 회사 대기요청 1건 + 남의 회사 대기요청 1건이 있을 때 내 회사로 목록 조회. 기대결과: 내 회사 건만(total=1) 나오고, 남의 회사 세션 id 는 결과에 없다.""" mine = await _seed_request(db_engine, company_id, number="R-MINE", renego=_pending()) await _seed_request(db_engine, other_company_id, number="R-THEIRS", renego=_pending()) res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG) assert res.result.success is True assert res.total == 1 assert [r.session_id for r in res.requests] == [mine["session_id"]] async def test_list_filters_by_status(db_engine, company_id): """검증: 같은 회사에 대기(PENDING)·반려(REJECTED) 요청을 하나씩 두고 status=1(대기)로 필터. 기대결과: 대기 건만 반환(total=1).""" pending = await _seed_request(db_engine, company_id, number="R-P", renego=_pending()) await _seed_request(db_engine, company_id, number="R-R", renego=_decided(RenegotiationStatus.REJECTED.value)) res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, RenegotiationStatus.PENDING.value, PG) assert res.total == 1 assert res.requests[0].session_id == pending["session_id"] assert res.requests[0].status == RenegotiationStatus.PENDING.value async def test_list_shows_decider_name(db_engine, company_id): """검증: 처리(승인/반려)된 요청의 decided_by(담당자 user_id)로 담당자 이름을 조인해 내려준다. 기대결과: 목록 항목의 decided_by_name 이 그 담당자 이름.""" decider_id = uuid.uuid4() await _seed_user(db_engine, company_id, decider_id, "김담당") renego = {**_decided(RenegotiationStatus.APPROVED.value), "decided_by": str(decider_id)} await _seed_request(db_engine, company_id, number="R-WHO", renego=renego) res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG) assert res.requests[0].decided_by_name == "김담당" async def test_list_shows_all_with_can_act_flags(db_engine, company_id): """검증: 같은 회사에 서로 다른 작성자의 요청 2건. 리스트엔 전체가 보이되, 처리 권한은 can_act 로 온다. 기대결과: 일반관리자(A)는 둘 다 보이고(total=2) can_act 는 자기(A) 것만 True. OWNER 는 전부 True.""" a = await _seed_request(db_engine, company_id, number="R-A", renego=_pending()) b = await _seed_request(db_engine, company_id, number="R-B", renego=_pending()) res = await _service().list_requests(company_id, a["user_id"], UserRole.USER.value, None, PG) assert res.total == 2 can = {r.session_id: r.can_act for r in res.requests} assert can[a["session_id"]] is True assert can[b["session_id"]] is False owner = await _service().list_requests(company_id, a["user_id"], UserRole.OWNER.value, None, PG) assert all(r.can_act for r in owner.requests) # ===== 승인 ===== async def test_approve_transitions_and_persists(db_engine, company_id): """검증: 대기 요청을 승인(재생성 성공 스텁, 승인메모 첨부). 기대결과: APPROVED + next_quotation_id 저장 + memo 저장, 재생성엔 요청 공급사가 포함돼 호출된다.""" seed = await _seed_request(db_engine, company_id, number="R-OK", renego=_pending()) stub = _StubQuotation(ok=True) svc = _service(stub) req = _approve_req(memo="조건 재검토 승인") res = await svc.approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req) assert res.result.success is True assert res.status == RenegotiationStatus.APPROVED.value assert res.next_quotation_id == str(stub.qt_id) # 재생성은 정확히 1번, 요청 공급사를 포함해서 호출 assert len(stub.calls) == 1 assert seed["supplier_id"] in stub.calls[0]["supplier_ids"] assert stub.calls[0]["qt_id"] == seed["quotation_id"] # 재생성 견적 타이틀 마킹용 라벨을 넘긴다(수동 재생성과 구분). assert stub.calls[0]["regen_label"] == "재협상 요청 재생성" saved = await _renego_of(db_engine, seed["session_id"]) assert saved["status"] == RenegotiationStatus.APPROVED.value assert saved["next_quotation_id"] == str(stub.qt_id) assert saved["memo"] == "조건 재검토 승인" async def test_approve_includes_extra_suppliers(db_engine, company_id): """검증: 승인 시 요청자 외 추가 공급사(supplier_ids)를 함께 지정. 기대결과: 재생성 호출의 공급사 집합에 요청자 + 추가 공급사가 모두 들어간다(중복 없이).""" seed = await _seed_request(db_engine, company_id, number="R-MULTI", renego=_pending()) extra = str(uuid.uuid4()) stub = _StubQuotation(ok=True) req = _approve_req(supplier_ids=[extra, seed["supplier_id"]]) # 요청자 중복 포함 await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req) got = set(stub.calls[0]["supplier_ids"]) assert got == {seed["supplier_id"], extra} async def test_approve_blocks_when_not_pending(db_engine, company_id): """검증: 이미 승인된(APPROVED) 요청에 승인 재시도(멱등 가드). 기대결과: 거부(INVALID_REQUEST_DATA) + 재생성 미호출.""" seed = await _seed_request( db_engine, company_id, number="R-DONE", renego=_decided(RenegotiationStatus.APPROVED.value) ) stub = _StubQuotation(ok=True) res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req()) assert res.result.success is False assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value assert stub.calls == [] async def test_approve_keeps_pending_when_regenerate_fails(db_engine, company_id): """검증: 대기 요청 승인 중 재생성이 실패(스텁 ok=False). 기대결과: 실패 반환 + 상태는 PENDING 그대로(성급히 APPROVED 로 넘기지 않음).""" seed = await _seed_request(db_engine, company_id, number="R-FAIL", renego=_pending()) stub = _StubQuotation(ok=False) res = await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _approve_req()) assert res.result.success is False saved = await _renego_of(db_engine, seed["session_id"]) assert saved["status"] == RenegotiationStatus.PENDING.value assert "next_quotation_id" not in saved or saved["next_quotation_id"] is None async def test_approve_other_company_not_found(db_engine, company_id, other_company_id): """검증: 남의 회사 요청 세션을 내 회사 자격으로 승인 시도(IDOR). 기대결과: NOT_FOUND(회사 스코프 밖) + 재생성 미호출.""" seed = await _seed_request(db_engine, other_company_id, number="R-IDOR", renego=_pending()) stub = _StubQuotation(ok=True) res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req()) assert res.result.success is False assert res.result.code == ErrorType.QUOTATION_NOT_FOUND.value assert stub.calls == [] # ===== 반려 ===== async def test_reject_transitions_and_saves_memo(db_engine, company_id): """검증: 대기 요청을 사유와 함께 반려. 기대결과: REJECTED + memo(반려 사유) 저장.""" seed = await _seed_request(db_engine, company_id, number="R-REJ", renego=_pending()) res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("단종 품목이라 불가")) assert res.result.success is True assert res.status == RenegotiationStatus.REJECTED.value saved = await _renego_of(db_engine, seed["session_id"]) assert saved["status"] == RenegotiationStatus.REJECTED.value assert saved["memo"] == "단종 품목이라 불가" async def test_reject_blocks_when_not_pending(db_engine, company_id): """검증: 이미 반려된 요청에 반려 재시도. 기대결과: 거부(INVALID_REQUEST_DATA).""" seed = await _seed_request( db_engine, company_id, number="R-REJ2", renego=_decided(RenegotiationStatus.REJECTED.value) ) res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("x")) assert res.result.success is False assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value # ===== 소유권 게이팅 ===== async def test_approve_forbidden_for_non_owner_user(db_engine, company_id): """검증: 남의 견적 재협상 요청을 일반관리자(비소유·USER)가 승인 시도. 기대결과: 거부(ACCOUNT_FORBIDDEN) + 재생성 미호출.""" seed = await _seed_request(db_engine, company_id, number="R-NOTMINE", renego=_pending()) stub = _StubQuotation(ok=True) res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req()) assert res.result.success is False assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value assert stub.calls == [] async def test_approve_allowed_for_owner(db_engine, company_id): """검증: 남의 견적이라도 최고관리자(OWNER)면 승인. 기대결과: 성공 + 재생성 호출.""" seed = await _seed_request(db_engine, company_id, number="R-OWNER", renego=_pending()) stub = _StubQuotation(ok=True) res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.OWNER.value, seed["session_id"], _approve_req()) assert res.result.success is True assert len(stub.calls) == 1 async def test_reject_forbidden_for_non_owner_user(db_engine, company_id): """검증: 남의 견적 재협상 요청을 일반관리자가 반려 시도. 기대결과: 거부(ACCOUNT_FORBIDDEN).""" seed = await _seed_request(db_engine, company_id, number="R-REJNOT", renego=_pending()) res = await _service().reject(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _reject_req("x")) assert res.result.success is False assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value # ===== 헬퍼 ===== def _pending(): return { "status": RenegotiationStatus.PENDING.value, "reason": "가격 재검토 요청", "desired_price": 90000, "requested_at": "2026-07-20T00:00:00+00:00", } def _decided(status): return {**_pending(), "status": status, "decided_at": "2026-07-21T00:00:00+00:00", "memo": "기존 판단"} def _approve_req(*, supplier_ids=None, memo=""): from router.v1.renegotiation.protocol import Req_ApproveRenegotiation return Req_ApproveRenegotiation(supplier_ids=supplier_ids or [], memo=memo) def _reject_req(memo): from router.v1.renegotiation.protocol import Req_RejectRenegotiation return Req_RejectRenegotiation(memo=memo) def _service(quotation_stub=None): return RenegotiationService(RenegotiationCRUD(), quotation_stub or _StubQuotation()) async def _seed_user(engine, company_id, user_id, name): """담당자 유저 1건 시드(decided_by 이름 조인 확인용).""" async with engine.begin() as conn: await conn.execute( text( "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " "VALUES (:uid, :cid, :login, 'x', :name, 1, :role, now())" ), {"uid": user_id, "cid": uuid.UUID(company_id), "login": f"dec-{str(user_id)[:8]}", "name": name, "role": UserRole.USER.value}, ) async def _seed_request(engine, company_id, *, number, renego, close_reason=CloseReason.OPEN_PRICE.value, round_=1): """재협상 요청 1건 시드: 작성자(회사 스코프) + 마감견적 + custom.renegotiation 달린 세션. 목록 쿼리가 quotations→users(company_id)·items·suppliers 를 조인하므로 이들을 함께 넣는다.""" user_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() supplier_id, item_id = uuid.uuid4(), uuid.uuid4() custom = {"renegotiation": renego} async with engine.begin() as conn: await conn.execute( text( "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " "VALUES (:uid, :cid, :login, 'x', '담당', 1, :role, now())" ), {"uid": user_id, "cid": uuid.UUID(company_id), "login": f"u-{number}", "role": UserRole.USER.value}, ) await conn.execute( text( "INSERT INTO quotations " "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, " " round, iteration, start_time, end_time, deleted) VALUES " "(:qt_id, :uid, :setting, :version, '견적', :number, :type, :status, :close_reason, " " :round, 0, :past, :past, false)" ), { "qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, "status": QuotationStatus.CLOSED.value, "close_reason": close_reason, "round": round_, "past": PAST, }, ) # items·suppliers 는 목록 쿼리에서 outerjoin 이라 시드 없이도 된다(이름은 빈 문자열로 채워짐). await conn.execute( text( "INSERT INTO sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " " target_price, status, bid_price, end_time, custom) VALUES " "(:sid, :qt_id, :iid, :spid, :number, :round, :type, " " 100000, :sstatus, 95000, :past, CAST(:custom AS JSONB))" ), { "sid": session_id, "qt_id": qt_id, "iid": item_id, "spid": supplier_id, "number": number, "round": round_, "type": QuotationType.REQUOTE.value, "sstatus": SessionStatus.DONE.value, "past": PAST, "custom": json.dumps(custom), }, ) return { "session_id": str(session_id), "quotation_id": str(qt_id), "supplier_id": str(supplier_id), "user_id": str(user_id), } async def _renego_of(engine, session_id): """세션 custom.renegotiation 을 읽어 dict 로 (저장 결과 확인용).""" async with engine.begin() as conn: row = (await conn.execute( text("SELECT custom FROM sessions WHERE session_id = :sid"), {"sid": uuid.UUID(session_id)}, )).one() return (row[0] or {}).get("renegotiation") or {}