feat: v1 portfolio mutation, sub-resources, ledger path migration (BAT-008~011)
dcy-003/004 기반 v1 API surface 완성 및 BAT-008~011 구현·검증 완료:
- BAT-008 (REQ-019~021,024): 토큰 surface baseline 문서 갱신
- BAT-009 (REQ-025~028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
- _require_write_token, _get_portfolio_or_404, soft delete, daemon-scoped list
- BAT-010 (REQ-030~032): portfolio tag/comment 서브 라우트 신설 + baseline 갱신
- GET/POST/DELETE /v1/portfolios/{id}/tags
- POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments
- BAT-011 (REQ-029): ledger path migration
- POST /v1/assets/{asset_id}/ledger/trades → /v1/portfolios/{portfolio_id}/ledger/trades
- v1_ledger.py 제거, disabled 차단(409) 추가
- test_v1.py / test_daemon_assignment.py 구 path 갱신
- 신규 tests: test_v1_portfolios_crud.py, test_v1_portfolios_list.py,
test_v1_portfolios_tags_comments.py, test_v1_portfolios_ledger.py
- 전체 suite: 340 passed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
"""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> → 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
|
||||
Reference in New Issue
Block a user