211 lines
12 KiB
Python
211 lines
12 KiB
Python
"""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) + LPS 수집 잡이 스케줄에 등록된다."""
|
|
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", "sync_lps_results"}
|
|
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()
|