feat: REQ-001~018 Implemented 전환, REQ-010 GET /v1/assets admin catalog 재정의, REL-001 draft

- confirm-req-implemented: REQ-001~018 (17건, REQ-013 제외) Approved → Implemented
  - BAT-001(REQ-009/011/014), BAT-002(REQ-010/012/015), BAT-003(REQ-001/002/003/006)
  - BAT-004(REQ-004/005), BAT-005(REQ-007/008), BAT-006(REQ-016/017/018)
  - CHG-20260521-01 entry 각 REQ에 추가; SRS index Recent Change Log 갱신

- REQ-010 CHG-20260521-02: GET /v1/assets admin catalog 전용 재정의
  - 기존 token_type 분기 portfolio 조회 코드 제거, admin permission + list_assets() 호출로 대체
  - AssetCatalogListResponse 스키마 추가 (items/total)
  - REQ-010 Status: Implemented → Approved (재검증 필요)
  - 테스트 343건 전체 통과: service/user token 403, admin token 200 + catalog schema 검증

- migration 보강: portfolios_asset_migration market 컬럼 부재 fallback,
  portfolio_tags 빈 테이블 early-return, service_token index fix autocommit_block 정리

- docs/releases/rel-001: REL-001 draft 추가 (BAT-001~011 포함)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 10:17:39 +00:00
co-authored by Claude Sonnet 4.6
parent 4bf6b554a7
commit 6add0ade29
28 changed files with 894 additions and 393 deletions
+44 -113
View File
@@ -290,94 +290,52 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
def test_v1_assets_status_filter(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
p_enabled = _make_portfolio("p1") # status="enabled"
p_active = Portfolio(
portfolio_id="p2", broker="kis", status="active", name="Active",
symbol="000660", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0,
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p_enabled, p_active]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?status=active", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p2"
assert resp.status_code == 403
def test_v1_assets_strategy_params(monkeypatch) -> None:
"""GET /v1/assets is admin catalog only — service token is rejected (strategy data is on GET /v1/portfolios)."""
settings = _make_app_db(monkeypatch)
from app.domain.models import StrategySet
import datetime as _dt
ss = StrategySet(id="ss-1", name="strategy", config='{"buy_threshold": 0.05}', nick="s1",
created_at=_dt.datetime.now(_dt.timezone.utc))
p = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
cash_balance=0.0, asset_quantity=0.0, price=0.0,
profit_buffer=0.0, active_strategy_id="ss-1",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.return_value = ss
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["assets"][0]["strategy_params"] == {"buy_threshold": 0.05}
assert resp.json()["assets"][0]["strategy_type"] == "strategy"
assert resp.status_code == 403
def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
"""strategy_set lookup failure → strategy_params == {}."""
"""GET /v1/assets is admin catalog only — service token is rejected."""
settings = _make_app_db(monkeypatch)
p = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
cash_balance=0.0, asset_quantity=0.0, price=0.0,
profit_buffer=0.0, active_strategy_id="ss-bad",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.side_effect = RuntimeError("DB error")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["assets"][0]["strategy_params"] == {}
assert resp.json()["assets"][0]["strategy_type"] is None
assert resp.status_code == 403
# ---------------------------------------------------------------------------
@@ -792,71 +750,61 @@ def test_v1_assets_rejects_env_token() -> None:
def test_v1_assets_returns_asset_list(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token is rejected."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p1"
assert "market" not in body["assets"][0]
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-013: GET /v1/assets token-type branching
# REQ-010 CHG-20260521-02: GET /v1/assets admin catalog (path 분리)
# ---------------------------------------------------------------------------
def test_v1_assets_user_token(monkeypatch) -> None:
"""User token is rejected — admin catalog endpoint requires admin permission."""
settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="user", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["total"] == 1
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=True,
market=None,
)
assert resp.status_code == 403
def test_v1_assets_admin_token_default(monkeypatch) -> None:
"""Admin token returns catalog list — REQ-010 AC-9."""
import datetime as _dt
from app.domain.models import Asset
settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
asset = Asset(
asset_id="a1-uuid", symbol="005930", markets=["KRX"],
display_name="Samsung", status="active",
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
principal = TokenPrincipal(
token_id="t1", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
mock_repo.list_assets.return_value = [asset]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
@@ -864,14 +812,19 @@ def test_v1_assets_admin_token_default(monkeypatch) -> None:
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="admin-1",
include_disabled=True,
market=None,
)
body = resp.json()
assert body["total"] == 1
item = body["items"][0]
assert item["symbol"] == "005930"
assert item["status"] == "active"
assert "portfolio_id" not in item
mock_repo.list_assets.assert_called_once()
def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
"""user_id param is not supported on catalog endpoint — returns 200 with full catalog."""
import datetime as _dt
from app.domain.models import Asset
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="admin-1", permission="admin",
@@ -879,7 +832,7 @@ def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = []
mock_repo.list_assets.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
@@ -887,14 +840,12 @@ def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
resp = client.get("/v1/assets?user_id=other-user", headers=_service_token_header())
assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="other-user",
include_disabled=True,
market=None,
)
assert resp.json()["total"] == 0
mock_repo.list_assets.assert_called_once()
def test_v1_assets_market_filter(monkeypatch) -> None:
"""Service token is rejected by admin catalog endpoint — REQ-010 AC-12."""
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
@@ -902,49 +853,29 @@ def test_v1_assets_market_filter(monkeypatch) -> None:
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?market=KRX", headers=_service_token_header())
assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
market="KRX",
)
assert resp.status_code == 403
def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None:
import uuid as _uuid
"""Service token is rejected by admin catalog endpoint."""
settings = _make_app_db(monkeypatch)
uuid_portfolio_id = str(_uuid.uuid4())
portfolio = Portfolio(
portfolio_id=uuid_portfolio_id,
broker="kis", status="enabled", name="T",
symbol="005930", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0, owner_user_id="user-1",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
returned_id = resp.json()["assets"][0]["asset_id"]
_uuid.UUID(returned_id) # raises ValueError if not a valid UUID
assert resp.status_code == 403
# ---------------------------------------------------------------------------