"""Tests for daemon assignment validation on POST/PATCH /v1/portfolios (Issue #27 / dcy-0004). Covers the approved discovery contract: non-null managed_by_daemon_id is validated (exists, active, same-owner as the portfolio owner) with HTTP 422/404/409 mapping, admin target-owner semantics, and persistence of the selected daemon. """ 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.errors import DaemonInactiveError, DaemonNotFoundError from app.domain.models import Asset, Daemon, Portfolio, StrategySet from app.main import create_app _ASSET_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" _PORTFOLIO_UUID = "cccccccc-cccc-cccc-cccc-cccccccccccc" _USER_ID = "user-001" _OTHER_USER_ID = "user-002" _DAEMON_UUID = "dddddddd-dddd-dddd-dddd-dddddddddddd" _DAEMON2_UUID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" 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_daemon( daemon_id: str = _DAEMON_UUID, owner: str = _USER_ID, is_active: bool = True, ) -> Daemon: return Daemon(id=daemon_id, owner_user_id=owner, daemon_slug="d-slug", is_active=is_active) def _make_portfolio(owner: str = _USER_ID) -> Portfolio: return Portfolio( portfolio_id=_PORTFOLIO_UUID, broker="kiwoom", status="enabled", 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: """Fake repository that models the repository-side atomic daemon validation.""" def __init__( self, asset: Asset | None = None, portfolios: dict[str, Portfolio] | None = None, daemons: dict[str, Daemon] | None = None, ) -> None: self._asset = asset or _make_asset() self._portfolios: dict[str, Portfolio] = portfolios or {} self._daemons: dict[str, Daemon] = daemons or {} 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_strategy_set(self, id: str) -> StrategySet | None: return self._strategy_set if id == self._strategy_set.id else None def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: return self._daemons.get(daemon_id) 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 _validate_daemon(self, daemon_id: str, owner_user_id: str) -> None: daemon = self._daemons.get(daemon_id) if daemon is None or daemon.owner_user_id != owner_user_id: raise DaemonNotFoundError(f"daemon not found: {daemon_id}") if not daemon.is_active: raise DaemonInactiveError(f"daemon is inactive: {daemon_id}") 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: if managed_by_daemon_id is not None: self._validate_daemon(managed_by_daemon_id, owner_user_id) 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 update_portfolio(self, portfolio_id: str, **fields: Any) -> Portfolio: p = self.get(portfolio_id) daemon_id = fields.get("managed_by_daemon_id") if isinstance(daemon_id, str): self._validate_daemon(daemon_id, p.owner_user_id) for k, v in fields.items(): if v is not None and hasattr(p, k): setattr(p, k, v) return p def _make_client() -> TestClient: get_settings.cache_clear() settings = _make_settings( admin_tokens_raw="admin-tok", trade_write_tokens_raw="user-tok", ) app = create_app(settings_factory=lambda: settings) app.dependency_overrides[get_settings] = lambda: settings return TestClient(app) def _with_repo(repo: _FakeRepo) -> ExitStack: stack = ExitStack() stack.enter_context( patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) ) return stack def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} # --------------------------------------------------------------------------- # POST /v1/portfolios — daemon assignment # --------------------------------------------------------------------------- def test_post_with_owner_active_daemon_returns_201_with_daemon_id() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner="system")}) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={ "asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "managed_by_daemon_id": _DAEMON_UUID, }, headers=_auth("admin-tok"), ) assert resp.status_code == 201 assert resp.json()["managed_by_daemon_id"] == _DAEMON_UUID def test_post_without_daemon_keeps_null() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)}) 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 == 201 assert resp.json()["managed_by_daemon_id"] is None def test_post_malformed_daemon_id_returns_422() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)}) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={ "asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "managed_by_daemon_id": "not-a-uuid", }, headers=_auth("admin-tok"), ) assert resp.status_code == 422 assert len(repo._portfolios) == 0 def test_post_missing_daemon_returns_404() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)}) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={ "asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "managed_by_daemon_id": "99999999-9999-9999-9999-999999999999", }, headers=_auth("admin-tok"), ) assert resp.status_code == 404 assert len(repo._portfolios) == 0 def test_post_foreign_daemon_returns_404() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_OTHER_USER_ID)}) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={ "asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "managed_by_daemon_id": _DAEMON_UUID, }, headers=_auth("admin-tok"), ) assert resp.status_code == 404 assert len(repo._portfolios) == 0 def test_post_inactive_daemon_returns_409() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner="system", is_active=False)}) client = _make_client() with _with_repo(repo): resp = client.post( "/v1/portfolios", json={ "asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "managed_by_daemon_id": _DAEMON_UUID, }, headers=_auth("admin-tok"), ) assert resp.status_code == 409 assert len(repo._portfolios) == 0 def test_post_admin_target_user_accepts_target_owner_daemon() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_OTHER_USER_ID)}) 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, "managed_by_daemon_id": _DAEMON_UUID, }, headers=_auth("admin-tok"), ) assert resp.status_code == 201 assert resp.json()["owner_user_id"] == _OTHER_USER_ID assert resp.json()["managed_by_daemon_id"] == _DAEMON_UUID def test_post_admin_target_user_rejects_admin_owned_daemon() -> None: repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)}) 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, "managed_by_daemon_id": _DAEMON_UUID, }, headers=_auth("admin-tok"), ) assert resp.status_code == 404 assert len(repo._portfolios) == 0 # --------------------------------------------------------------------------- # PATCH /v1/portfolios/{portfolio_id} — daemon assignment / reassignment # --------------------------------------------------------------------------- def _repo_with_portfolio_and_daemons(**daemon_kwargs: Any) -> _FakeRepo: repo = _FakeRepo( daemons={_DAEMON_UUID: _make_daemon(**daemon_kwargs)}, ) repo._portfolios[_PORTFOLIO_UUID] = _make_portfolio() return repo def test_patch_assign_daemon_success() -> None: repo = _repo_with_portfolio_and_daemons(owner=_USER_ID) client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"managed_by_daemon_id": _DAEMON_UUID}, headers=_auth("admin-tok"), ) assert resp.status_code == 200 assert resp.json()["managed_by_daemon_id"] == _DAEMON_UUID def test_patch_reassign_daemon_success() -> None: repo = _FakeRepo( daemons={ _DAEMON_UUID: _make_daemon(owner=_USER_ID), _DAEMON2_UUID: _make_daemon(daemon_id=_DAEMON2_UUID, owner=_USER_ID), } ) repo._portfolios[_PORTFOLIO_UUID] = _make_portfolio() client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"managed_by_daemon_id": _DAEMON2_UUID}, headers=_auth("admin-tok"), ) assert resp.status_code == 200 assert resp.json()["managed_by_daemon_id"] == _DAEMON2_UUID def test_patch_malformed_daemon_id_returns_422() -> None: repo = _repo_with_portfolio_and_daemons(owner=_USER_ID) client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"managed_by_daemon_id": "not-a-uuid"}, headers=_auth("admin-tok"), ) assert resp.status_code == 422 def test_patch_foreign_daemon_returns_404() -> None: repo = _repo_with_portfolio_and_daemons(owner=_OTHER_USER_ID) client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"managed_by_daemon_id": _DAEMON_UUID}, headers=_auth("admin-tok"), ) assert resp.status_code == 404 def test_patch_missing_daemon_returns_404_without_changing_portfolio() -> None: repo = _repo_with_portfolio_and_daemons(owner=_USER_ID) client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"managed_by_daemon_id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, headers=_auth("admin-tok"), ) assert resp.status_code == 404 assert repo.get(_PORTFOLIO_UUID).managed_by_daemon_id is None def test_patch_inactive_daemon_returns_409() -> None: repo = _repo_with_portfolio_and_daemons(owner=_USER_ID, is_active=False) client = _make_client() with _with_repo(repo): resp = client.patch( f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"managed_by_daemon_id": _DAEMON_UUID}, headers=_auth("admin-tok"), ) assert resp.status_code == 409