feat(bat-021): REQ-047 Step2 동반 코드 수정 + 실 DB 통합검증 + 런북
- ensure_schema() broker_credentials: daemon_id NOT NULL, PK=(daemon_id,broker)로 갱신 - _row_to_broker_credential: Step2 컬럼 순서(daemon_id 선두)로 갱신 - upsert_broker_credential: ON CONFLICT (daemon_id,broker)로 수정, fallback try/except 제거 - list_broker_credentials: fallback 제거, SELECT 컬럼 순서 갱신 - test_bat020_broker_credential_domain.py: AC-3/AC-4 행 순서를 Step2 포맷으로 갱신 - tests/test_bat021_step2_ensure_schema_and_upsert.py: 12개 신규 테스트 - docs/runbooks/bat-021-req047-step2-migration-runbook.md: 운영 적용 런북 - docs/batches/bat-021: batch 문서(index/planning/design/implementation/verification) 실 PostgreSQL 통합검증(AC-1~7 전건 PASS): Step1 upgrade → backfill → Step2 upgrade → downgrade 시나리오 포함 NULL 사전검증(upgrade 거부) 및 downgrade 안전장치(중복 시 RuntimeError) 확인 521 passed, 0 failed (기존 509 + 신규 12)
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"""BAT-021: REQ-047 Step2 동반 코드 수정 단위 테스트.
|
||||
|
||||
검증 범위:
|
||||
- ensure_schema() broker_credentials CREATE TABLE: daemon-scoped PK 정의
|
||||
- _row_to_broker_credential: Step2 컬럼 순서 (daemon_id 선두)
|
||||
- upsert_broker_credential: ON CONFLICT (daemon_id, broker), fallback 제거
|
||||
- list_broker_credentials: fallback 제거, Step2 컬럼 순서
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.models import BrokerCredential
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 공통 픽스처
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config() -> PortfolioDBConfig:
|
||||
return PortfolioDBConfig(
|
||||
host="localhost",
|
||||
port=5432,
|
||||
dbname="test",
|
||||
user="test",
|
||||
password="test",
|
||||
sslmode="disable",
|
||||
)
|
||||
|
||||
|
||||
def _make_repo() -> PostgresPortfolioRepository:
|
||||
return PostgresPortfolioRepository(_make_config())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_schema broker_credentials 스키마 정의 검증
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ensure_schema_broker_credentials_ddl_contains_daemon_id_pk() -> None:
|
||||
"""BAT-021: ensure_schema()의 broker_credentials CREATE TABLE에 daemon_id NOT NULL과
|
||||
PRIMARY KEY (daemon_id, broker)가 포함되어 있다."""
|
||||
import inspect
|
||||
import app.repositories.postgres as repo_module
|
||||
|
||||
source = inspect.getsource(repo_module)
|
||||
# CREATE TABLE에 daemon_id NOT NULL 포함 확인
|
||||
assert "daemon_id TEXT NOT NULL" in source, "ensure_schema broker_credentials에 daemon_id NOT NULL 없음"
|
||||
# PRIMARY KEY (daemon_id, broker) 포함 확인
|
||||
assert "PRIMARY KEY (daemon_id, broker)" in source, "ensure_schema broker_credentials PK가 (daemon_id,broker) 아님"
|
||||
|
||||
|
||||
def test_ensure_schema_broker_credentials_no_old_pk_user_id_broker() -> None:
|
||||
"""BAT-021: ensure_schema()의 broker_credentials CREATE TABLE에 구 PK (user_id, broker)가 없다."""
|
||||
import inspect
|
||||
import app.repositories.postgres as repo_module
|
||||
|
||||
source = inspect.getsource(repo_module)
|
||||
# ensure_schema 함수 영역만 검사: broker_credentials CREATE TABLE
|
||||
# "PRIMARY KEY (user_id, broker)" 패턴이 없어야 함
|
||||
assert "PRIMARY KEY (user_id, broker)" not in source, "ensure_schema에 구 PK (user_id,broker) 잔존"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _row_to_broker_credential Step2 컬럼 순서
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_row_to_broker_credential_step2_7col_canonical_order() -> None:
|
||||
"""BAT-021: Step2 표준 7컬럼 행 변환 — daemon_id 선두."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Step2: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
|
||||
row = ("daemon-abc", "user-1", "kis", "ACC-001", "enc-payload", 3, now)
|
||||
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
|
||||
|
||||
assert cred.daemon_id == "daemon-abc"
|
||||
assert cred.user_id == "user-1"
|
||||
assert cred.broker == "kis"
|
||||
assert cred.account_ref == "ACC-001"
|
||||
assert cred.encrypted_payload == "enc-payload"
|
||||
assert cred.credential_version == 3
|
||||
assert cred.last_rotated_at == now
|
||||
|
||||
|
||||
def test_row_to_broker_credential_step2_5col_no_version() -> None:
|
||||
"""BAT-021: 5컬럼 행(credential_version 없음) — 기본값 1, last_rotated_at=None."""
|
||||
row = ("daemon-xyz", "user-2", "kis2", "ACC-002", "enc-2")
|
||||
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
|
||||
|
||||
assert cred.daemon_id == "daemon-xyz"
|
||||
assert cred.user_id == "user-2"
|
||||
assert cred.broker == "kis2"
|
||||
assert cred.credential_version == 1
|
||||
assert cred.last_rotated_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_broker_credential: ON CONFLICT (daemon_id, broker)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_upsert_broker_credential_uses_daemon_id_conflict_target() -> None:
|
||||
"""BAT-021: upsert_broker_credential SQL에 ON CONFLICT (daemon_id, broker) 포함 확인."""
|
||||
import inspect
|
||||
import app.repositories.postgres as repo_module
|
||||
|
||||
# upsert_broker_credential 메서드 소스 추출
|
||||
source = inspect.getsource(
|
||||
PostgresPortfolioRepository.upsert_broker_credential
|
||||
)
|
||||
assert "ON CONFLICT (daemon_id, broker)" in source, \
|
||||
"upsert_broker_credential에 ON CONFLICT (daemon_id, broker) 없음"
|
||||
|
||||
|
||||
def test_upsert_broker_credential_no_old_conflict_target() -> None:
|
||||
"""BAT-021: upsert_broker_credential SQL에 구 ON CONFLICT (user_id, broker) 없음."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(
|
||||
PostgresPortfolioRepository.upsert_broker_credential
|
||||
)
|
||||
assert "ON CONFLICT (user_id, broker)" not in source, \
|
||||
"upsert_broker_credential에 구 ON CONFLICT (user_id,broker) 잔존"
|
||||
|
||||
|
||||
def test_upsert_broker_credential_no_fallback_try_except() -> None:
|
||||
"""BAT-021: upsert_broker_credential에 fallback try/except 없음 (트랜잭션 abort 이슈 해소)."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(
|
||||
PostgresPortfolioRepository.upsert_broker_credential
|
||||
)
|
||||
# Fallback try/except는 "Fallback" 주석과 함께 있었음 — 완전 제거 확인
|
||||
assert "Fallback: daemon_id" not in source, \
|
||||
"upsert_broker_credential에 Fallback try/except 잔존"
|
||||
|
||||
|
||||
def test_upsert_broker_credential_mock_returns_step2_row() -> None:
|
||||
"""BAT-021: upsert_broker_credential mock 기반 — Step2 RETURNING 순서(daemon_id 선두)로 BrokerCredential 생성."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Step2 RETURNING 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
mock_row = ("daemon-test", "user-test", "kis", "REF-001", "enc-test", 1, now)
|
||||
|
||||
repo = _make_repo()
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
# user existence check
|
||||
mock_cursor.fetchone.side_effect = [MagicMock(), mock_row] # user exists, then credential row
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
cred = repo.upsert_broker_credential(
|
||||
"user-test", "kis",
|
||||
account_ref="REF-001",
|
||||
encrypted_payload="enc-test",
|
||||
daemon_id="daemon-test",
|
||||
)
|
||||
|
||||
assert cred.daemon_id == "daemon-test"
|
||||
assert cred.user_id == "user-test"
|
||||
assert cred.broker == "kis"
|
||||
assert cred.account_ref == "REF-001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_broker_credentials: fallback 제거, Step2 컬럼 순서
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_broker_credentials_no_fallback_try_except() -> None:
|
||||
"""BAT-021: list_broker_credentials에 fallback try/except 없음."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(
|
||||
PostgresPortfolioRepository.list_broker_credentials
|
||||
)
|
||||
assert "Fallback: daemon_id" not in source, \
|
||||
"list_broker_credentials에 Fallback try/except 잔존"
|
||||
|
||||
|
||||
def test_list_broker_credentials_step2_select_order() -> None:
|
||||
"""BAT-021: list_broker_credentials SELECT 쿼리에 daemon_id 선두 컬럼 순서."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(
|
||||
PostgresPortfolioRepository.list_broker_credentials
|
||||
)
|
||||
# daemon_id가 SELECT에서 user_id보다 앞에 있어야 함
|
||||
daemon_pos = source.find("SELECT daemon_id")
|
||||
assert daemon_pos != -1, "list_broker_credentials SELECT에 daemon_id 선두 없음"
|
||||
|
||||
|
||||
def test_list_broker_credentials_mock_returns_step2_rows() -> None:
|
||||
"""BAT-021: list_broker_credentials mock — Step2 컬럼 순서로 BrokerCredential 생성."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Step2 컬럼 순서: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
|
||||
mock_rows = [
|
||||
("daemon-1", "user-1", "kis", "REF-A", "enc-a", 1, now),
|
||||
("daemon-2", "user-1", "kis2", "REF-B", "enc-b", 1, now),
|
||||
]
|
||||
|
||||
repo = _make_repo()
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchall.return_value = mock_rows
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
results = repo.list_broker_credentials("user-1")
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].daemon_id == "daemon-1"
|
||||
assert results[0].broker == "kis"
|
||||
assert results[1].daemon_id == "daemon-2"
|
||||
assert results[1].broker == "kis2"
|
||||
|
||||
|
||||
def test_list_broker_credentials_daemon_id_filter_step2() -> None:
|
||||
"""BAT-021: list_broker_credentials daemon_id 필터 — Step2 컬럼 순서."""
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_rows = [
|
||||
("daemon-1", "user-1", "kis", "REF-A", "enc-a", 1, now),
|
||||
]
|
||||
|
||||
repo = _make_repo()
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchall.return_value = mock_rows
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
results = repo.list_broker_credentials("user-1", daemon_id="daemon-1")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].daemon_id == "daemon-1"
|
||||
assert results[0].broker == "kis"
|
||||
Reference in New Issue
Block a user