test(bat-023): add REQ-055 AC-1~10 tests for strategy+market meta fields
10 test cases covering:
- AC-1: bound portfolio returns strategy_name/params
- AC-2: unbound → null/{}
- AC-3: dangling FK → keeps active_strategy_id, null strategy fields
- AC-4: invalid JSON config → strategy_params={} + WARNING log (caplog)
- AC-5/6: markets with values / empty
- AC-7: existing 16-field regression
- AC-8: token-branch invariant (service token)
- AC-9: strategy_params exact key-value match
- AC-10: mixed list individual accuracy
583 passed total (0 failures, 18 new tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
"""Tests for BAT-009: GET /v1/portfolios (REQ-028)."""
|
"""Tests for BAT-009: GET /v1/portfolios (REQ-028).
|
||||||
|
|
||||||
|
REQ-055 AC-1~10 케이스는 파일 하단에 추가됨.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -42,6 +46,10 @@ def _make_portfolio(
|
|||||||
status: str = "enabled",
|
status: str = "enabled",
|
||||||
daemon_id: str | None = None,
|
daemon_id: str | None = None,
|
||||||
display_name: str | None = "삼성전자",
|
display_name: str | None = "삼성전자",
|
||||||
|
active_strategy_id: str | None = None,
|
||||||
|
strategy_name: str | None = None,
|
||||||
|
strategy_params: dict | None = None,
|
||||||
|
markets: list[str] | None = None,
|
||||||
) -> Portfolio:
|
) -> Portfolio:
|
||||||
return Portfolio(
|
return Portfolio(
|
||||||
portfolio_id=portfolio_id,
|
portfolio_id=portfolio_id,
|
||||||
@@ -58,6 +66,10 @@ def _make_portfolio(
|
|||||||
profit_buffer=0.0,
|
profit_buffer=0.0,
|
||||||
tag_ids=[],
|
tag_ids=[],
|
||||||
managed_by_daemon_id=daemon_id,
|
managed_by_daemon_id=daemon_id,
|
||||||
|
active_strategy_id=active_strategy_id,
|
||||||
|
strategy_name=strategy_name,
|
||||||
|
strategy_params=strategy_params if strategy_params is not None else {},
|
||||||
|
markets=markets if markets is not None else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -282,3 +294,278 @@ def test_get_response_has_asset_id_field() -> None:
|
|||||||
item = resp.json()["portfolios"][0]
|
item = resp.json()["portfolios"][0]
|
||||||
assert item["asset_id"] == _ASSET_UUID
|
assert item["asset_id"] == _ASSET_UUID
|
||||||
assert item["symbol"] == "005930"
|
assert item["symbol"] == "005930"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REQ-055: GET /v1/portfolios 응답 전략·시장 메타데이터 additive 필드 (BAT-023)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_STRATEGY_ID = "strat-uuid-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac1_bound_portfolio_returns_strategy_fields() -> None:
|
||||||
|
"""REQ-055 AC-1: active_strategy_id 바인딩된 포트폴리오 → strategy_name/strategy_params 정상 반환."""
|
||||||
|
p = _make_portfolio(
|
||||||
|
"p-001",
|
||||||
|
active_strategy_id=_STRATEGY_ID,
|
||||||
|
strategy_name="tvr_v1",
|
||||||
|
strategy_params={"target_weight": 0.5, "rebalance_band_pct": 0.05},
|
||||||
|
markets=["KRX"],
|
||||||
|
)
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
assert item["active_strategy_id"] == _STRATEGY_ID
|
||||||
|
assert item["strategy_name"] == "tvr_v1"
|
||||||
|
assert item["strategy_params"] == {"target_weight": 0.5, "rebalance_band_pct": 0.05}
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac2_unbound_portfolio_returns_null_strategy() -> None:
|
||||||
|
"""REQ-055 AC-2: active_strategy_id=null → strategy_name=null, strategy_params={}."""
|
||||||
|
p = _make_portfolio("p-001", active_strategy_id=None, strategy_name=None, strategy_params={})
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
assert item["active_strategy_id"] is None
|
||||||
|
assert item["strategy_name"] is None
|
||||||
|
assert item["strategy_params"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac3_dangling_fk_returns_null_strategy_but_keeps_strategy_id() -> None:
|
||||||
|
"""REQ-055 AC-3: dangling FK → active_strategy_id 원래 값 유지, strategy_name=null, strategy_params={}."""
|
||||||
|
# dangling FK: active_strategy_id 있으나 LEFT JOIN 결과 ss.name=null, ss.config=null
|
||||||
|
# 이 시뮬레이션에서는 domain layer에서 strategy_name=None, strategy_params={}로 설정
|
||||||
|
p = _make_portfolio(
|
||||||
|
"p-001",
|
||||||
|
active_strategy_id=_STRATEGY_ID,
|
||||||
|
strategy_name=None, # dangling: JOIN 결과 없음
|
||||||
|
strategy_params={},
|
||||||
|
markets=[],
|
||||||
|
)
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
assert item["active_strategy_id"] == _STRATEGY_ID # 원래 값 유지
|
||||||
|
assert item["strategy_name"] is None
|
||||||
|
assert item["strategy_params"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac4_invalid_config_json_returns_empty_params_and_logs_warning(caplog) -> None:
|
||||||
|
"""REQ-055 AC-4: config 파싱 불가 → strategy_params={}, WARNING 로그 1건, HTTP 200."""
|
||||||
|
# 이 테스트는 postgres._row_to_portfolio 파싱 로직을 직접 검증한다.
|
||||||
|
# WARNING 로그는 app.repositories.postgres 로거에서 발생한다.
|
||||||
|
from app.repositories.postgres import PostgresPortfolioRepository
|
||||||
|
|
||||||
|
# row: index 0=portfolio_id, 1=broker, 2=status, 3=name, 4=symbol, 5=cash, 6=qty, 7=price,
|
||||||
|
# 8=profit_buffer, 9=active_strategy_id, 10=avabile_strategies, 11=owner_user_id,
|
||||||
|
# 12=created_at, 13=updated_at, 14=last_trade_at, 15=managed_by_daemon_id,
|
||||||
|
# 16=asset_id, 17=tag_ids, 18=display_name, 19=ss.name, 20=ss.config, 21=a.markets
|
||||||
|
row = (
|
||||||
|
"p-001", # [0] portfolio_id
|
||||||
|
"kiwoom", # [1] broker
|
||||||
|
"enabled", # [2] status
|
||||||
|
"test-portfolio", # [3] name
|
||||||
|
"005930", # [4] symbol
|
||||||
|
0.0, # [5] cash_balance
|
||||||
|
0.0, # [6] asset_quantity
|
||||||
|
0.0, # [7] asset_price
|
||||||
|
0.0, # [8] profit_buffer
|
||||||
|
_STRATEGY_ID, # [9] active_strategy_id
|
||||||
|
None, # [10] avabile_strategies
|
||||||
|
"user-001", # [11] owner_user_id
|
||||||
|
None, # [12] created_at
|
||||||
|
None, # [13] updated_at
|
||||||
|
None, # [14] last_trade_at
|
||||||
|
None, # [15] managed_by_daemon_id
|
||||||
|
_ASSET_UUID, # [16] asset_id
|
||||||
|
[], # [17] tag_ids
|
||||||
|
"삼성전자", # [18] display_name
|
||||||
|
"tvr_v1", # [19] ss.name
|
||||||
|
"{invalid json}", # [20] ss.config — 파싱 불가
|
||||||
|
["KRX"], # [21] a.markets
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.repositories.postgres"):
|
||||||
|
portfolio = PostgresPortfolioRepository._row_to_portfolio(row)
|
||||||
|
|
||||||
|
assert portfolio.strategy_params == {}
|
||||||
|
assert any("파싱 실패" in record.message for record in caplog.records)
|
||||||
|
warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||||
|
assert len(warning_records) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac5_markets_with_values() -> None:
|
||||||
|
"""REQ-055 AC-5: assets.markets=['KRX','NXT'] → markets=['KRX','NXT']."""
|
||||||
|
p = _make_portfolio("p-001", markets=["KRX", "NXT"])
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
assert item["markets"] == ["KRX", "NXT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac6_empty_markets() -> None:
|
||||||
|
"""REQ-055 AC-6: assets.markets=[] → markets=[]."""
|
||||||
|
p = _make_portfolio("p-001", markets=[])
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
assert item["markets"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac7_existing_fields_regression() -> None:
|
||||||
|
"""REQ-055 AC-7: 기존 16필드 회귀 없음 — 값 변경 없이 반환."""
|
||||||
|
p = _make_portfolio(
|
||||||
|
"p-001",
|
||||||
|
owner=_USER_ID,
|
||||||
|
status="enabled",
|
||||||
|
display_name="삼성전자",
|
||||||
|
daemon_id=_DAEMON_ID,
|
||||||
|
)
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
|
||||||
|
# 기존 16필드 검증
|
||||||
|
assert item["portfolio_id"] == "p-001"
|
||||||
|
assert item["asset_id"] == _ASSET_UUID
|
||||||
|
assert item["symbol"] == "005930"
|
||||||
|
assert item["display_name"] == "삼성전자"
|
||||||
|
assert item["owner_user_id"] == _USER_ID
|
||||||
|
assert item["broker"] == "kiwoom"
|
||||||
|
assert item["status"] == "enabled"
|
||||||
|
assert item["name"] == "p-p-00"
|
||||||
|
assert item["cash_balance"] == 0.0
|
||||||
|
assert item["asset_quantity"] == 0.0
|
||||||
|
assert item["price"] == 0.0
|
||||||
|
assert item["profit_buffer"] == 0.0
|
||||||
|
assert item["tag_ids"] == []
|
||||||
|
assert item["managed_by_daemon_id"] == _DAEMON_ID
|
||||||
|
assert "created_at" in item
|
||||||
|
assert "updated_at" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac8_token_branch_invariant_service_token() -> None:
|
||||||
|
"""REQ-055 AC-8: service token 분기 — REQ-028 토큰 동작 불변 + 신규 필드 포함."""
|
||||||
|
p = _make_portfolio(
|
||||||
|
"p-001",
|
||||||
|
daemon_id=_DAEMON_ID,
|
||||||
|
active_strategy_id=_STRATEGY_ID,
|
||||||
|
strategy_name="tvr_v1",
|
||||||
|
strategy_params={"k": "v"},
|
||||||
|
markets=["KRX"],
|
||||||
|
)
|
||||||
|
repo = _FakeListRepo(
|
||||||
|
portfolios=[p],
|
||||||
|
token_principal=TokenPrincipal(
|
||||||
|
token_id="tid", user_id=_USER_ID, permission="trade-write",
|
||||||
|
token_type="service", daemon_id=_DAEMON_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
client = _make_client(db_enabled=True)
|
||||||
|
|
||||||
|
with _with_repo(repo, db_auth=True):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("svc-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
item = data["portfolios"][0]
|
||||||
|
# 토큰 분기 동작 불변 확인
|
||||||
|
assert item["portfolio_id"] == "p-001"
|
||||||
|
# 신규 필드도 포함되어 있음
|
||||||
|
assert item["active_strategy_id"] == _STRATEGY_ID
|
||||||
|
assert item["strategy_name"] == "tvr_v1"
|
||||||
|
assert item["strategy_params"] == {"k": "v"}
|
||||||
|
assert item["markets"] == ["KRX"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac9_strategy_params_exact_key_values() -> None:
|
||||||
|
"""REQ-055 AC-9: strategy_params 응답 dict가 원본 JSON과 동일 키·값."""
|
||||||
|
expected_params = {
|
||||||
|
"target_weight": 0.5,
|
||||||
|
"rebalance_band_pct": 0.05,
|
||||||
|
"min_trade_krw": 10000,
|
||||||
|
}
|
||||||
|
p = _make_portfolio(
|
||||||
|
"p-001",
|
||||||
|
active_strategy_id=_STRATEGY_ID,
|
||||||
|
strategy_name="tvr_v1",
|
||||||
|
strategy_params=expected_params,
|
||||||
|
markets=["KRX"],
|
||||||
|
)
|
||||||
|
repo = _FakeListRepo(portfolios=[p])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["portfolios"][0]
|
||||||
|
assert item["strategy_params"] == expected_params
|
||||||
|
assert item["strategy_params"]["target_weight"] == 0.5
|
||||||
|
assert item["strategy_params"]["rebalance_band_pct"] == 0.05
|
||||||
|
assert item["strategy_params"]["min_trade_krw"] == 10000
|
||||||
|
|
||||||
|
|
||||||
|
def test_req055_ac10_mixed_list_individual_accuracy() -> None:
|
||||||
|
"""REQ-055 AC-10: 혼합 리스트 — 각 포트폴리오의 4 필드가 개별적으로 정확하게 반환."""
|
||||||
|
bound = _make_portfolio(
|
||||||
|
"p-001",
|
||||||
|
active_strategy_id=_STRATEGY_ID,
|
||||||
|
strategy_name="tvr_v1",
|
||||||
|
strategy_params={"target_weight": 0.5},
|
||||||
|
markets=["KRX", "NXT"],
|
||||||
|
)
|
||||||
|
unbound = _make_portfolio(
|
||||||
|
"p-002",
|
||||||
|
active_strategy_id=None,
|
||||||
|
strategy_name=None,
|
||||||
|
strategy_params={},
|
||||||
|
markets=["KRX"],
|
||||||
|
)
|
||||||
|
repo = _FakeListRepo(portfolios=[bound, unbound])
|
||||||
|
client = _make_client()
|
||||||
|
|
||||||
|
with _with_repo(repo):
|
||||||
|
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
|
||||||
|
items = {item["portfolio_id"]: item for item in data["portfolios"]}
|
||||||
|
|
||||||
|
# 바인딩된 포트폴리오
|
||||||
|
p1 = items["p-001"]
|
||||||
|
assert p1["active_strategy_id"] == _STRATEGY_ID
|
||||||
|
assert p1["strategy_name"] == "tvr_v1"
|
||||||
|
assert p1["strategy_params"] == {"target_weight": 0.5}
|
||||||
|
assert p1["markets"] == ["KRX", "NXT"]
|
||||||
|
|
||||||
|
# 미바인딩 포트폴리오
|
||||||
|
p2 = items["p-002"]
|
||||||
|
assert p2["active_strategy_id"] is None
|
||||||
|
assert p2["strategy_name"] is None
|
||||||
|
assert p2["strategy_params"] == {}
|
||||||
|
assert p2["markets"] == ["KRX"]
|
||||||
|
|||||||
Reference in New Issue
Block a user