430 lines
14 KiB
Python
430 lines
14 KiB
Python
"""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
|