"""Tests for BAT-009: GET /v1/portfolios (REQ-028).""" 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, TokenPrincipal from app.main import create_app # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _ASSET_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" _DAEMON_ID = "daemon-001" _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(markets: list[str] | None = None) -> Asset: return Asset(asset_id=_ASSET_UUID, symbol="005930", markets=markets or ["KRX"], status="active") def _make_portfolio( portfolio_id: str, owner: str = _USER_ID, status: str = "enabled", daemon_id: str | None = None, ) -> Portfolio: return Portfolio( portfolio_id=portfolio_id, broker="kiwoom", status=status, name=f"p-{portfolio_id[:4]}", symbol="005930", asset_id=_ASSET_UUID, owner_user_id=owner, cash_balance=0.0, asset_quantity=0.0, price=0.0, profit_buffer=0.0, tag_ids=[], managed_by_daemon_id=daemon_id, ) class _FakeListRepo: def __init__( self, portfolios: list[Portfolio] | None = None, token_principal: TokenPrincipal | None = None, ) -> None: self._portfolios: list[Portfolio] = portfolios or [] self._token_principal = token_principal def close(self) -> None: pass def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: return self._token_principal def list_portfolios( self, *, owner_user_id: str | None = None, managed_by_daemon_id: str | None = None, market: str | None = None, status: str | None = None, **_kwargs: Any, ) -> list[Portfolio]: results = self._portfolios if owner_user_id is not None: results = [p for p in results if p.owner_user_id == owner_user_id] if managed_by_daemon_id is not None: results = [p for p in results if p.managed_by_daemon_id == managed_by_daemon_id] if status is not None: results = [p for p in results if p.status == status] return results def _make_client( admin_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="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: _FakeListRepo, *, 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}"} # --------------------------------------------------------------------------- # REQ-028: GET /v1/portfolios # --------------------------------------------------------------------------- def test_get_service_token_daemon_bound_returns_own_portfolios() -> None: """REQ-028 AC-1: service token(daemon-bound) → portfolios with managed_by_daemon_id=auth.daemon_id.""" p1 = _make_portfolio("p-001", daemon_id=_DAEMON_ID) p2 = _make_portfolio("p-002", daemon_id="other-daemon") repo = _FakeListRepo( portfolios=[p1, p2], 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 assert data["portfolios"][0]["portfolio_id"] == "p-001" def test_get_service_token_no_daemon_returns_403() -> None: """REQ-028 AC-2: service token(no daemon_id) → HTTP 403.""" repo = _FakeListRepo( token_principal=TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="trade-write", token_type="service", daemon_id=None, ), ) 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 == 403 def test_get_user_token_returns_own_portfolios() -> None: """REQ-028 AC-3: session token + trade-write → own portfolios only.""" p1 = _make_portfolio("p-001", owner=_USER_ID) p2 = _make_portfolio("p-002", owner=_OTHER_USER_ID) repo = _FakeListRepo( portfolios=[p1, p2], 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): # Use a token not in env lists so it falls through to DB lookup resp = client.get("/v1/portfolios", headers=_auth("session-db-tok")) assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 assert data["portfolios"][0]["owner_user_id"] == _USER_ID def test_get_admin_default_returns_own_portfolios() -> None: """REQ-028 AC-4: admin token (no ?user_id) → own portfolios (env token user_id=None → no filter → all).""" p1 = _make_portfolio("p-001", owner=_USER_ID) p2 = _make_portfolio("p-002", owner=_OTHER_USER_ID) repo = _FakeListRepo(portfolios=[p1, p2]) 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 def test_get_admin_with_user_id_returns_target_portfolios() -> None: """REQ-028 AC-5: admin token + ?user_id= → target user's portfolios.""" p1 = _make_portfolio("p-001", owner=_USER_ID) p2 = _make_portfolio("p-002", owner=_OTHER_USER_ID) repo = _FakeListRepo(portfolios=[p1, p2]) client = _make_client() with _with_repo(repo): resp = client.get(f"/v1/portfolios?user_id={_OTHER_USER_ID}", headers=_auth("admin-tok")) assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 assert data["portfolios"][0]["owner_user_id"] == _OTHER_USER_ID def test_get_excludes_disabled_by_default() -> None: """REQ-028 AC-7: no ?include_disabled → disabled portfolios excluded.""" active = _make_portfolio("p-001", status="enabled") disabled = _make_portfolio("p-002", status="disabled") repo = _FakeListRepo(portfolios=[active, disabled]) 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"] == 1 assert data["portfolios"][0]["status"] == "enabled" def test_get_include_disabled_returns_all() -> None: """REQ-028 AC-8: ?include_disabled=true → all portfolios returned.""" active = _make_portfolio("p-001", status="enabled") disabled = _make_portfolio("p-002", status="disabled") repo = _FakeListRepo(portfolios=[active, disabled]) client = _make_client() with _with_repo(repo): resp = client.get("/v1/portfolios?include_disabled=true", headers=_auth("admin-tok")) assert resp.status_code == 200 data = resp.json() assert data["total"] == 2 def test_get_client_token_returns_403() -> None: """REQ-028 AC-9: client token → HTTP 403.""" repo = _FakeListRepo( token_principal=TokenPrincipal( token_id="tid", user_id=_USER_ID, permission="read", token_type="client", daemon_id=None, ), ) client = _make_client(db_enabled=True) with _with_repo(repo, db_auth=True): resp = client.get("/v1/portfolios", headers=_auth("client-tok")) assert resp.status_code == 403 def test_get_no_bearer_returns_401() -> None: """REQ-028 AC-10: no token → HTTP 401.""" client = _make_client() resp = client.get("/v1/portfolios") assert resp.status_code == 401 def test_get_response_has_asset_id_field() -> None: """REQ-028 AC-11: response portfolios reference assets.asset_id (UUID).""" p = _make_portfolio("p-001") 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["asset_id"] == _ASSET_UUID