From 7dba160c0ff91fc6f979cc27b9659e4e5b3e355e Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 4 Jul 2026 11:03:47 +0900 Subject: [PATCH] test(bat-023): add REQ-055 AC-1~10 tests for strategy+market meta fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- tests/test_v1_portfolios_list.py | 289 ++++++++++++++++++++++++++++++- 1 file changed, 288 insertions(+), 1 deletion(-) diff --git a/tests/test_v1_portfolios_list.py b/tests/test_v1_portfolios_list.py index c5abacd..5be0114 100644 --- a/tests/test_v1_portfolios_list.py +++ b/tests/test_v1_portfolios_list.py @@ -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 +import logging from contextlib import ExitStack from typing import Any from unittest.mock import patch @@ -42,6 +46,10 @@ def _make_portfolio( status: str = "enabled", daemon_id: str | None = 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: return Portfolio( portfolio_id=portfolio_id, @@ -58,6 +66,10 @@ def _make_portfolio( profit_buffer=0.0, tag_ids=[], 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] assert item["asset_id"] == _ASSET_UUID 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"]