553 lines
19 KiB
Python
553 lines
19 KiB
Python
"""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_optional_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"),
|
|
)
|
|
without_strategy = 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 without_strategy.status_code == 201
|
|
assert without_strategy.json()["active_strategy_id"] is None
|
|
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
|