Files
portfolio-hub/tests/test_bat020_broker_credential_domain.py
T
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

184 lines
6.6 KiB
Python

"""BAT-020: REQ-046 BrokerCredential daemon_id 필드 단위 테스트."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.domain.models import BrokerCredential
# ---------------------------------------------------------------------------
# REQ-046 AC-1: daemon_id=None 기본값
# ---------------------------------------------------------------------------
def test_broker_credential_daemon_id_defaults_to_none() -> None:
"""REQ-046 AC-1: daemon_id 미지정 시 None이 기본값."""
cred = BrokerCredential(
user_id="user-1",
broker="kis",
account_ref="12345678",
encrypted_payload="encrypted",
)
assert hasattr(cred, "daemon_id")
assert cred.daemon_id is None
# ---------------------------------------------------------------------------
# REQ-046 AC-2: daemon_id 값 지정
# ---------------------------------------------------------------------------
def test_broker_credential_daemon_id_set() -> None:
"""REQ-046 AC-2: daemon_id="daemon-abc" 지정 시 해당 값으로 세팅."""
cred = BrokerCredential(
user_id="user-1",
broker="kis",
account_ref="12345678",
encrypted_payload="encrypted",
daemon_id="daemon-abc",
)
assert cred.daemon_id == "daemon-abc"
# ---------------------------------------------------------------------------
# REQ-046 AC-3 / BAT-021: _row_to_broker_credential Step2 컬럼 순서 검증
# Step2(REQ-047) 이후 컬럼 순서: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
# ---------------------------------------------------------------------------
def test_row_to_broker_credential_with_daemon_id() -> None:
"""REQ-046 AC-3 / BAT-021: Step2 컬럼 순서(daemon_id 선두) 행 변환 검증."""
from app.repositories.postgres import PostgresPortfolioRepository
now = datetime.now(timezone.utc)
# Step2 이후 RETURNING/SELECT 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
row = ("daemon-xyz", "user-1", "kis", "12345678", "encrypted", 2, now)
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
assert cred.daemon_id == "daemon-xyz"
assert cred.user_id == "user-1"
assert cred.broker == "kis"
assert cred.account_ref == "12345678"
assert cred.encrypted_payload == "encrypted"
assert cred.credential_version == 2
assert cred.last_rotated_at == now
# ---------------------------------------------------------------------------
# REQ-046 AC-4 / BAT-021: _row_to_broker_credential credential_version 기본값
# Step2 이후 daemon_id는 항상 존재. 6컬럼(credential_version 없음) 기본값 1 확인.
# ---------------------------------------------------------------------------
def test_row_to_broker_credential_without_daemon_id() -> None:
"""REQ-046 AC-4 / BAT-021: credential_version 없는 5컬럼 행 → credential_version 기본값 1."""
from app.repositories.postgres import PostgresPortfolioRepository
# Step2 이후 컬럼 순서: daemon_id, user_id, broker, account_ref, encrypted_payload (5컬럼)
row = ("daemon-abc", "user-1", "kis", "12345678", "encrypted")
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
assert cred.daemon_id == "daemon-abc"
assert cred.credential_version == 1
assert cred.last_rotated_at is None
# ---------------------------------------------------------------------------
# REQ-046 AC-5/6: list_broker_credentials daemon_id 필터 (mock 기반)
# ---------------------------------------------------------------------------
class _FakeCursor:
"""psycopg Cursor 최소 mock."""
def __init__(self, rows):
self._rows = rows
self.last_query = None
self.last_params = None
def execute(self, query, params=None):
self.last_query = query
self.last_params = params
def fetchall(self):
return self._rows
def __enter__(self):
return self
def __exit__(self, *args):
pass
class _FakeConn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _FakeCursor(self._rows)
def _make_rows_for_user(user_id: str):
"""Step2 컬럼 순서: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)."""
now = datetime.now(timezone.utc)
return [
("d1", user_id, "kis", "ref-d1", "enc-d1", 1, now),
("d2", user_id, "kis2", "ref-d2", "enc-d2", 1, now),
]
def test_list_broker_credentials_daemon_id_filter() -> None:
"""REQ-046 AC-5: daemon_id 필터 시 해당 daemon_id 행만 반환."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
rows = _make_rows_for_user("user-1")
# daemon_id="d1" 필터 시 d1 행만 반환되는 mock
d1_rows = [rows[0]]
config = PortfolioDBConfig(
host="localhost", port=5432, dbname="test",
user="test", password="test", sslmode="disable",
)
repo = PostgresPortfolioRepository(config)
mock_cursor = MagicMock()
mock_cursor.__enter__ = lambda s: mock_cursor
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchall.return_value = d1_rows
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
repo._connection = mock_conn
# ensure_schema 무력화
with patch.object(repo, "ensure_schema"):
results = repo.list_broker_credentials("user-1", daemon_id="d1")
assert len(results) == 1
assert results[0].daemon_id == "d1"
assert results[0].broker == "kis"
def test_list_broker_credentials_no_daemon_id_filter_returns_all() -> None:
"""REQ-046 AC-6: daemon_id 미지정 시 전체 반환 (하위호환)."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
rows = _make_rows_for_user("user-1")
config = PortfolioDBConfig(
host="localhost", port=5432, dbname="test",
user="test", password="test", sslmode="disable",
)
repo = PostgresPortfolioRepository(config)
mock_cursor = MagicMock()
mock_cursor.__enter__ = lambda s: mock_cursor
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchall.return_value = 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