"""Tests for BAT-009: POST/PATCH/DELETE /v1/portfolios (REQ-025, REQ-026, REQ-027).""" from __future__ import annotations from contextlib import ExitStack from typing import Any from unittest.mock import patch from fastapi.testclient import TestClient from app.core.config import Settings, get_settings from app.domain.models import Asset, Portfolio, StrategySet, TokenPrincipal from app.main import create_app # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _ASSET_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" _PORTFOLIO_UUID = "cccccccc-cccc-cccc-cccc-cccccccccccc" _USER_ID = "user-001" _OTHER_USER_ID = "user-002" def _make_settings(**kwargs) -> Settings: return Settings(allowed_origins_raw="http://localhost:3000", **kwargs) def _make_asset(asset_id: str = _ASSET_UUID, status: str = "active") -> Asset: return Asset(asset_id=asset_id, symbol="005930", markets=["KRX"], status=status) def _make_portfolio( portfolio_id: str = _PORTFOLIO_UUID, owner: str = _USER_ID, status: str = "enabled", ) -> Portfolio: return Portfolio( portfolio_id=portfolio_id, broker="kiwoom", status=status, name="test-portfolio", symbol="005930", asset_id=_ASSET_UUID, owner_user_id=owner, cash_balance=1000.0, asset_quantity=10.0, price=50000.0, profit_buffer=0.0, tag_ids=[], managed_by_daemon_id=None, ) def _make_strategy_set() -> StrategySet: return StrategySet(id="strat-001", name="target_value", config="{}", nick="tv") class _FakeRepo: def __init__( self, asset: Asset | None = None, portfolios: dict[str, Portfolio] | None = None, token_principal: TokenPrincipal | None = None, ) -> None: self._asset = asset or _make_asset() self._portfolios: dict[str, Portfolio] = portfolios or {} self._token_principal = token_principal self._strategy_set = _make_strategy_set() def close(self) -> None: pass def get_asset_by_id(self, asset_id: str) -> Asset | None: if self._asset and self._asset.asset_id == asset_id: return self._asset return None def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: return self._strategy_set def get_strategy_set(self, id: str) -> StrategySet | None: return self._strategy_set if id == self._strategy_set.id else None def create_portfolio( self, portfolio_id: str, *, owner_user_id: str, broker: str, status: str, name: str, asset_id: str, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, active_strategy_id: str | None, avabile_strategies: list[str] | None, managed_by_daemon_id: str | None = None, ) -> Portfolio: p = Portfolio( portfolio_id=portfolio_id, broker=broker, status=status, name=name, symbol="005930", asset_id=asset_id, owner_user_id=owner_user_id, cash_balance=cash_balance, asset_quantity=asset_quantity, price=asset_price, profit_buffer=profit_buffer, tag_ids=[], managed_by_daemon_id=managed_by_daemon_id, ) self._portfolios[portfolio_id] = p return p def get(self, portfolio_id: str) -> Portfolio: p = self._portfolios.get(portfolio_id) if p is None: raise ValueError(f"포트폴리오를 찾을 수 없습니다: {portfolio_id}") return p def update_portfolio(self, portfolio_id: str, **fields: Any) -> Portfolio: p = self.get(portfolio_id) for k, v in fields.items(): if v is not None and hasattr(p, k): setattr(p, k, v) return p def soft_delete_portfolio(self, portfolio_id: str) -> Portfolio: p = self.get(portfolio_id) p.status = "disabled" p.managed_by_daemon_id = None return p def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: return self._token_principal def list_portfolios(self, **kwargs: Any) -> list[Portfolio]: return [] def _make_client( admin_tokens: list[str] | None = None, trade_write_tokens: list[str] | None = None, db_enabled: bool = False, ) -> TestClient: get_settings.cache_clear() settings = _make_settings( admin_tokens_raw=",".join(admin_tokens or ["admin-tok"]), trade_write_tokens_raw=",".join(trade_write_tokens or ["user-tok"]), db_enabled=db_enabled, ) app = create_app(settings_factory=lambda: settings) app.dependency_overrides[get_settings] = lambda: settings return TestClient(app) def _with_repo(repo: _FakeRepo, *, db_auth: bool = False) -> ExitStack: """Patch repo factory for route handlers and optionally for auth middleware.""" stack = ExitStack() stack.enter_context( patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) ) if db_auth: stack.enter_context( patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) ) return stack def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _repo_with_portfolio(owner: str = _USER_ID, status: str = "enabled") -> _FakeRepo: p = _make_portfolio(owner=owner, status=status) repo = _FakeRepo(asset=_make_asset()) repo._portfolios[_PORTFOLIO_UUID] = p return repo # --------------------------------------------------------------------------- # REQ-025: POST /v1/portfolios # --------------------------------------------------------------------------- def test_post_admin_creates_for_target_user() -> None: """REQ-025 AC-1: admin + user_id → portfolio owned by target user.""" repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "user_id": _OTHER_USER_ID}, headers=_auth("admin-tok"), ) assert resp.status_code == 201 data = resp.json() assert data["owner_user_id"] == _OTHER_USER_ID assert data["broker"] == "kiwoom" assert data["status"] == "enabled" assert data["symbol"] == "005930" def test_post_user_creates_for_self() -> None: """REQ-025 AC-2: session/env token → owner_user_id == auth.user_id (env: None→'system').""" repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"}, headers=_auth("user-tok"), ) assert resp.status_code == 201 assert resp.json()["status"] == "enabled" def test_post_service_token_returns_403() -> None: """REQ-025 AC-3: service token → HTTP 403.""" repo = _FakeRepo( asset=_make_asset(_ASSET_UUID, "active"), token_principal=TokenPrincipal( token_id="tid-1", user_id=_USER_ID, permission="trade-write", token_type="service", daemon_id="d-1", ), ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"}, headers=_auth("svc-raw-token"), ) assert resp.status_code == 403 def test_post_inactive_asset_returns_422() -> None: """REQ-025 AC-5: inactive asset → HTTP 422.""" repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "inactive")) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"}, headers=_auth("admin-tok"), ) assert resp.status_code == 422 def test_post_missing_asset_returns_422() -> None: """REQ-025 AC-5: asset not in catalog → HTTP 422.""" repo = _FakeRepo(asset=None) repo._asset = None # type: ignore[assignment] client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={"asset_id": "no-such-uuid", "broker": "kiwoom", "strategy_id": "strat-001"}, headers=_auth("admin-tok"), ) assert resp.status_code == 422 def test_post_no_bearer_returns_401() -> None: """REQ-025 AC-6: no token → HTTP 401.""" client = _make_client() resp = client.post("/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"}) assert resp.status_code == 401 def test_post_non_admin_user_id_ignored() -> None: """REQ-025 AC-7: non-admin + body user_id → user_id ignored (fallback to env user_id = None → 'system').""" repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "user_id": _OTHER_USER_ID}, headers=_auth("user-tok"), ) assert resp.status_code == 201 assert resp.json()["owner_user_id"] != _OTHER_USER_ID # --------------------------------------------------------------------------- # REQ-026: PATCH /v1/portfolios/{portfolio_id} # --------------------------------------------------------------------------- def test_patch_admin_updates_any_portfolio() -> None: """REQ-026 AC-1: admin token → can update any portfolio.""" repo = _repo_with_portfolio(owner=_OTHER_USER_ID) client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}, headers=_auth("admin-tok"), ) assert resp.status_code == 200 data = resp.json() assert data["broker"] == "ebest" assert data["symbol"] == "005930" def test_patch_user_updates_own_portfolio() -> None: """REQ-026 AC-2: user token + own portfolio → HTTP 200.""" repo = _repo_with_portfolio(owner=_USER_ID) repo._token_principal = TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="session", daemon_id=None, ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}, headers=_auth("session-token"), ) assert resp.status_code == 200 def test_patch_user_other_portfolio_returns_403() -> None: """REQ-026 AC-3: user token + other user's portfolio → HTTP 403.""" repo = _repo_with_portfolio(owner=_OTHER_USER_ID) repo._token_principal = TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="session", daemon_id=None, ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}, headers=_auth("session-token"), ) assert resp.status_code == 403 def test_patch_service_token_returns_403() -> None: """REQ-026 AC-4: service token → HTTP 403.""" repo = _repo_with_portfolio() repo._token_principal = TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="service", daemon_id="d-1", ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}, headers=_auth("svc-token"), ) assert resp.status_code == 403 def test_patch_no_bearer_returns_401() -> None: """REQ-026 AC-6: no token → HTTP 401.""" client = _make_client() resp = client.patch(f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}) assert resp.status_code == 401 def test_patch_nonexistent_portfolio_returns_404() -> None: """REQ-026 AC-7: non-existent portfolio_id → HTTP 404.""" repo = _FakeRepo() client = _make_client() with _with_repo(repo): resp = client.patch("/v1/portfolios/no-such-id", json={"broker": "ebest"}, headers=_auth("admin-tok")) assert resp.status_code == 404 def test_patch_disabled_portfolio_non_status_returns_409() -> None: """REQ-026 AC-8: disabled portfolio + non-status field → HTTP 409.""" repo = _repo_with_portfolio(status="disabled") client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}, headers=_auth("admin-tok"), ) assert resp.status_code == 409 def test_patch_disabled_portfolio_status_restore_returns_200() -> None: """REQ-026 AC-9: disabled portfolio + status='enabled' → HTTP 200 (restore).""" repo = _repo_with_portfolio(status="disabled") client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"status": "enabled"}, headers=_auth("admin-tok"), ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "enabled" assert data["symbol"] == "005930" # --------------------------------------------------------------------------- # REQ-027: DELETE /v1/portfolios/{portfolio_id} # --------------------------------------------------------------------------- def test_delete_user_own_active_portfolio_soft_deletes() -> None: """REQ-027 AC-1: session token + own active portfolio → HTTP 200 + status='disabled'.""" repo = _repo_with_portfolio(owner=_USER_ID) repo._token_principal = TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="session", daemon_id=None, ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("session-token")) assert resp.status_code == 200 data = resp.json() assert data["status"] == "disabled" assert data["managed_by_daemon_id"] is None assert data["symbol"] == "005930" def test_delete_admin_other_user_portfolio() -> None: """REQ-027 AC-3: admin token + other user's portfolio → HTTP 200.""" repo = _repo_with_portfolio(owner=_OTHER_USER_ID) client = _make_client() with _with_repo(repo): resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok")) assert resp.status_code == 200 data = resp.json() assert data["status"] == "disabled" assert data["symbol"] == "005930" def test_delete_user_other_portfolio_returns_403() -> None: """REQ-027 AC-4: session token + other user's portfolio → HTTP 403.""" repo = _repo_with_portfolio(owner=_OTHER_USER_ID) repo._token_principal = TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="session", daemon_id=None, ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("session-token")) assert resp.status_code == 403 def test_delete_service_token_returns_403() -> None: """REQ-027 AC-5: service token → HTTP 403.""" repo = _repo_with_portfolio() repo._token_principal = TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="service", daemon_id="d-1", ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("svc-token")) assert resp.status_code == 403 def test_delete_no_bearer_returns_401() -> None: """REQ-027 AC-7: no token → HTTP 401.""" client = _make_client() resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}") assert resp.status_code == 401 def test_delete_nonexistent_portfolio_returns_404() -> None: """REQ-027 AC-8: non-existent portfolio_id → HTTP 404.""" repo = _FakeRepo() client = _make_client() with _with_repo(repo): resp = client.delete("/v1/portfolios/no-such-id", headers=_auth("admin-tok")) assert resp.status_code == 404 def test_delete_already_disabled_is_idempotent() -> None: """REQ-027: already disabled portfolio → HTTP 200 (idempotent).""" repo = _repo_with_portfolio(status="disabled") client = _make_client() with _with_repo(repo): resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok")) assert resp.status_code == 200 assert resp.json()["status"] == "disabled" # Issue #26: strategy catalog references on portfolio mutations. def test_post_strategy_reference_errors_and_required_contract() -> None: repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) client = _make_client() with _with_repo(repo): missing = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "missing"}, headers=_auth("admin-tok"), ) invalid = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom"}, headers=_auth("admin-tok"), ) repo._strategy_set.status = "inactive" inactive = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"}, headers=_auth("admin-tok"), ) assert missing.status_code == 404 assert invalid.status_code == 422 assert inactive.status_code == 409 def test_patch_strategy_reference_errors_are_404_and_409() -> None: repo = _repo_with_portfolio(owner=_OTHER_USER_ID) client = _make_client() with _with_repo(repo): missing = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"strategy_id": "missing"}, headers=_auth("admin-tok"), ) repo._strategy_set.status = "inactive" inactive = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"strategy_id": "strat-001"}, headers=_auth("admin-tok"), ) assert missing.status_code == 404 assert inactive.status_code == 409