"""scheduler(마감 크론 잡) e2e 테스트 — 어떤 견적을 고르고, 마감하면 결과가 어떻게 나오는지 확인. 용어: 견적 = 한 건의 입찰 공고 / 세션 = 그 견적에 참여한 공급사별 협상 1건 / 마감 = 견적을 닫고 낙찰자를 정함. 마감을 자동으로 돌리는 크론 잡이 2개 있다(scheduler/jobs.py): · 잡① close_expired_quotations : 마감시각(end_time)이 지났는데 아직 안 닫힌 견적을 닫는다. · 잡② close_negotiated_quotations : 참여 세션이 전부 끝난(협상 종료) 견적을 닫는다. 두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 다음 라운드 재생성 / 그냥 마감)를 정한다. 이 파일은 그 두 잡이 (1) 마감할 견적을 올바로 고르는지, (2) 마감 결과가 맞는지 확인한다. 잡에는 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 부른다(앱과 같은 DB 연결을 써서 mock 불필요). 세션 상태(협상완료/거부/입찰가 등)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다. """ import asyncio import uuid from datetime import datetime import pytest_asyncio from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy import text from common.enums import QuotationStatus, QuotationType, SessionStatus from scheduler import jobs PAST = datetime(2020, 1, 1) # 마감시각이 이미 지난 시점(잡①의 마감 대상) FUTURE = datetime(2999, 1, 1) # 마감시각이 아직 안 온 시점(잡①에서 제외) @pytest_asyncio.fixture async def clean(db_engine): """각 테스트 시작 전에 quotations·sessions 를 모두 비워 깨끗한 상태로 만든다. 공용 db_engine 픽스처는 quotations 만 비운다. 그런데 잡②는 '세션이 다 끝난 견적'을 전체 견적에서 찾으므로, 앞선 다른 테스트가 남긴 세션이 남아 있으면 엉뚱한 견적이 대상에 끼어든다 → 그래서 여기서 sessions 까지 비운다. """ async with db_engine.begin() as conn: await conn.execute(text("TRUNCATE TABLE sessions, quotations RESTART IDENTITY CASCADE")) return db_engine async def test_close_expired_picks_only_due_and_open(clean): """검증: 잡①을 돌린다. 견적 4개를 섞어둔다 — ① 마감시각 지난 미마감 ② 마감시각 안 지난 것 ③ 이미 마감된 것 ④ 삭제된 것. 기대결과: ①(due) 1건만 새로 마감(CLOSED)되고, ②③④ 는 그대로 둔다.""" engine = clean due = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST) # 마감시각 지남 + 미마감 → 마감 대상 future = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) # 마감시각 안 지남 → 제외 already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST) # 이미 마감 → 제외 deleted = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=True) # 삭제됨 → 제외 n = await jobs.close_expired_quotations() assert n == 1 # 새로 마감된 건 due 1건뿐 assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value assert (await _quotation_row(engine, future)).status == QuotationStatus.IN_PROGRESS.value # 마감시각 전이라 그대로 assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 마감 assert (await _quotation_row(engine, deleted)).status == QuotationStatus.IN_PROGRESS.value # 삭제분은 건드리지 않음 async def test_close_negotiated_picks_when_all_sessions_ended(clean): """검증: 잡②를 돌린다. 견적 3개를 섞어둔다 — ① 세션이 전부 끝난 것 ② 아직 진행중인 세션이 있는 것 ③ 세션이 아예 없는 것. 기대결과: ①(세션 다 끝남)만 마감(CLOSED)되고, ②③ 은 제외.""" engine = clean # ① 세션이 전부 끝남(거부로 종료) → 마감 대상 ended = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) await _add_session(engine, ended, status=SessionStatus.REJECTED.value) # ② 아직 진행중인 세션이 하나라도 있음 → 제외 pending = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100) await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value) # ③ 세션이 아예 없음 → 제외(끝났다고 볼 세션 자체가 없음) no_session = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) await jobs.close_negotiated_quotations() assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value assert (await _quotation_row(engine, pending)).status == QuotationStatus.IN_PROGRESS.value assert (await _quotation_row(engine, no_session)).status == QuotationStatus.IN_PROGRESS.value async def test_award_single_lowest(clean): """검증: 두 공급사가 각각 100·200 으로 협상완료(DONE)한, 마감시각 지난 견적을 잡①로 마감. 기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 단독 낙찰(낙찰 있음 + 낙찰자=그 공급사).""" engine = clean qt = await _add_quotation(engine, end_time=PAST) winner = uuid.uuid4() await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner) # 더 싼 쪽 await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200) await jobs.close_expired_quotations() row = await _quotation_row(engine, qt) assert row.status == QuotationStatus.CLOSED.value assert row.preferred_sp_yn is True # 낙찰자 있음 assert str(row.preferred_sp_id) == str(winner) # 최저가가 단독이라 그 공급사로 확정 async def test_rejected_just_closes(clean): """검증: 입찰 없이 '거부'만 있는, 마감시각 지난 견적을 잡①로 마감. 기대결과: 마감(CLOSED)되지만 낙찰자는 없음(살 사람이 없으니 그냥 닫힘).""" engine = clean qt = await _add_quotation(engine, end_time=PAST) await _add_session(engine, qt, status=SessionStatus.REJECTED.value) # 입찰가 없이 거부만 await jobs.close_expired_quotations() row = await _quotation_row(engine, qt) assert row.status == QuotationStatus.CLOSED.value assert not row.preferred_sp_yn # 거부뿐이라 낙찰 없이 마감 async def test_scheduler_disabled_without_env(monkeypatch): """검증: SCHEDULER_ENABLED 환경변수 없이 start_scheduler() 호출. 기대결과: 스케줄러가 켜지지 않는다(운영에서 실수로 자동 마감이 도는 걸 막는 안전장치).""" import scheduler monkeypatch.delenv("SCHEDULER_ENABLED", raising=False) scheduler._scheduler = None scheduler.start_scheduler() assert scheduler._scheduler is None # 환경변수가 1이 아니면 미기동 async def test_scheduler_registers_both_jobs(monkeypatch): """검증: SCHEDULER_ENABLED=1 로 start_scheduler() 호출. 기대결과: 마감 잡 2개(close_expired·close_negotiated)가 스케줄에 등록된다.""" import scheduler monkeypatch.setenv("SCHEDULER_ENABLED", "1") scheduler._scheduler = None scheduler.start_scheduler() try: ids = {j.id for j in scheduler._scheduler.get_jobs()} assert ids == {"close_expired_quotations", "close_negotiated_quotations"} finally: scheduler.shutdown_scheduler() assert scheduler._scheduler is None async def test_scheduler_actually_runs_job_and_closes(clean): """검증: 스케줄러에 잡을 걸어 실제로 발화시킨다(1초 간격으로). 기대결과: 스케줄러가 잡을 호출해 마감시각 지난 견적이 몇 초 안에 마감(CLOSED)된다 — '스케줄러→잡→마감' 경로 확인.""" engine = clean qt = await _add_quotation(engine, end_time=PAST) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) sched = AsyncIOScheduler(timezone="Asia/Seoul") sched.add_job(jobs.close_expired_quotations, IntervalTrigger(seconds=1), max_instances=1) sched.start() try: row = None for _ in range(25): # 잡은 1초 뒤 첫 발화 → 최대 ~5초 동안 0.2초 간격으로 확인 await asyncio.sleep(0.2) row = await _quotation_row(engine, qt) if row.status == QuotationStatus.CLOSED.value: break assert row is not None and row.status == QuotationStatus.CLOSED.value # 스케줄러가 잡을 호출해 마감됨 finally: sched.shutdown(wait=False) # ===== 헬퍼 (위 테스트들이 쓰는 도우미. FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) ===== async def _add_quotation(engine, *, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=False): """견적 1건을 DB 에 직접 넣는다(시드). status/end_time/deleted 로 '대상/제외' 상황을 만든다.""" 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, :name, :number, :type, :status, " " :round, :iteration, :start_time, :end_time, :deleted)" ), { "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(), "version_id": uuid.uuid4(), "name": "견적", "number": f"Q-{qt_id.hex[:8]}", "type": QuotationType.REQUOTE.value, "status": status, "round": 1, "iteration": 0, "start_time": PAST, "end_time": end_time, "deleted": deleted, }, ) return qt_id async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): """세션(공급사 협상 1건)을 DB 에 직접 넣는다. status/bid_price 로 협상완료·거부·입찰가를 만든다.""" async with engine.begin() as conn: 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) VALUES " "(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, " " :target_price, :status, :bid_price, :end_time)" ), { "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), "supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1, "qt_type": QuotationType.REQUOTE.value, "target_price": 0, "status": status, "bid_price": bid_price, "end_time": PAST, }, ) async def _quotation_row(engine, qt_id): """견적 1건을 다시 읽어온다(마감 후 status·낙찰자 확인용).""" async with engine.begin() as conn: return (await conn.execute( text("SELECT status, preferred_sp_yn, preferred_sp_id FROM quotations WHERE qt_id = :id"), {"id": qt_id}, )).first()