Files
portfolio-hub/tests/test_bat021_step2_ensure_schema_and_upsert.py
genichin 3e1b571906 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)
2026-06-28 10:19:27 +09:00

253 lines
9.7 KiB
Python

"""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"