- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
1791 lines
76 KiB
Python
1791 lines
76 KiB
Python
"""검증 루프 2회차 (2026-10-02) — 인프라·데이터·보안 회귀 테스트.
|
||
|
||
H-1 공개 GET 계좌정보 노출 / H-2 세션 is_active·관리자 게이트 / H-3 토큰 락 안 sleep /
|
||
H-4 토큰 무효 응답 / H-5 db_worker cancel 유실 / M-2 클라이언트 IP / M-5 키 분리·점검 /
|
||
M-6 토큰 로그 마스킹 / M-7 created_at / L-4 KRX 휴장.
|
||
|
||
원칙: 외부 호출 0 (KIS 는 conftest 가 클래스 단위로 차단, 여기서는 HTTP 클라이언트·DB 세션을
|
||
가짜로 주입), 목은 예외 발생형, 기대값은 settings 참조.
|
||
"""
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
import types
|
||
from datetime import date, datetime, timedelta
|
||
|
||
import pytest
|
||
from fastapi import HTTPException
|
||
|
||
from app.core.config import settings
|
||
from app.services import korea_investment_api as kis_mod
|
||
|
||
# conftest 가 테스트마다 클래스 메서드를 차단 스텁으로 덮는다 → 수집 시점의 원본을 보관
|
||
_ORIG_GET_ACCESS_TOKEN = kis_mod.KoreaInvestmentAPI.__dict__["_get_access_token"]
|
||
_ORIG_REQUEST_NEW_TOKEN = kis_mod.KoreaInvestmentAPI.__dict__["_request_new_token"]
|
||
_ORIG_PLACE_ORDER = kis_mod.KoreaInvestmentAPI.__dict__["place_order"]
|
||
_ORIG_CANCEL_ORDER = kis_mod.KoreaInvestmentAPI.__dict__["cancel_order"]
|
||
_ORIG_CANCEL_ORDER_V2 = kis_mod.KoreaInvestmentAPI.__dict__["cancel_order_v2"]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# H-5: db_worker cancel 시 유실
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.fixture
|
||
def fresh_queue(monkeypatch):
|
||
from app.services import db_worker as w
|
||
|
||
monkeypatch.setattr(w, "_db_task_queue", None)
|
||
return w
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_during_retry_backoff_preserves_backoff_task_and_queue(fresh_queue, monkeypatch):
|
||
"""재시도 백오프 sleep 중 cancel → 백오프 태스크와 큐 잔여가 모두 drain(→DLQ) 된다.
|
||
|
||
수정 전: CancelledError 가 except Exception 핸들러 안의 sleep 에서 나와 drain 을 건너뛰고
|
||
3건 전부 유실(dlq=[]).
|
||
"""
|
||
w = fresh_queue
|
||
dlq: list[tuple[str, str]] = []
|
||
|
||
class _FailSession:
|
||
async def __aenter__(self):
|
||
raise RuntimeError("db down")
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
async def _fake_dlq(task, err):
|
||
dlq.append((task["data"]["stock_code"], err))
|
||
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _FailSession())
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
|
||
q = w.get_db_task_queue()
|
||
worker = asyncio.create_task(w.db_writer_worker())
|
||
await q.put({"type": "close_active_trade", "data": {"stock_code": "A"}})
|
||
await q.put({"type": "upsert_active_trade", "data": {"stock_code": "B"}})
|
||
await q.put({"type": "insert_trade_history", "data": {"stock_code": "C"}})
|
||
await asyncio.sleep(0.2) # A 가 1초 백오프 sleep 중
|
||
worker.cancel()
|
||
await worker # drain 후 정상 종료 (CancelledError 를 밖으로 내지 않는다)
|
||
|
||
codes = [c for c, _ in dlq]
|
||
assert codes[0] == "A", "백오프 태스크가 drain 목록 맨 앞이어야 한다"
|
||
assert sorted(codes) == ["A", "B", "C"], dlq
|
||
assert q.qsize() == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_during_inflight_commit_preserves_task_in_dlq(fresh_queue, monkeypatch):
|
||
"""처리 중(커밋 대기) cancel → 비멱등 INSERT 는 커밋 여부 불명이라 DLQ, 큐 잔여도 처리."""
|
||
w = fresh_queue
|
||
dlq: list[tuple[str, str]] = []
|
||
|
||
class _SlowSession:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
def add(self, obj):
|
||
pass
|
||
|
||
async def commit(self):
|
||
await asyncio.sleep(5)
|
||
|
||
class _TH:
|
||
def __init__(self, **kw):
|
||
self.kw = kw
|
||
|
||
async def _fake_dlq(task, err):
|
||
dlq.append((task["data"]["stock_code"], err))
|
||
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _SlowSession())
|
||
monkeypatch.setattr(w, "TradeHistory", _TH)
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
|
||
q = w.get_db_task_queue()
|
||
worker = asyncio.create_task(w.db_writer_worker())
|
||
await q.put({"type": "insert_trade_history", "data": {"stock_code": "INFLIGHT"}})
|
||
await q.put({"type": "update_trade_history", "data": {"id": 1, "stock_code": "QUEUED"}})
|
||
await asyncio.sleep(0.1)
|
||
worker.cancel()
|
||
await worker
|
||
|
||
by_code = dict(dlq)
|
||
assert "INFLIGHT" in by_code, "처리 중이던 태스크가 DLQ 에 보존돼야 한다"
|
||
assert "commit_unknown" in by_code["INFLIGHT"]
|
||
assert "QUEUED" in by_code
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_while_idle_returns_normally(fresh_queue):
|
||
w = fresh_queue
|
||
worker = asyncio.create_task(w.db_writer_worker())
|
||
await asyncio.sleep(0.05)
|
||
worker.cancel()
|
||
await worker
|
||
assert worker.done() and not worker.cancelled()
|
||
|
||
|
||
def test_shutdown_cancels_db_worker_last():
|
||
"""[H-5(d)] lifespan 종료 순서 — db_worker 가 생산자보다 뒤."""
|
||
import inspect
|
||
|
||
from app import main
|
||
|
||
src = inspect.getsource(main.lifespan)
|
||
block = src[src.index("# 태스크 정리"):]
|
||
names = [n for n in (
|
||
"swing_trade_task", "day_trade_task", "us_trade_task", "news_collect_task",
|
||
"adaptive_strategy_task", "ai_strategy_task", "db_worker_task",
|
||
)]
|
||
pos = {n: block.index(f'"{n}"') for n in names}
|
||
assert pos["db_worker_task"] == max(pos.values())
|
||
|
||
|
||
def _compose_stop_grace() -> int:
|
||
from pathlib import Path
|
||
|
||
import yaml # PyYAML (uvicorn[standard] 의존성)
|
||
|
||
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
|
||
grace = compose["services"]["backend"].get("stop_grace_period", "10s")
|
||
assert grace.endswith("s")
|
||
return int(grace[:-1])
|
||
|
||
|
||
def test_compose_backend_stop_grace_exceeds_drain_deadline():
|
||
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
|
||
|
||
assert _compose_stop_grace() > DRAIN_DEADLINE_SECONDS
|
||
|
||
|
||
def test_shutdown_budget_fits_stop_grace_and_dockerfile_matches():
|
||
"""[M2] uvicorn graceful + 백그라운드 대기 + drain + 여유(5s) ≤ stop_grace_period, Dockerfile CMD 값 일치."""
|
||
import json as _json
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
|
||
|
||
dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text()
|
||
cmd = [l for l in dockerfile.splitlines() if l.startswith("CMD ")][-1]
|
||
args = _json.loads(cmd[4:])
|
||
# [L-3] CMD 가 설정 키와 같은 이름의 환경변수를 읽고, 기본값이 설정 기본값과 같아야 한다
|
||
assert args[:2] == ["sh", "-c"] and args[2].startswith("exec uvicorn ")
|
||
m = re.search(r"--timeout-graceful-shutdown \$\{UVICORN_GRACEFUL_SHUTDOWN_SECONDS:-(\d+)\}", args[2])
|
||
assert m, args[2]
|
||
assert int(m.group(1)) == settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS
|
||
assert settings.CONTAINER_STOP_GRACE_SECONDS == _compose_stop_grace()
|
||
budget = (settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS + settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS
|
||
+ DRAIN_DEADLINE_SECONDS)
|
||
assert budget + 5 <= _compose_stop_grace(), (budget, _compose_stop_grace())
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# M-7: created_at 은 큐 투입 시각
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.asyncio
|
||
async def test_enqueue_trade_history_stamps_created_at(fresh_queue):
|
||
w = fresh_queue
|
||
payload = {"stock_code": "005930", "order_type": "BUY"}
|
||
before = w.now_kst()
|
||
await w.enqueue_trade_history(payload)
|
||
task = w.get_db_task_queue().get_nowait()
|
||
assert "created_at" not in payload, "호출자 dict 를 변경하면 안 된다"
|
||
assert task["data"]["created_at"] >= before
|
||
|
||
fixed = datetime(2026, 9, 1, 10, 0, 0)
|
||
await w.enqueue_trade_history({"stock_code": "1", "created_at": fixed})
|
||
assert w.get_db_task_queue().get_nowait()["data"]["created_at"] == fixed
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# H-3 / H-4: KIS 토큰
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _bare_api(tmp_path):
|
||
api = kis_mod.KoreaInvestmentAPI.__new__(kis_mod.KoreaInvestmentAPI)
|
||
api.access_token = None
|
||
api.token_expires_at = None
|
||
api._token_hard_expires_at = None
|
||
api._token_lock = asyncio.Lock()
|
||
api.TOKEN_CACHE_FILE = tmp_path / "kis_token_cache.json"
|
||
api._get_access_token = types.MethodType(_ORIG_GET_ACCESS_TOKEN, api)
|
||
return api
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_token_reissue_window_fails_fast_without_sleep(tmp_path, monkeypatch):
|
||
"""재현(검증): 직전 발급 실패 + 대기자 5 → 이전 구현은 락 안 70초×5 = 355초 직렬 sleep."""
|
||
api = _bare_api(tmp_path)
|
||
|
||
async def _no_sleep(*a, **k):
|
||
raise AssertionError("토큰 경로에서 sleep 금지 (H-3)")
|
||
|
||
monkeypatch.setattr(kis_mod.asyncio, "sleep", _no_sleep)
|
||
|
||
async def _must_not_issue():
|
||
raise AssertionError("재발급 최소 간격 안에서는 발급을 시도하면 안 된다")
|
||
|
||
api._request_new_token = _must_not_issue
|
||
api._last_token_request_time = datetime.now()
|
||
|
||
started = time.monotonic()
|
||
results = await asyncio.gather(*[api._get_access_token() for _ in range(5)], return_exceptions=True)
|
||
assert all(isinstance(r, kis_mod.KISTokenUnavailableError) for r in results), results
|
||
assert time.monotonic() - started < 1.0
|
||
assert 0 < results[0].retry_after_sec <= settings.KIS_TOKEN_REISSUE_MIN_INTERVAL_SEC
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_token_single_flight_one_issue_for_many_callers(tmp_path):
|
||
api = _bare_api(tmp_path)
|
||
calls = {"n": 0}
|
||
|
||
async def _issue():
|
||
calls["n"] += 1
|
||
await asyncio.sleep(0.05)
|
||
api.access_token = "NEW"
|
||
api.token_expires_at = datetime.now() + timedelta(hours=23)
|
||
return "NEW"
|
||
|
||
api._request_new_token = _issue
|
||
tokens = await asyncio.gather(*[api._get_access_token() for _ in range(5)])
|
||
assert tokens == ["NEW"] * 5
|
||
assert calls["n"] == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_make_request_acquires_token_outside_semaphore(tmp_path):
|
||
"""[H-3] 토큰을 못 얻으면 세마포어에 들어가지 않는다(다른 호출의 슬롯을 점유하지 않음)."""
|
||
api = _bare_api(tmp_path)
|
||
|
||
class _Breaker:
|
||
recovery_timeout = 120
|
||
|
||
def can_request(self):
|
||
return True
|
||
|
||
class _Sem:
|
||
entered = False
|
||
|
||
async def __aenter__(self):
|
||
_Sem.entered = True
|
||
raise AssertionError("토큰 없이 세마포어 진입")
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
api.circuit_breaker = _Breaker()
|
||
api.semaphore = _Sem()
|
||
|
||
async def _unavailable():
|
||
raise kis_mod.KISTokenUnavailableError("wait", retry_after_sec=30)
|
||
|
||
api._get_access_token = _unavailable
|
||
with pytest.raises(kis_mod.KISTokenUnavailableError):
|
||
await api._make_request("GET", "/uapi/x", "TR")
|
||
assert _Sem.entered is False
|
||
|
||
|
||
class _Resp:
|
||
def __init__(self, status, payload):
|
||
self.status_code = status
|
||
self._payload = payload
|
||
self.headers = {}
|
||
self.text = str(payload)
|
||
|
||
def json(self):
|
||
return self._payload
|
||
|
||
|
||
class _Client:
|
||
def __init__(self, responses):
|
||
self.responses = list(responses)
|
||
self.auth_headers: list[str] = []
|
||
|
||
async def request(self, method, url, headers=None, timeout=None, **kw):
|
||
self.auth_headers.append(headers["authorization"])
|
||
return self.responses.pop(0)
|
||
|
||
|
||
def _api_for_request(tmp_path, client, tokens):
|
||
api = _bare_api(tmp_path)
|
||
api.base_url = "https://example.invalid"
|
||
api.app_key = "k"
|
||
api.app_secret = "s"
|
||
api.last_request_time = None
|
||
api._throttle_lock = asyncio.Lock()
|
||
api.min_request_interval = 0
|
||
api.max_retries = 3
|
||
api.retry_delay = 0
|
||
api.retry_backoff = 1
|
||
api.timeout = 5
|
||
api.monitor = kis_mod.APICallMonitor(window_size=10)
|
||
api.circuit_breaker = kis_mod.CircuitBreaker(failure_threshold=7, recovery_timeout=120)
|
||
it = iter(tokens)
|
||
|
||
async def _tok():
|
||
if api.access_token is None:
|
||
api.access_token = next(it)
|
||
api.token_expires_at = datetime.now() + timedelta(hours=1)
|
||
return api.access_token
|
||
|
||
api._get_access_token = _tok
|
||
|
||
async def _client():
|
||
return client
|
||
|
||
api._get_http_client = _client
|
||
return api
|
||
|
||
|
||
class _TrackingSem:
|
||
"""세마포어 대체 — 진입 중 여부를 기록해 '세마포어 안에서 토큰 발급' 을 잡아낸다."""
|
||
|
||
def __init__(self):
|
||
self.held = False
|
||
|
||
async def __aenter__(self):
|
||
self.held = True
|
||
|
||
async def __aexit__(self, *a):
|
||
self.held = False
|
||
return False
|
||
|
||
|
||
def _with_sem(api):
|
||
api.semaphore = _TrackingSem()
|
||
issued_under_sem = []
|
||
inner = api._get_access_token
|
||
|
||
async def _tok():
|
||
if api.access_token is None:
|
||
issued_under_sem.append(api.semaphore.held)
|
||
return await inner()
|
||
|
||
api._get_access_token = _tok
|
||
return issued_under_sem
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invalid_token_response_clears_cache_and_retries_once(tmp_path):
|
||
client = _Client([
|
||
_Resp(500, {"rt_cd": "1", "msg_cd": "EGW00123", "msg1": "기간이 만료된 token 입니다."}),
|
||
_Resp(200, {"rt_cd": "0", "output": {"ok": 1}}),
|
||
])
|
||
api = _api_for_request(tmp_path, client, ["OLD", "NEW"])
|
||
issued_under_sem = _with_sem(api)
|
||
api.TOKEN_CACHE_FILE.write_text('{"access_token": "OLD"}')
|
||
|
||
data, _ = await api._make_request("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "TR")
|
||
assert data["rt_cd"] == "0"
|
||
assert client.auth_headers == ["Bearer OLD", "Bearer NEW"]
|
||
assert not api.TOKEN_CACHE_FILE.exists(), "무효 토큰 파일 캐시가 삭제돼야 한다(재기동 재사용 방지)"
|
||
assert issued_under_sem == [False, False], "토큰 발급은 세마포어 밖에서 (H-3 취지)"
|
||
assert api.circuit_breaker.failure_count == 0, "토큰 재발급 신호를 KIS 장애로 세면 안 된다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invalid_token_retry_is_bounded(tmp_path):
|
||
client = _Client([
|
||
_Resp(200, {"rt_cd": "1", "msg_cd": "EGW00121"}),
|
||
_Resp(200, {"rt_cd": "1", "msg_cd": "EGW00121"}),
|
||
])
|
||
api = _api_for_request(tmp_path, client, ["T1", "T2", "T3"])
|
||
_with_sem(api)
|
||
data, _ = await api._make_request("GET", "/uapi/q", "TR")
|
||
assert data["msg_cd"] == "EGW00121"
|
||
assert len(client.auth_headers) == 2, "재발급·재전송은 요청당 1회"
|
||
assert api.access_token is None
|
||
# 다음 요청은 다시 1회 재전송 기회를 가진다 (contextvar 가 새지 않음)
|
||
assert kis_mod._TOKEN_REFRESH_RETRIED.get() is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invalid_token_on_order_endpoint_is_not_resent(tmp_path):
|
||
client = _Client([_Resp(200, {"rt_cd": "1", "msg_cd": "EGW00123"})])
|
||
api = _api_for_request(tmp_path, client, ["OLD", "NEW"])
|
||
_with_sem(api)
|
||
data, _ = await api._make_request(
|
||
"POST", "/uapi/domestic-stock/v1/trading/order-cash", "TR"
|
||
)
|
||
assert data["msg_cd"] == "EGW00123"
|
||
assert len(client.auth_headers) == 1, "주문은 재전송하지 않는다"
|
||
assert api.access_token is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_place_order_presend_token_failure_is_tagged(tmp_path):
|
||
"""전송 전 토큰 부재 실패는 msg_cd=TOKEN_UNAVAILABLE — 호출부가 '미전송'을 구분할 수 있다."""
|
||
api = _bare_api(tmp_path)
|
||
api.base_url = "https://openapivts.example.invalid"
|
||
api.app_key = "k"
|
||
api.app_secret = "s"
|
||
api.account_number = "1234567801"
|
||
# conftest 가 place_order 를 차단하므로 원본을 직접 바인딩한다 (수집 시점 원본)
|
||
place_order = _ORIG_PLACE_ORDER
|
||
|
||
async def _unavailable(*a, **k):
|
||
raise kis_mod.KISTokenUnavailableError("wait", retry_after_sec=42)
|
||
|
||
api._make_request = _unavailable
|
||
res = await types.MethodType(place_order, api)("005930", "BUY", 1, 70000)
|
||
assert res["rt_cd"] == "-1"
|
||
assert res["msg_cd"] == kis_mod.KIS_PRESEND_TOKEN_UNAVAILABLE == "TOKEN_UNAVAILABLE"
|
||
assert res["odno"] is None and res["retry_after_sec"] == 42
|
||
|
||
|
||
def test_grace_extension_bounded_by_hard_expiry(tmp_path):
|
||
api = _bare_api(tmp_path)
|
||
api.access_token = "T"
|
||
# 실제 만료가 이미 지났으면 연장 금지 (이전: 무조건 +1h)
|
||
api._token_hard_expires_at = datetime.now() - timedelta(minutes=1)
|
||
assert api._grace_extend_existing_token() is None
|
||
# 실제 만료 전이면 짧게만 연장
|
||
api._token_hard_expires_at = datetime.now() + timedelta(minutes=40)
|
||
assert api._grace_extend_existing_token() == "T"
|
||
assert api.token_expires_at <= datetime.now() + timedelta(minutes=settings.KIS_TOKEN_GRACE_EXTEND_MINUTES, seconds=1)
|
||
assert api.token_expires_at < api._token_hard_expires_at
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_request_new_token_failure_without_hard_expiry_raises(tmp_path, monkeypatch):
|
||
api = _bare_api(tmp_path)
|
||
api.base_url = "https://example.invalid"
|
||
api.app_key = "k"
|
||
api.app_secret = "s"
|
||
api.access_token = "EXPIRED"
|
||
api._token_hard_expires_at = datetime.now() - timedelta(minutes=10)
|
||
api._request_new_token = types.MethodType(_ORIG_REQUEST_NEW_TOKEN, api)
|
||
|
||
class _FailingClient:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
async def post(self, *a, **k):
|
||
return _Resp(403, {"error_code": "EGW00133"})
|
||
|
||
monkeypatch.setattr(kis_mod.httpx, "AsyncClient", lambda *a, **k: _FailingClient())
|
||
with pytest.raises(Exception, match="Failed to get access token"):
|
||
await api._request_new_token()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# H-2 / M-5: 세션 검증
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.fixture
|
||
def security(monkeypatch):
|
||
from app.core import security as sec
|
||
|
||
sec.invalidate_session_user_cache()
|
||
yield sec
|
||
sec.invalidate_session_user_cache()
|
||
|
||
|
||
def _patch_flags(monkeypatch, sec, mapping, calls=None):
|
||
async def _fetch(username):
|
||
if calls is not None:
|
||
calls.append(username)
|
||
if username not in mapping:
|
||
raise RuntimeError("db down")
|
||
return mapping[username]
|
||
|
||
monkeypatch.setattr(sec, "_fetch_session_user_flags_impl", _fetch)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_revoked_user_session_rejected(security, monkeypatch):
|
||
sec = security
|
||
token, _ = sec.create_session_token("bob")
|
||
flags = {"bob": sec.SessionUserFlags(exists=True, is_active=True, is_admin=False)}
|
||
_patch_flags(monkeypatch, sec, flags)
|
||
assert await sec.is_valid_credential_v2(token) is True
|
||
assert await sec.require_api_key(token) == token
|
||
|
||
flags["bob"] = sec.SessionUserFlags(exists=True, is_active=False, is_admin=False)
|
||
sec.invalidate_session_user_cache("bob") # revoke_user 가 호출하는 것과 동일
|
||
assert await sec.is_valid_credential_v2(token) is False
|
||
with pytest.raises(HTTPException) as ei:
|
||
await sec.require_api_key(token)
|
||
assert ei.value.status_code == 401
|
||
# 레거시 동기 판정은 여전히 서명만 본다 — 인증 경로가 이것을 쓰면 안 되는 이유
|
||
assert sec.is_valid_credential(token) is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_flags_cached_with_ttl(security, monkeypatch):
|
||
sec = security
|
||
calls: list[str] = []
|
||
_patch_flags(monkeypatch, sec, {"bob": sec.SessionUserFlags(True, True, False)}, calls)
|
||
await sec.get_session_user_flags("bob")
|
||
await sec.get_session_user_flags("bob")
|
||
assert calls == ["bob"]
|
||
monkeypatch.setattr(settings, "SESSION_ACTIVE_CACHE_SECONDS", 0)
|
||
await sec.get_session_user_flags("bob")
|
||
assert calls == ["bob", "bob"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_lookup_failure_503_without_cache_stale_with_cache(security, monkeypatch):
|
||
sec = security
|
||
_patch_flags(monkeypatch, sec, {})
|
||
with pytest.raises(HTTPException) as ei:
|
||
await sec.get_session_user_flags("carol")
|
||
assert ei.value.status_code == 503, "DB 일시 장애로 401(로그아웃)을 내면 안 된다"
|
||
|
||
sec._session_user_cache["carol"] = (sec.SessionUserFlags(True, True, False), time.monotonic() - 9999)
|
||
flags = await sec.get_session_user_flags("carol")
|
||
assert flags.is_active is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_api_key_is_admin_without_db(security, monkeypatch):
|
||
sec = security
|
||
_patch_flags(monkeypatch, sec, {}) # DB 를 부르면 실패
|
||
info = await sec.resolve_credential_v2(sec._get_api_key())
|
||
assert info is not None and info.kind == "api_key" and info.is_admin
|
||
|
||
|
||
def test_session_signing_key_separated_from_api_key(security, monkeypatch):
|
||
"""[M-5] API_KEY 미설정이면 API 키 = SECRET_KEY. 그 키로 직접 서명한 토큰은 거부돼야 한다."""
|
||
from jose import jwt
|
||
|
||
sec = security
|
||
monkeypatch.setattr(settings, "API_KEY", "")
|
||
forged = jwt.encode(
|
||
{"sub": "admin", "aud": sec._SESSION_AUD, "exp": datetime.utcnow() + timedelta(hours=1)},
|
||
sec._get_api_key(), algorithm=sec._SESSION_ALG,
|
||
)
|
||
assert sec.verify_session_token(forged) is None
|
||
good, _ = sec.create_session_token("admin")
|
||
assert sec.verify_session_token(good) == "admin"
|
||
|
||
# API_KEY 가 별도로 있으면 배포 전 발급분(SECRET_KEY 서명)을 하위호환으로 받는다
|
||
monkeypatch.setattr(settings, "API_KEY", "x" * 80)
|
||
legacy = jwt.encode(
|
||
{"sub": "admin", "aud": sec._SESSION_AUD, "exp": datetime.utcnow() + timedelta(hours=1)},
|
||
settings.SECRET_KEY, algorithm=sec._SESSION_ALG,
|
||
)
|
||
assert sec.verify_session_token(legacy) == "admin"
|
||
|
||
|
||
def test_security_key_issues(monkeypatch):
|
||
from app.core.security import security_key_issues
|
||
|
||
monkeypatch.setattr(settings, "SECRET_KEY", "change-this-secret-key")
|
||
monkeypatch.setattr(settings, "API_KEY", "")
|
||
issues = security_key_issues()
|
||
assert any("SECRET_KEY" in i for i in issues) and any("API_KEY" in i for i in issues)
|
||
strong = "a" * settings.MIN_SECRET_KEY_LENGTH
|
||
monkeypatch.setattr(settings, "SECRET_KEY", strong)
|
||
monkeypatch.setattr(settings, "API_KEY", "b" * settings.MIN_SECRET_KEY_LENGTH)
|
||
assert security_key_issues() == []
|
||
monkeypatch.setattr(settings, "SECRET_KEY", strong[:-1])
|
||
assert security_key_issues()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# H-1 / H-2: 라우트 인증 정책 (앱 라우트 테이블 기준 — 핸들러 실행 없음)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _route_dep_calls():
|
||
from fastapi.routing import APIRoute
|
||
|
||
from app.main import app
|
||
|
||
out = {}
|
||
for r in app.routes:
|
||
if not isinstance(r, APIRoute):
|
||
continue
|
||
|
||
def _walk(dep, acc):
|
||
for d in dep.dependencies:
|
||
if d.call is not None:
|
||
acc.add(getattr(d.call, "__name__", str(d.call)))
|
||
_walk(d, acc)
|
||
|
||
acc: set = set()
|
||
_walk(r.dependant, acc)
|
||
for m in r.methods:
|
||
out[(m, r.path)] = acc
|
||
return out
|
||
|
||
|
||
_MUST_REQUIRE_KEY = [
|
||
("GET", "/api/v1/journals"),
|
||
("GET", "/api/v1/journals/stats"),
|
||
("GET", "/api/v1/analysis/performance"),
|
||
("GET", "/api/v1/analysis/insights"),
|
||
("GET", "/api/v1/market-intelligence/dashboard"),
|
||
("GET", "/api/v1/market-data/price/{stock_code}"),
|
||
("GET", "/api/v1/market-data/prices"),
|
||
("GET", "/api/v1/market-data/chart/{stock_code}"),
|
||
("GET", "/api/v1/market-data/orderbook/{stock_code}"),
|
||
("GET", "/api/v1/market-data/ai-analysis/{stock_code}"),
|
||
("GET", "/api/v1/market-data/overseas/price/{ticker}"),
|
||
("GET", "/api/v1/ml-ensemble/predict/{stock_code}"),
|
||
]
|
||
|
||
|
||
def test_sensitive_gets_require_api_key():
|
||
deps = _route_dep_calls()
|
||
for key in _MUST_REQUIRE_KEY:
|
||
assert key in deps, f"라우트 없음(경로 변경?): {key}"
|
||
assert "require_api_key" in deps[key], f"인증 없이 열려 있음: {key}"
|
||
|
||
|
||
def test_public_market_indices_stays_public():
|
||
deps = _route_dep_calls()
|
||
d = deps[("GET", "/api/v1/market/indices")]
|
||
assert "require_api_key" not in d
|
||
|
||
|
||
def test_admin_only_routes_exist_and_are_gated():
|
||
"""ADMIN_ONLY_ROUTES 의 오타는 '보호가 조용히 빠짐'이 되므로 실제 라우트와 대조한다."""
|
||
from app.core.security import ADMIN_ONLY_ROUTES, is_admin_only_route
|
||
|
||
deps = _route_dep_calls()
|
||
for method, path in ADMIN_ONLY_ROUTES:
|
||
full = "/api/v1" + path
|
||
assert (method, full) in deps, f"ADMIN_ONLY_ROUTES 항목에 해당 라우트 없음: {method} {full}"
|
||
assert "require_admin_for_listed_routes" in deps[(method, full)]
|
||
assert is_admin_only_route(method, full)
|
||
assert is_admin_only_route("GET", "/api/v1/debug/debug/autotrade-status")
|
||
assert not is_admin_only_route("GET", "/api/v1/strategy-config")
|
||
assert not is_admin_only_route("POST", "/api/v1/daytrading/watchlist")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unauthenticated_sensitive_get_is_401_and_non_admin_write_is_403(security, monkeypatch):
|
||
import httpx
|
||
from httpx import ASGITransport
|
||
|
||
from app.main import app
|
||
|
||
sec = security
|
||
_patch_flags(monkeypatch, sec, {"bob": sec.SessionUserFlags(True, True, False)})
|
||
bob, _ = sec.create_session_token("bob")
|
||
transport = ASGITransport(app=app)
|
||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||
for path in ("/api/v1/journals", "/api/v1/analysis/performance",
|
||
"/api/v1/market-intelligence/dashboard", "/api/v1/market-data/price/005930",
|
||
"/api/v1/ml-ensemble/predict/005930"):
|
||
r = await c.get(path)
|
||
assert r.status_code == 401, (path, r.status_code)
|
||
r = await c.post("/api/v1/risk/mdd/reset", headers={"X-API-Key": bob})
|
||
assert r.status_code == 403
|
||
r = await c.post("/api/v1/daytrading/close-all", headers={"X-API-Key": bob})
|
||
assert r.status_code == 403
|
||
r = await c.post("/api/admin/reset-daily-loss", headers={"X-API-Key": bob})
|
||
assert r.status_code == 403
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# M-2: 클라이언트 IP
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
class _Req:
|
||
def __init__(self, peer, headers):
|
||
self.client = types.SimpleNamespace(host=peer) if peer else None
|
||
self.headers = {k.lower(): v for k, v in headers.items()}
|
||
|
||
|
||
def test_client_ip_ignores_spoofed_xff_and_trusts_proxy_real_ip():
|
||
from app.core.security import client_ip_v2
|
||
|
||
# 외부에서 직접: XFF/X-Real-IP 위조 무시
|
||
assert client_ip_v2(_Req("8.8.8.8", {"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "5.6.7.8"})) == "8.8.8.8"
|
||
# 신뢰 프록시(nginx 컨테이너): X-Real-IP 사용, XFF 무시
|
||
assert client_ip_v2(_Req("172.18.0.5", {"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "203.0.113.9"})) == "203.0.113.9"
|
||
# 신뢰 프록시인데 헤더 없음 / 형식 불량 → 소켓 주소
|
||
assert client_ip_v2(_Req("172.18.0.5", {})) == "172.18.0.5"
|
||
assert client_ip_v2(_Req("172.18.0.5", {"X-Real-IP": "not-an-ip"})) == "172.18.0.5"
|
||
|
||
|
||
def test_auth_login_ip_uses_client_ip_v2():
|
||
from app.api.endpoints import auth
|
||
|
||
assert auth._client_ip(_Req("8.8.8.8", {"X-Forwarded-For": "9.9.9.9"})) == "8.8.8.8"
|
||
|
||
|
||
def test_nginx_overwrites_forwarding_headers():
|
||
from pathlib import Path
|
||
|
||
conf = (Path(__file__).resolve().parents[2] / "frontend" / "nginx.conf").read_text()
|
||
# 백엔드로 가는 모든 proxy_pass 블록(/health, /api, /ws)이 전달 헤더를 덮어써야 한다
|
||
n_proxy = conf.count("proxy_pass http://backend:8000")
|
||
assert n_proxy == 3
|
||
assert conf.count("proxy_set_header X-Real-IP $remote_addr;") == n_proxy
|
||
assert conf.count("proxy_set_header X-Forwarded-For $remote_addr;") == n_proxy
|
||
assert "real_ip_recursive on;" in conf
|
||
|
||
|
||
def test_health_is_proxied_to_backend_not_spa():
|
||
"""Layout.tsx 의 fetch('/health') 가 SPA index.html(항상 200)에 응답받던 결함."""
|
||
from pathlib import Path
|
||
|
||
root = Path(__file__).resolve().parents[2]
|
||
conf = (root / "frontend" / "nginx.conf").read_text()
|
||
assert "location = /health {" in conf and "proxy_pass http://backend:8000/health;" in conf
|
||
assert "'/health': {" in (root / "frontend" / "vite.config.ts").read_text()
|
||
|
||
|
||
def test_cors_allows_patch():
|
||
from starlette.middleware.cors import CORSMiddleware
|
||
|
||
from app.main import app
|
||
|
||
mw = [m for m in app.user_middleware if m.cls is CORSMiddleware][0]
|
||
assert "PATCH" in mw.kwargs["allow_methods"]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# M-6: WebSocket 토큰 로그 마스킹
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_uvicorn_ws_log_token_redacted():
|
||
from app.core.logging_config import QueryTokenRedactionFilter
|
||
|
||
rec = logging.LogRecord(
|
||
"uvicorn.error", logging.INFO, __file__, 1, '%s - "WebSocket %s" [accepted]',
|
||
(("1.2.3.4", 5), "/ws/abc?token=eyJ.SECRET.sig&x=1"), None,
|
||
)
|
||
QueryTokenRedactionFilter().filter(rec)
|
||
msg = rec.getMessage()
|
||
assert "SECRET" not in msg and "token=***" in msg and "x=1" in msg
|
||
|
||
|
||
def test_redaction_filter_installed_on_uvicorn_loggers():
|
||
import app.main # noqa: F401 — import 시 설치
|
||
from app.core.logging_config import QueryTokenRedactionFilter
|
||
|
||
for name in ("uvicorn.error", "uvicorn.access"):
|
||
assert any(isinstance(f, QueryTokenRedactionFilter) for f in logging.getLogger(name).filters)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# L-4: KRX 휴장
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_krx_specific_holidays(monkeypatch):
|
||
from app.core.market_schedule import is_krx_holiday
|
||
|
||
assert is_krx_holiday(date(2026, 5, 1)) # 근로자의날
|
||
assert is_krx_holiday(date(2026, 12, 31)) # 연말 휴장 (목)
|
||
assert is_krx_holiday(date(2026, 9, 25)) # 추석 (holidays.KR)
|
||
assert not is_krx_holiday(date(2026, 10, 2)) # 평일
|
||
# 12/31 이 일요일인 2028 → 직전 영업일 12/29(금)
|
||
assert is_krx_holiday(date(2028, 12, 29))
|
||
assert not is_krx_holiday(date(2028, 12, 28))
|
||
monkeypatch.setattr(settings, "KRX_EXTRA_HOLIDAYS", "2026-10-07, bad")
|
||
assert is_krx_holiday(date(2026, 10, 7))
|
||
|
||
|
||
def test_ai_strategy_scheduler_checks_holiday():
|
||
import inspect
|
||
|
||
from app import main
|
||
|
||
assert "is_krx_holiday(today)" in inspect.getsource(main.lifespan)
|
||
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 3회차 — M-A: in-flight close/upsert 재실행 멱등성
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
class _Row:
|
||
def __init__(self, id, status="active", remaining_pct=1.0):
|
||
self.id = id
|
||
self.status = status
|
||
self.remaining_pct = remaining_pct
|
||
self.updated_at = None
|
||
|
||
|
||
class _FakeActiveTradeSession:
|
||
"""select(ActiveTrade) 를 흉내 — id 고정 조회와 '가장 최근 active' 조회를 구분한다."""
|
||
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
self.commits = 0
|
||
|
||
async def execute(self, query):
|
||
text = str(query)
|
||
params = query.compile().params
|
||
actives = [r for r in self.rows if r.status == "active"]
|
||
if "active_trades.id =" in text:
|
||
pid = [v for k, v in params.items() if k.startswith("id")][0]
|
||
hit = [r for r in actives if r.id == pid]
|
||
else:
|
||
hit = sorted(actives, key=lambda r: r.id, reverse=True)[:1]
|
||
|
||
class _R:
|
||
def scalars(self_inner):
|
||
class _S:
|
||
def first(self_s):
|
||
return hit[0] if hit else None
|
||
return _S()
|
||
return _R()
|
||
|
||
async def commit(self):
|
||
self.commits += 1
|
||
|
||
def add(self, obj):
|
||
self.rows.append(obj)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_close_active_trade_rerun_is_idempotent():
|
||
"""재현(3회차 검증): 같은 close 를 두 번 실행하면 다음 active 행까지 닫혔다."""
|
||
from app.services import db_worker as w
|
||
|
||
rows = [_Row(1), _Row(2)] # 2 = 가장 최근(예: 스윙), 1 = 같은 종목 다른 포지션(예: DT)
|
||
sess = _FakeActiveTradeSession(rows)
|
||
data = {"stock_code": "005930"}
|
||
await w._handle_close_active_trade(sess, data)
|
||
await w._handle_close_active_trade(sess, data) # 종료 drain 의 in-flight 재실행
|
||
assert [r.status for r in rows] == ["active", "closed"], "두 번째 실행이 다른 행을 닫으면 안 된다"
|
||
assert data["_target_id"] == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_close_active_trade_no_target_rerun_is_noop():
|
||
from app.services import db_worker as w
|
||
|
||
rows = []
|
||
sess = _FakeActiveTradeSession(rows)
|
||
data = {"stock_code": "005930"}
|
||
await w._handle_close_active_trade(sess, data)
|
||
rows.append(_Row(7)) # 그 사이 새 포지션 진입
|
||
await w._handle_close_active_trade(sess, data)
|
||
assert rows[0].status == "active", "처음에 대상이 없던 close 의 재실행이 새 포지션을 닫으면 안 된다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_upsert_multiply_rerun_is_idempotent_but_retry_remultiplies():
|
||
from app.services import db_worker as w
|
||
|
||
row = _Row(5, remaining_pct=1.0)
|
||
sess = _FakeActiveTradeSession([row])
|
||
data = {"stock_code": "005930", "remaining_pct_multiply": 0.7}
|
||
await w._handle_upsert_active_trade(sess, data)
|
||
await w._handle_upsert_active_trade(sess, data) # 커밋 여부 불명 재실행
|
||
assert row.remaining_pct == pytest.approx(0.7)
|
||
assert "_applied_remaining_pct" not in row.__dict__ and "_target_id" not in row.__dict__
|
||
|
||
# 정상 재시도 경로: 실패 실행의 절대값 마커를 지우고 다시 곱한다(그 사이 다른 갱신 보존)
|
||
task = {"type": "upsert_active_trade", "data": data}
|
||
w._strip_retry_markers(task)
|
||
row.remaining_pct = 0.5 # 다른 태스크가 먼저 반영
|
||
await w._handle_upsert_active_trade(sess, task["data"])
|
||
assert row.remaining_pct == pytest.approx(0.35)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_inflight_close_is_reexecuted_with_pin_not_dlq(fresh_queue, monkeypatch):
|
||
"""종료 drain: 처리 중 close 는 고정 id 로 재실행(멱등) — 다음 행을 닫지 않는다."""
|
||
w = fresh_queue
|
||
closed: list = []
|
||
dlq: list = []
|
||
|
||
async def _slow_close(session, data):
|
||
data["_target_id"] = 2
|
||
await asyncio.sleep(5) # 커밋 대기 중 cancel
|
||
|
||
async def _drain_close(session, data):
|
||
closed.append(data.get("_target_id"))
|
||
|
||
class _Sess:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
async def _fake_dlq(task, err):
|
||
dlq.append(task["type"])
|
||
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _Sess())
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
monkeypatch.setattr(w, "_handle_close_active_trade", _slow_close)
|
||
q = w.get_db_task_queue()
|
||
worker = asyncio.create_task(w.db_writer_worker())
|
||
await q.put({"type": "close_active_trade", "data": {"stock_code": "005930"}})
|
||
await asyncio.sleep(0.1)
|
||
monkeypatch.setattr(w, "_handle_close_active_trade", _drain_close)
|
||
worker.cancel()
|
||
await worker
|
||
assert closed == [2], "drain 재실행이 고정된 대상 id 를 써야 한다"
|
||
assert dlq == []
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 3회차 — M-B: 커밋 여부 불명 DLQ 의 재큐잉 검증
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.fixture
|
||
def dlq_dirs(tmp_path, monkeypatch):
|
||
from app.services import db_worker as w
|
||
|
||
monkeypatch.setattr(w, "DLQ_DIR", tmp_path)
|
||
monkeypatch.setattr(w, "DLQ_PROCESSED_DIR", tmp_path / "processed")
|
||
monkeypatch.setattr(w, "DLQ_EXPIRED_DIR", tmp_path / "expired")
|
||
monkeypatch.setattr(w, "DLQ_MANUAL_DIR", tmp_path / "manual")
|
||
requeued: list = []
|
||
|
||
async def _enq(task):
|
||
requeued.append(task)
|
||
|
||
monkeypatch.setattr(w, "enqueue_db_write", _enq)
|
||
return w, tmp_path, requeued
|
||
|
||
|
||
def _write_dlq(path, name, task, error):
|
||
import json
|
||
|
||
(path / name).write_text(json.dumps({
|
||
"task": task, "error": error, "failed_at": datetime.now().isoformat(), "retry_count": 0,
|
||
}))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_commit_unknown_insert_without_order_id_goes_manual(dlq_dirs):
|
||
w, d, requeued = dlq_dirs
|
||
_write_dlq(d, "a.json", {"type": "insert_trade_history", "data": {"stock_code": "005930"}},
|
||
w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
_write_dlq(d, "b.json", {"type": "insert_trade_journal", "data": {"stock_code": "005930"}},
|
||
w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
assert await w.replay_dlq() == 0
|
||
assert requeued == []
|
||
assert sorted(p.name for p in (d / "manual").iterdir()) == ["a.json", "b.json"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_commit_unknown_insert_with_existing_order_id_is_quarantined(dlq_dirs, monkeypatch):
|
||
w, d, requeued = dlq_dirs
|
||
seen = []
|
||
|
||
async def _exists(data, entry):
|
||
seen.append(data["order_id"])
|
||
return data["order_id"] == "0001"
|
||
|
||
monkeypatch.setattr(w, "_dlq_trade_history_exists", _exists)
|
||
for name, oid in (("a.json", "0001"), ("b.json", "0002")):
|
||
_write_dlq(d, name, {"type": "insert_trade_history",
|
||
"data": {"stock_code": "005930", "order_id": oid, "order_type": "BUY"}},
|
||
w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
assert await w.replay_dlq() == 1
|
||
assert [t["data"]["order_id"] for t in requeued] == ["0002"]
|
||
assert [p.name for p in (d / "expired").iterdir()] == ["a.json"]
|
||
assert sorted(seen) == ["0001", "0002"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_commit_unknown_insert_db_unavailable_goes_manual(dlq_dirs, monkeypatch):
|
||
w, d, requeued = dlq_dirs
|
||
|
||
async def _boom(data, entry):
|
||
raise RuntimeError("db down")
|
||
|
||
monkeypatch.setattr(w, "_dlq_trade_history_exists", _boom)
|
||
_write_dlq(d, "a.json", {"type": "insert_trade_history",
|
||
"data": {"stock_code": "005930", "order_id": "0001"}},
|
||
w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
assert await w.replay_dlq() == 0
|
||
assert requeued == []
|
||
assert [p.name for p in (d / "manual").iterdir()] == ["a.json"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_normal_dlq_insert_duplicate_order_id_is_quarantined(dlq_dirs, monkeypatch):
|
||
"""사유와 무관하게 같은 order_id 행이 이미 있으면 재큐잉하지 않는다(_dlq_state_conflict)."""
|
||
w, d, requeued = dlq_dirs
|
||
|
||
async def _exists(data, entry):
|
||
return True
|
||
|
||
monkeypatch.setattr(w, "_dlq_trade_history_exists", _exists)
|
||
_write_dlq(d, "a.json", {"type": "insert_trade_history",
|
||
"data": {"stock_code": "005930", "order_id": "0001"}}, "db down")
|
||
assert await w.replay_dlq() == 0
|
||
assert [p.name for p in (d / "expired").iterdir()] == ["a.json"]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 3회차 — M-D: 워커 종료 후 enqueue / 종료 순서
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.asyncio
|
||
async def test_enqueue_after_worker_shutdown_goes_to_dlq(fresh_queue, monkeypatch):
|
||
w = fresh_queue
|
||
dlq: list = []
|
||
|
||
async def _fake_dlq(task, err):
|
||
dlq.append((task["type"], err))
|
||
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
worker = asyncio.create_task(w.db_writer_worker())
|
||
await asyncio.sleep(0.05)
|
||
worker.cancel()
|
||
await worker
|
||
await w.enqueue_trade_history({"stock_code": "LATE"}) # shield 된 청산 마무리가 늦게 도착
|
||
assert dlq == [("insert_trade_history", "enqueued_after_worker_shutdown")]
|
||
assert w.get_db_task_queue().qsize() == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_await_background_tasks_before_worker(monkeypatch):
|
||
from app import main
|
||
|
||
done = []
|
||
|
||
async def _bg():
|
||
await asyncio.sleep(0.05)
|
||
done.append(1)
|
||
|
||
async def _hang():
|
||
await asyncio.sleep(60)
|
||
|
||
monkeypatch.setattr(main, "_background_tasks", set())
|
||
main._spawn_background(_bg())
|
||
t = main._spawn_background(_hang())
|
||
started = time.monotonic()
|
||
await main._await_background_tasks(0.3)
|
||
assert done == [1]
|
||
assert t.cancelled() and time.monotonic() - started < 2
|
||
|
||
|
||
def test_shutdown_awaits_background_before_db_worker_within_grace():
|
||
import inspect
|
||
|
||
from app import main
|
||
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
|
||
|
||
block = inspect.getsource(main.lifespan)
|
||
block = block[block.index("# 태스크 정리"):]
|
||
assert block.index('"__background__"') < block.index('"db_worker_task"')
|
||
assert settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS + DRAIN_DEADLINE_SECONDS < _compose_stop_grace()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 3회차 — 회귀 가드: 공개 허용 목록 외 모든 라우트는 require_api_key
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
_PUBLIC_ALLOWLIST = {
|
||
("POST", "/api/v1/auth/login"),
|
||
("POST", "/api/v1/auth/register"),
|
||
("GET", "/api/v1/market/indices"),
|
||
("GET", "/"),
|
||
("GET", "/health"),
|
||
}
|
||
# FastAPI 가 자동 등록하는 문서 라우트 (DEBUG 무관 기본 활성)
|
||
_FRAMEWORK_ROUTES = {"/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||
|
||
|
||
# APIRoute 가 아닌 라우트(WebSocket·Starlette Route·Mount) — 전수 목록. 새로 생기면 이 테스트가 실패한다.
|
||
# /ws/{client_id}: 핸들러 안에서 is_valid_credential_v2 로 인증(아래 WS 테스트가 4001 을 확인)
|
||
_NON_API_ROUTES_ALLOWED = {"/ws/{client_id}", "/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||
|
||
|
||
def test_no_unexpected_non_api_routes():
|
||
from fastapi.routing import APIRoute
|
||
|
||
from app.main import app
|
||
|
||
others = {getattr(r, "path", repr(r)) for r in app.routes if not isinstance(r, APIRoute)}
|
||
assert others <= _NON_API_ROUTES_ALLOWED, f"검토되지 않은 비-API 라우트: {sorted(others - _NON_API_ROUTES_ALLOWED)}"
|
||
assert "/ws/{client_id}" in others
|
||
|
||
|
||
def test_every_non_public_route_requires_api_key():
|
||
from fastapi.routing import APIRoute
|
||
|
||
from app.main import app
|
||
|
||
deps = _route_dep_calls()
|
||
open_routes = []
|
||
for r in app.routes:
|
||
if not isinstance(r, APIRoute) or r.path in _FRAMEWORK_ROUTES:
|
||
continue
|
||
for m in r.methods:
|
||
key = (m, r.path)
|
||
if key in _PUBLIC_ALLOWLIST:
|
||
continue
|
||
calls = deps.get(key, set())
|
||
if not ({"require_api_key", "require_admin_v2"} & calls):
|
||
open_routes.append(key)
|
||
assert open_routes == [], f"인증 없이 열린 라우트(공개 허용 목록 외): {sorted(open_routes)}"
|
||
# 허용 목록이 실재하는지(경로가 바뀌면 이 가드가 조용히 약해지지 않게)
|
||
for key in _PUBLIC_ALLOWLIST:
|
||
assert key in deps, f"공개 허용 목록의 라우트가 없음: {key}"
|
||
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — M1: drain 예산은 항목 실행 중에도 강제된다 (실제 시간 검증)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
class _HangSession:
|
||
def __init__(self, delay):
|
||
self.delay = delay
|
||
|
||
async def __aenter__(self):
|
||
await asyncio.sleep(self.delay) # asyncpg connect timeout(≈60s) 대체
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_drain_hung_db_respects_budget_and_dlqs_everything(monkeypatch):
|
||
from app.services import db_worker as w
|
||
|
||
monkeypatch.setattr(w, "DRAIN_DEADLINE_SECONDS", 0.3)
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _HangSession(5))
|
||
saved = []
|
||
|
||
async def _fake_dlq(task, err):
|
||
saved.append((task["data"]["stock_code"], err))
|
||
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
q = asyncio.Queue()
|
||
q.put_nowait({"type": "insert_trade_history", "data": {"stock_code": "S0"}})
|
||
for i in (1, 2):
|
||
q.put_nowait({"type": "close_active_trade", "data": {"stock_code": f"S{i}"}})
|
||
t0 = time.monotonic()
|
||
await w._drain_on_shutdown_impl(q, None, [], set())
|
||
elapsed = time.monotonic() - t0
|
||
assert elapsed < 1.0, f"예산 0.3s 인데 {elapsed:.2f}s (수정 전: 항목당 세션 대기 전부 소비)"
|
||
assert [c for c, _ in saved] == ["S0", "S1", "S2"], "남은 항목이 모두 DLQ 에 남아야 한다"
|
||
# 커밋 중 끊긴 비멱등 INSERT 는 '커밋 여부 불명' — replay 가 중복 확인 후에만 재큐잉
|
||
assert saved[0][1] == w._DLQ_COMMIT_UNKNOWN_REASON
|
||
assert saved[1][1] == saved[2][1] == "shutdown_drain_deadline_exceeded"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_second_drain_pass_shares_first_budget(monkeypatch):
|
||
from app.services import db_worker as w
|
||
|
||
opened = []
|
||
|
||
def _session():
|
||
opened.append(1)
|
||
return _HangSession(0)
|
||
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", _session)
|
||
saved = []
|
||
|
||
async def _fake_dlq(task, err):
|
||
saved.append(err)
|
||
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
q = asyncio.Queue()
|
||
q.put_nowait({"type": "close_active_trade", "data": {"stock_code": "A"}})
|
||
await w._drain_on_shutdown_impl(
|
||
q, None, [], set(), drain_started=time.monotonic() - w.DRAIN_DEADLINE_SECONDS - 1
|
||
)
|
||
assert opened == [] and saved == ["shutdown_drain_deadline_exceeded"]
|
||
|
||
import inspect
|
||
src = inspect.getsource(w.db_writer_worker)
|
||
assert src.count("drain_started=_drain_started") == 2, "워커의 두 drain 호출이 같은 시작 시각을 공유해야 한다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dlq_only_types_do_not_open_session(monkeypatch):
|
||
from app.services import db_worker as w
|
||
|
||
def _no_session():
|
||
raise AssertionError("DLQ 전용 타입이 세션을 열면 DB 행 시 예산을 잡아먹는다")
|
||
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", _no_session)
|
||
saved = []
|
||
|
||
async def _fake_dlq(task, err):
|
||
saved.append((task["type"], err))
|
||
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
q = asyncio.Queue()
|
||
q.put_nowait({"type": "upsert_stock_analysis", "data": {}})
|
||
q.put_nowait({"type": "weird", "data": {}})
|
||
await w._drain_on_shutdown_impl(q, None, [], set())
|
||
assert saved == [("upsert_stock_analysis", "shutdown_drain"), ("weird", "shutdown_drain_unknown_type")]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — L4: log_mcp_deny 는 재실행하지 않는다
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.asyncio
|
||
async def test_inflight_log_mcp_deny_not_reexecuted_and_not_requeued(monkeypatch, dlq_dirs):
|
||
w, d, requeued = dlq_dirs
|
||
saved = []
|
||
|
||
async def _fake_dlq(task, err):
|
||
saved.append((task["type"], err))
|
||
|
||
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
|
||
await w._drain_on_shutdown_impl(asyncio.Queue(), {"type": "log_mcp_deny", "data": {}}, [], set())
|
||
assert saved == [("log_mcp_deny", w._DLQ_COMMIT_UNKNOWN_REASON)]
|
||
_write_dlq(d, "m.json", {"type": "log_mcp_deny", "data": {"stock_code": "x"}}, w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
assert await w.replay_dlq() == 0 and requeued == []
|
||
assert [p.name for p in (d / "expired").iterdir()] == ["m.json"]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — L2: 주문번호 중복 확인은 같은 날만
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_dlq_ref_day_bounds_same_day():
|
||
from app.services import db_worker as w
|
||
|
||
start, end = w._dlq_ref_day_bounds({"created_at": "2026-10-01T15:10:00"}, {})
|
||
assert (start, end) == (datetime(2026, 10, 1), datetime(2026, 10, 2))
|
||
start, _ = w._dlq_ref_day_bounds({"order_date": "2026-09-30", "created_at": "2026-10-01T09:00:00"}, {})
|
||
assert start == datetime(2026, 9, 30)
|
||
# 날짜 단서 없음 → failed_at (재처리 시점 '오늘'이 아니다)
|
||
start, _ = w._dlq_ref_day_bounds({}, {"failed_at": "2026-09-29T23:59:00"})
|
||
assert start == datetime(2026, 9, 29)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — L3: 커밋 여부 불명 일지는 존재 확인 후에만 재큐잉
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.parametrize("exists, dest, n_requeued", [(True, "expired", 0), (None, "manual", 0), (False, None, 1)])
|
||
@pytest.mark.asyncio
|
||
async def test_commit_unknown_journal_verified_before_requeue(dlq_dirs, monkeypatch, exists, dest, n_requeued):
|
||
w, d, requeued = dlq_dirs
|
||
|
||
async def _exists(data, entry):
|
||
return exists
|
||
|
||
monkeypatch.setattr(w, "_dlq_trade_journal_exists", _exists)
|
||
_write_dlq(d, "j.json", {"type": "insert_trade_journal",
|
||
"data": {"stock_code": "005930", "order_id": "0001"}},
|
||
w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
assert await w.replay_dlq() == n_requeued
|
||
assert len(requeued) == n_requeued
|
||
if dest:
|
||
assert [p.name for p in (d / dest).iterdir()] == ["j.json"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_commit_unknown_journal_check_error_goes_manual(dlq_dirs, monkeypatch):
|
||
w, d, requeued = dlq_dirs
|
||
|
||
async def _boom(data, entry):
|
||
raise RuntimeError("db down")
|
||
|
||
monkeypatch.setattr(w, "_dlq_trade_journal_exists", _boom)
|
||
_write_dlq(d, "j.json", {"type": "insert_trade_journal", "data": {"trade_id": 9}},
|
||
w._DLQ_COMMIT_UNKNOWN_REASON)
|
||
assert await w.replay_dlq() == 0
|
||
assert [p.name for p in (d / "manual").iterdir()] == ["j.json"]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — L1: DLQ 건수 노출 (내용 비공개)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.asyncio
|
||
async def test_dlq_status_counts_and_health_field(dlq_dirs, monkeypatch):
|
||
w, d, _ = dlq_dirs
|
||
_write_dlq(d, "p.json", {"type": "x", "data": {}}, "e")
|
||
(d / "manual").mkdir()
|
||
_write_dlq(d / "manual", "m1.json", {"type": "x", "data": {}}, "e")
|
||
_write_dlq(d / "manual", "m2.json", {"type": "x", "data": {}}, "e")
|
||
assert await w.dlq_status_counts() == {"pending": 1, "manual": 2, "expired": 0}
|
||
|
||
# /health 는 DB·Redis 를 실제로 확인하므로 유닛에서는 응답 구조를 소스로 고정한다
|
||
# (건수만 노출, expired 비노출 — 정보 최소화)
|
||
import inspect
|
||
|
||
from app import main
|
||
src = inspect.getsource(main._health_check_impl)
|
||
assert '"dlq": dlq_info' in src and '"manual": _dlq["manual"]' in src
|
||
assert "_dlq[\"expired\"]" not in src.split("dlq_info = {")[1].split("}")[0]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — L5: WebSocket 인증 (DEBUG=False)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _ws_connect(path):
|
||
from starlette.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
client = TestClient(app) # context manager 미사용 → lifespan(스케줄러·KIS) 미기동
|
||
with client.websocket_connect(path) as ws:
|
||
ws.receive_text()
|
||
|
||
|
||
@pytest.mark.parametrize("case", ["no_token", "revoked", "db_failure", "bad_token"])
|
||
def test_ws_auth_rejects_with_4001(monkeypatch, case):
|
||
from starlette.websockets import WebSocketDisconnect
|
||
|
||
from app.core import security as sec
|
||
|
||
monkeypatch.setattr(settings, "DEBUG", False)
|
||
sec.invalidate_session_user_cache()
|
||
path = "/ws/c1"
|
||
if case == "revoked":
|
||
async def _flags(u):
|
||
return sec.SessionUserFlags(True, False, False)
|
||
monkeypatch.setattr(sec, "_fetch_session_user_flags_impl", _flags)
|
||
path += "?token=" + sec.create_session_token("bob")[0]
|
||
elif case == "db_failure":
|
||
async def _boom(u):
|
||
raise RuntimeError("db down")
|
||
monkeypatch.setattr(sec, "_fetch_session_user_flags_impl", _boom)
|
||
path += "?token=" + sec.create_session_token("carol")[0]
|
||
elif case == "bad_token":
|
||
path += "?token=not-a-token"
|
||
try:
|
||
with pytest.raises(WebSocketDisconnect) as e:
|
||
_ws_connect(path)
|
||
assert e.value.code == 4001
|
||
finally:
|
||
sec.invalidate_session_user_cache()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4회차 — Redis 소켓 타임아웃
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.asyncio
|
||
async def test_redis_client_has_socket_timeouts(monkeypatch):
|
||
# 다른 테스트가 sys.modules 의 app.core.redis 를 목으로 바꿔 두는 경우가 있어
|
||
# (순서 의존 — CLAUDE.md §4.1) 소스 파일에서 격리된 사본을 로드해 검사한다.
|
||
import importlib.util
|
||
from pathlib import Path
|
||
|
||
spec = importlib.util.spec_from_file_location(
|
||
"_redis_under_test", Path(__file__).resolve().parents[1] / "app" / "core" / "redis.py"
|
||
)
|
||
r = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(r)
|
||
|
||
captured = {}
|
||
|
||
class _Client:
|
||
async def ping(self):
|
||
return True
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
def _from_url(url, **kw):
|
||
captured.update(kw)
|
||
return _Client()
|
||
|
||
monkeypatch.setattr(r.aioredis, "from_url", _from_url)
|
||
monkeypatch.setattr(r, "_redis_client", None)
|
||
await r.init_redis()
|
||
assert captured["socket_timeout"] == settings.REDIS_SOCKET_TIMEOUT > 0
|
||
assert captured["socket_connect_timeout"] == settings.REDIS_SOCKET_CONNECT_TIMEOUT > 0
|
||
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 5회차 — M-4: 주문 취소 파라미터 (공식 샘플 order_rvsecncl 대조)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _cancel_api(tmp_path, virtual=False, ccld_rows=None, ccld_rt="0"):
|
||
api = _bare_api(tmp_path)
|
||
api.base_url = "https://openapivts.example.invalid" if virtual else "https://openapi.example.invalid"
|
||
api.account_number = "1234567801"
|
||
api.cancel_order_v2 = types.MethodType(_ORIG_CANCEL_ORDER_V2, api)
|
||
api.cancel_order = types.MethodType(_ORIG_CANCEL_ORDER, api)
|
||
sent: list = []
|
||
lookups: list = []
|
||
|
||
async def _make_request(method, endpoint, tr_id, extra_headers=None, **kw):
|
||
if endpoint != kis_mod.KIS_CANCEL_ENDPOINT:
|
||
raise AssertionError(f"예상치 못한 KIS 호출: {endpoint}")
|
||
sent.append({"method": method, "tr_id": tr_id, "body": kw["json"]})
|
||
return {"rt_cd": "0", "msg1": "ok", "output": {"ODNO": "0000099999"}}, {}
|
||
|
||
async def _ccld(stock_code="", order_id="", **kw):
|
||
lookups.append((stock_code, order_id))
|
||
if ccld_rows is None:
|
||
raise AssertionError("조직번호를 알고 있는데 주문내역을 조회했다")
|
||
return {"rt_cd": ccld_rt, "output1": ccld_rows}, {}
|
||
|
||
api._make_request = _make_request
|
||
api._daily_ccld_fetch_v2 = _ccld
|
||
return api, sent, lookups
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_v2_body_matches_official_sample(tmp_path):
|
||
api, sent, lookups = _cancel_api(tmp_path)
|
||
res = await api.cancel_order_v2("0000002101", org_no="06010")
|
||
assert res["rt_cd"] == "0" and lookups == []
|
||
req = sent[0]
|
||
assert req["method"] == "POST" and req["tr_id"] == "TTTC0013U"
|
||
assert req["body"] == {
|
||
"CANO": "12345678", "ACNT_PRDT_CD": "01",
|
||
"KRX_FWDG_ORD_ORGNO": "06010", "ORGN_ODNO": "0000002101",
|
||
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
|
||
"ORD_QTY": "0", "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
|
||
"EXCG_ID_DVSN_CD": "KRX",
|
||
}
|
||
# 공식 샘플의 필수 검증: 빈 문자열 금지
|
||
assert all(v != "" for v in req["body"].values())
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_v2_partial_quantity_and_virtual_tr(tmp_path):
|
||
api, sent, _ = _cancel_api(tmp_path, virtual=True)
|
||
await api.cancel_order_v2("2101", org_no="06010", quantity=3)
|
||
body = sent[0]["body"]
|
||
assert sent[0]["tr_id"] == "VTTC0013U"
|
||
assert body["QTY_ALL_ORD_YN"] == "N" and body["ORD_QTY"] == "3"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_legacy_cancel_order_looks_up_org_no_from_todays_orders(tmp_path):
|
||
rows = [
|
||
{"odno": "0000001111", "ord_gno_brno": "99999"},
|
||
{"odno": "0000002101", "ord_gno_brno": "06010"},
|
||
]
|
||
api, sent, lookups = _cancel_api(tmp_path, ccld_rows=rows)
|
||
await api.cancel_order("2101", 5) # 레거시 시그니처: 0 패딩 차이 흡수 + 잔량 전부 취소 유지
|
||
assert lookups == [("", "2101")]
|
||
body = sent[0]["body"]
|
||
assert body["KRX_FWDG_ORD_ORGNO"] == "06010"
|
||
assert body["QTY_ALL_ORD_YN"] == "Y" and body["ORD_QTY"] == "0"
|
||
|
||
|
||
@pytest.mark.parametrize("rows, rt", [([], "0"), ([{"odno": "2101", "ord_gno_brno": ""}], "0"), ([], "1")])
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_not_sent_when_org_no_unknown(tmp_path, caplog, rows, rt):
|
||
api, sent, _ = _cancel_api(tmp_path, ccld_rows=rows, ccld_rt=rt)
|
||
with caplog.at_level(logging.WARNING):
|
||
res = await api.cancel_order_v2("2101")
|
||
assert sent == [], "조직번호 없이 취소를 전송하면 실전에서 거부된다"
|
||
assert res["rt_cd"] == "-1" and res["msg_cd"] == kis_mod.KIS_PRESEND_ORGNO_NOT_FOUND
|
||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_rejection_logged_warning(tmp_path, caplog):
|
||
api, sent, _ = _cancel_api(tmp_path)
|
||
|
||
async def _reject(method, endpoint, tr_id, extra_headers=None, **kw):
|
||
sent.append(kw["json"])
|
||
return {"rt_cd": "1", "msg_cd": "APBK0918", "msg1": "취소 가능 수량 없음"}, {}
|
||
|
||
api._make_request = _reject
|
||
with caplog.at_level(logging.WARNING):
|
||
res = await api.cancel_order_v2("2101", org_no="06010")
|
||
assert res["rt_cd"] == "1"
|
||
assert any("취소 거부" in r.getMessage() and r.levelno >= logging.WARNING for r in caplog.records)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_presend_token_failure_tagged(tmp_path):
|
||
api, sent, _ = _cancel_api(tmp_path)
|
||
|
||
async def _unavailable(*a, **k):
|
||
raise kis_mod.KISTokenUnavailableError("wait", retry_after_sec=10)
|
||
|
||
api._make_request = _unavailable
|
||
res = await api.cancel_order_v2("2101", org_no="06010")
|
||
assert res["msg_cd"] == kis_mod.KIS_PRESEND_TOKEN_UNAVAILABLE and res["rt_cd"] == "-1"
|
||
|
||
|
||
def test_extract_order_org_no_from_place_order_result():
|
||
f = kis_mod.extract_order_org_no
|
||
assert f({"rt_cd": "0", "output": {"KRX_FWDG_ORD_ORGNO": "06010", "ODNO": "0000002101"}}) == "06010"
|
||
assert f({"rt_cd": "0", "output": [{"KRX_FWDG_ORD_ORGNO": "91252"}]}) == "91252"
|
||
assert f({"rt_cd": "1", "output": None}) is None
|
||
assert f(None) is None
|
||
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 5회차 — M-3: 연패 밴은 종료 drain 에서 커밋 + replay 후 loss_tracker 재로드
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
@pytest.mark.parametrize("ttype, handler", [
|
||
("upsert_stock_ban", "_handle_upsert_stock_ban"),
|
||
("upsert_stock_ban_v2", "_handle_upsert_stock_ban_v2"),
|
||
])
|
||
@pytest.mark.asyncio
|
||
async def test_drain_commits_stock_ban(monkeypatch, ttype, handler):
|
||
from app.services import db_worker as w
|
||
|
||
called = []
|
||
|
||
class _Sess:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
async def _h(session, data):
|
||
called.append(data["stock_code"])
|
||
|
||
async def _no_dlq(task, err):
|
||
raise AssertionError(f"밴이 DLQ 로 빠졌다: {err}")
|
||
|
||
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _Sess())
|
||
monkeypatch.setattr(w, handler, _h)
|
||
monkeypatch.setattr(w, "_save_to_dlq", _no_dlq)
|
||
q = asyncio.Queue()
|
||
q.put_nowait({"type": ttype, "data": {"stock_code": "005930", "consecutive_losses": 3}})
|
||
await w._drain_on_shutdown_impl(q, None, [], set())
|
||
assert called == ["005930"]
|
||
|
||
|
||
def test_normal_path_uses_same_ban_handlers():
|
||
import inspect
|
||
|
||
from app.services import db_worker as w
|
||
|
||
src = inspect.getsource(w._execute_db_task_impl)
|
||
assert "_handle_upsert_stock_ban(session, data)" in src
|
||
assert "_handle_upsert_stock_ban_v2(session, data)" in src
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_replay_records_requeued_types(dlq_dirs):
|
||
w, d, requeued = dlq_dirs
|
||
_write_dlq(d, "b.json", {"type": "upsert_stock_ban",
|
||
"data": {"stock_code": "005930", "consecutive_losses": 3}}, "shutdown_drain")
|
||
assert await w.replay_dlq() == 1
|
||
assert "upsert_stock_ban" in w.last_replay_requeued_types
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_bans_reloaded_after_replay_commit(monkeypatch, fresh_queue):
|
||
from app import main
|
||
from app.services import db_worker as w
|
||
from app.services import loss_tracker as lt_mod
|
||
|
||
order = []
|
||
|
||
class _Sess:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
async def _load(session):
|
||
order.append("reload")
|
||
|
||
monkeypatch.setattr(lt_mod.loss_tracker, "load_from_db", _load)
|
||
monkeypatch.setattr(main, "AsyncSessionLocal", lambda: _Sess())
|
||
monkeypatch.setattr(w, "last_replay_requeued_types", {"upsert_stock_ban"})
|
||
q = w.get_db_task_queue()
|
||
await q.put({"type": "upsert_stock_ban", "data": {}})
|
||
|
||
async def _consumer():
|
||
await asyncio.sleep(0.05)
|
||
q.get_nowait()
|
||
order.append("committed")
|
||
q.task_done()
|
||
|
||
asyncio.create_task(_consumer())
|
||
await main._reload_bans_after_replay()
|
||
assert order == ["committed", "reload"], "재큐잉된 밴이 커밋된 뒤 재로드해야 한다"
|
||
|
||
# 밴이 재큐잉되지 않았으면 재로드하지 않는다
|
||
order.clear()
|
||
monkeypatch.setattr(w, "last_replay_requeued_types", {"insert_trade_history"})
|
||
await main._reload_bans_after_replay()
|
||
assert order == []
|
||
|
||
|
||
def test_lifespan_reloads_bans_after_replay():
|
||
import inspect
|
||
|
||
from app import main
|
||
|
||
src = inspect.getsource(main.lifespan)
|
||
assert src.index("await replay_dlq()") < src.index("await _reload_bans_after_replay()")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 5회차 — L-3: 종료 예산 합계 검증 / L-4: /health 캐시 / L-7: Redis 축출 정책
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_shutdown_budget_check(monkeypatch):
|
||
from app import main
|
||
|
||
assert main.check_shutdown_budget() == []
|
||
monkeypatch.setattr(settings, "SHUTDOWN_BACKGROUND_DRAIN_SECONDS", 30)
|
||
issues = main.check_shutdown_budget()
|
||
assert issues and "종료 유예" in issues[0]
|
||
import inspect
|
||
assert "check_shutdown_budget()" in inspect.getsource(main.lifespan)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_health_is_cached_and_coalesced(monkeypatch):
|
||
from app import main
|
||
|
||
calls = []
|
||
|
||
async def _impl():
|
||
calls.append(1)
|
||
await asyncio.sleep(0.05)
|
||
return {"status": "healthy", "n": len(calls)}
|
||
|
||
monkeypatch.setattr(main, "_health_check_impl", _impl)
|
||
monkeypatch.setattr(main, "_health_cache", {"at": 0.0, "value": None})
|
||
monkeypatch.setattr(settings, "HEALTH_CACHE_SECONDS", 1.5)
|
||
res = await asyncio.gather(*[main.health_check() for _ in range(20)])
|
||
assert len(calls) == 1 and all(r == res[0] for r in res)
|
||
monkeypatch.setattr(settings, "HEALTH_CACHE_SECONDS", 0)
|
||
await main.health_check()
|
||
assert len(calls) == 2
|
||
|
||
|
||
def test_redis_eviction_spares_ttl_less_state_keys():
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
|
||
cmd = compose["services"]["redis"]["command"]
|
||
assert "--maxmemory-policy volatile-lru" in cmd and "allkeys" not in cmd
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 6회차 — M-1: 주문 엔드포인트 5xx + JSON 거부 본문은 '결과 불명'이 아니라 응답 dict
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
class _HtmlResp(_Resp):
|
||
def json(self):
|
||
raise ValueError("not json")
|
||
|
||
|
||
_ORDER_EP = "/uapi/domestic-stock/v1/trading/order-cash"
|
||
|
||
|
||
@pytest.mark.parametrize("status", [500, 503])
|
||
@pytest.mark.asyncio
|
||
async def test_order_5xx_with_json_rejection_returns_dict(tmp_path, status):
|
||
body = {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수를 초과하였습니다."}
|
||
client = _Client([_Resp(status, body)])
|
||
api = _api_for_request(tmp_path, client, ["T"])
|
||
data, _ = await api._make_request_with_retry("POST", _ORDER_EP, "TTTC0802U", json={})
|
||
assert data == body
|
||
assert len(client.auth_headers) == 1, "주문은 재전송하지 않는다"
|
||
|
||
|
||
@pytest.mark.parametrize("resp", [
|
||
_HtmlResp(500, None), # HTML/빈 본문
|
||
_Resp(502, {"error": "bad gateway"}), # JSON 이지만 KIS 응답 형식 아님
|
||
])
|
||
@pytest.mark.asyncio
|
||
async def test_order_5xx_without_kis_body_still_raises(tmp_path, resp):
|
||
client = _Client([resp])
|
||
api = _api_for_request(tmp_path, client, ["T"])
|
||
with pytest.raises(Exception, match="최대 재시도 초과"):
|
||
await api._make_request_with_retry("POST", _ORDER_EP, "TTTC0802U", json={})
|
||
assert len(client.auth_headers) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_query_5xx_with_json_behavior_unchanged(tmp_path):
|
||
"""조회 계열은 5xx 면 기존대로 재시도 후 예외 (본문이 KIS 형식이어도)."""
|
||
body = {"rt_cd": "1", "msg_cd": "EGW00201"}
|
||
client = _Client([_Resp(500, body) for _ in range(4)])
|
||
api = _api_for_request(tmp_path, client, ["T"])
|
||
with pytest.raises(Exception, match="최대 재시도 초과"):
|
||
await api._make_request_with_retry("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "TR")
|
||
assert len(client.auth_headers) == api.max_retries + 1
|
||
|
||
|
||
def test_compose_backend_has_healthcheck_hang_only():
|
||
"""[R2 M] backend 는 무응답(hang) 감지 healthcheck 를 가져야 하고, degraded 로 오탐하지 않는다."""
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
|
||
hc = compose["services"]["backend"].get("healthcheck")
|
||
assert hc, "backend healthcheck 누락"
|
||
cmd = " ".join(hc["test"])
|
||
assert "/health" in cmd and "timeout=" in cmd
|
||
assert "degraded" not in cmd and "healthy" not in cmd # 상태 문자열 판정 금지(오탐)
|
||
assert int(str(hc["retries"])) >= 2
|
||
|
||
|
||
def test_compose_has_health_watch_sidecar():
|
||
"""[하드닝 R1] /health degraded 능동 감시는 NAS cron 에 의존하지 않고 compose 사이드카가 수행한다."""
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
|
||
svc = compose["services"]["health-watch"]
|
||
assert "health_watch_state" in compose["volumes"]
|
||
env = " ".join(svc["environment"])
|
||
assert "HEALTH_URL=http://backend:8000/health" in env
|
||
assert "TELEGRAM_BOT_TOKEN" in env and "TELEGRAM_CHAT_ID" in env
|
||
assert any("health_watch.py" in v for v in svc["volumes"])
|
||
assert "health_watch.py" in " ".join(svc["command"])
|
||
assert svc["restart"] == "unless-stopped"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 라운드4 — Redis 초기 연결 실패 후 지연 재연결
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _load_redis_isolated():
|
||
import importlib.util
|
||
from pathlib import Path
|
||
|
||
spec = importlib.util.spec_from_file_location(
|
||
"_redis_lazy_under_test", Path(__file__).resolve().parents[1] / "app" / "core" / "redis.py"
|
||
)
|
||
r = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(r)
|
||
return r
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_redis_lazy_reconnect_after_init_failure(monkeypatch):
|
||
r = _load_redis_isolated()
|
||
built = []
|
||
|
||
class _Dead:
|
||
async def ping(self):
|
||
raise ConnectionError("down")
|
||
|
||
class _Alive:
|
||
pass
|
||
|
||
def _from_url(url, **kw):
|
||
c = _Dead() if not built else _Alive()
|
||
built.append(c)
|
||
return c
|
||
|
||
monkeypatch.setattr(r.aioredis, "from_url", _from_url)
|
||
monkeypatch.setattr(r, "_redis_client", None)
|
||
clock = [1000.0]
|
||
monkeypatch.setattr(r.time, "monotonic", lambda: clock[0])
|
||
|
||
await r.init_redis()
|
||
assert r._redis_client is None
|
||
# 쓰로틀 구간 내에는 재시도하지 않는다
|
||
assert r.get_redis() is None
|
||
assert len(built) == 1
|
||
clock[0] += r.REDIS_RECONNECT_INTERVAL_SEC + 1
|
||
c = r.get_redis()
|
||
assert isinstance(c, _Alive)
|
||
assert r.get_redis() is c # 이후 재생성 없음
|
||
assert len(built) == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_redis_no_lazy_reconnect_after_close(monkeypatch):
|
||
r = _load_redis_isolated()
|
||
|
||
def _boom(*a, **k):
|
||
raise AssertionError("재연결 금지")
|
||
|
||
monkeypatch.setattr(r.aioredis, "from_url", _boom)
|
||
monkeypatch.setattr(r, "_redis_client", None)
|
||
clock = [1000.0]
|
||
monkeypatch.setattr(r.time, "monotonic", lambda: clock[0])
|
||
await r.close_redis()
|
||
clock[0] += 1000
|
||
assert r.get_redis() is None
|