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:
2026-05-21 07:25:39 +00:00
co-authored by Claude Sonnet 4.6
parent c815ce39b6
commit 4bf6b554a7
50 changed files with 3989 additions and 249 deletions
+10 -9
View File
@@ -42,6 +42,7 @@ def _portfolio_with_daemon(daemon_id: str | None = "d-1") -> MagicMock:
p = MagicMock(spec=Portfolio)
p.managed_by_daemon_id = daemon_id
p.portfolio_id = "asset-1"
p.status = "enabled"
return p
@@ -143,7 +144,7 @@ def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
# ---------------------------------------------------------------------------
# REQ-008: POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization
# REQ-008/029: POST /v1/portfolios/{portfolio_id}/ledger/trades daemon assignment authorization
# ---------------------------------------------------------------------------
def _setup_ledger_mocks(monkeypatch, principal: TokenPrincipal, portfolio: MagicMock, trade: MagicMock) -> tuple[MagicMock, TestClient]:
@@ -170,9 +171,9 @@ def test_ledger_trade_403_when_token_has_no_daemon_id(monkeypatch) -> None:
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.post(
"/v1/assets/asset-1/ledger/trades",
"/v1/portfolios/asset-1/ledger/trades",
json=_ledger_trade_payload(),
headers=_svc_header(),
)
@@ -189,9 +190,9 @@ def test_ledger_trade_403_when_asset_has_no_daemon_assignment(monkeypatch) -> No
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.post(
"/v1/assets/asset-1/ledger/trades",
"/v1/portfolios/asset-1/ledger/trades",
json=_ledger_trade_payload(),
headers=_svc_header(),
)
@@ -208,9 +209,9 @@ def test_ledger_trade_403_when_daemon_mismatch(monkeypatch) -> None:
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.post(
"/v1/assets/asset-1/ledger/trades",
"/v1/portfolios/asset-1/ledger/trades",
json=_ledger_trade_payload(),
headers=_svc_header(),
)
@@ -238,9 +239,9 @@ def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None:
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.post(
"/v1/assets/asset-1/ledger/trades",
"/v1/portfolios/asset-1/ledger/trades",
json=_ledger_trade_payload(),
headers=_svc_header(),
)
+10 -47
View File
@@ -571,10 +571,10 @@ def test_v1_ledger_trade_created(monkeypatch) -> None:
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=True)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
"/v1/portfolios/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
@@ -591,10 +591,10 @@ def test_v1_ledger_trade_idempotent_same_payload(monkeypatch) -> None:
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=False)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
"/v1/portfolios/005930/ledger/trades",
json=_trade_payload(), # same payload
headers=_service_token_header(),
)
@@ -610,10 +610,10 @@ def test_v1_ledger_trade_conflict_different_payload(monkeypatch) -> None:
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=False)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
"/v1/portfolios/005930/ledger/trades",
json=_trade_payload(quantity="99.0"), # different quantity → conflict
headers=_service_token_header(),
)
@@ -622,15 +622,15 @@ def test_v1_ledger_trade_conflict_different_payload(monkeypatch) -> None:
def test_v1_ledger_trade_asset_not_found(monkeypatch) -> None:
"""Non-existent asset_id → HTTP 404."""
"""Non-existent portfolio_id → HTTP 404."""
trade = _make_ledger_trade()
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=True, found=False)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/nonexistent/ledger/trades",
"/v1/portfolios/nonexistent/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
@@ -655,7 +655,7 @@ def test_v1_ledger_trade_requires_trade_write_permission(monkeypatch) -> None:
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
"/v1/portfolios/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
@@ -1025,40 +1025,3 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
assert resp.status_code == 200
assert resp.json()["disabled_reason"] == "account_inactive"
# ---------------------------------------------------------------------------
# POST /v1/assets/{asset_id}/ledger/trades
# ---------------------------------------------------------------------------
def _trade_payload(**overrides) -> dict:
base = {
"order_id": "ord-001",
"execution_id": "exec-001",
"side": "buy",
"quantity": "10.5",
"price": "50000",
"fee_amount": "100",
"tax_amount": "0",
"executed_at": "2026-05-01T10:00:00+09:00",
}
base.update(overrides)
return base
def _make_ledger_trade(**kwargs) -> LedgerTrade:
defaults = dict(
ledger_entry_id=1,
asset_id="005930",
order_id="ord-001",
execution_id="exec-001",
side="buy",
quantity=10.5,
price=50000.0,
fee_amount=100.0,
tax_amount=0.0,
executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
)
defaults.update(kwargs)
return LedgerTrade(**defaults)
+493
View File
@@ -0,0 +1,493 @@
"""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 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,
) -> 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=[],
)
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", "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"
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"},
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"},
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"},
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"},
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"})
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", "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
assert resp.json()["broker"] == "ebest"
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
assert resp.json()["status"] == "enabled"
# ---------------------------------------------------------------------------
# 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
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
assert resp.json()["status"] == "disabled"
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"
+260
View File
@@ -0,0 +1,260 @@
"""Tests for POST /v1/portfolios/{portfolio_id}/ledger/trades (REQ-029, BAT-011)."""
from __future__ import annotations
from contextlib import ExitStack
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import LedgerTrade, Portfolio, TokenPrincipal
from app.main import create_app
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
_USER_ID = "user-001"
_PORTFOLIO_ID = "pf-0001"
_DAEMON_ID = "daemon-001"
_NEW_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/trades"
_OLD_PATH = f"/v1/assets/{_PORTFOLIO_ID}/ledger/trades"
def _make_settings() -> Settings:
get_settings.cache_clear()
return Settings(
db_enabled=True,
credential_encryption_key=_ENC_KEY,
allowed_origins_raw="http://localhost:3000",
)
def _make_client(settings: Settings) -> TestClient:
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _make_portfolio(
*,
status: str = "enabled",
managed_by_daemon_id: str | None = _DAEMON_ID,
) -> Portfolio:
return Portfolio(
portfolio_id=_PORTFOLIO_ID,
broker="kis",
status=status,
name="test-portfolio",
symbol="005930",
asset_id="005930",
owner_user_id=_USER_ID,
cash_balance=1000000.0,
asset_quantity=0.0,
tag_ids=[],
managed_by_daemon_id=managed_by_daemon_id,
)
def _make_trade(**kwargs) -> LedgerTrade:
defaults = dict(
ledger_entry_id=1,
asset_id=_PORTFOLIO_ID,
order_id="ord-001",
execution_id="exec-001",
side="buy",
quantity=10.5,
price=50000.0,
fee_amount=100.0,
tax_amount=0.0,
executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
)
defaults.update(kwargs)
return LedgerTrade(**defaults)
def _make_service_repo(
*,
daemon_id: str | None = _DAEMON_ID,
portfolio: Portfolio | None = None,
trade: LedgerTrade | None = None,
created: bool = True,
) -> MagicMock:
repo = MagicMock()
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="trade-write",
token_type="service", runtime_id="rt-1", daemon_id=daemon_id,
)
repo.get_token_principal.return_value = principal
repo.get.return_value = portfolio or _make_portfolio()
repo.insert_ledger_trade.return_value = (trade or _make_trade(), created)
return repo
def _with_repo(repo: MagicMock) -> ExitStack:
stack = ExitStack()
stack.enter_context(
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)
)
stack.enter_context(
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)
)
return stack
def _trade_payload(**overrides) -> dict:
base = {
"order_id": "ord-001",
"execution_id": "exec-001",
"side": "buy",
"quantity": "10.5",
"price": "50000",
"fee_amount": "100",
"tax_amount": "0",
"executed_at": "2026-05-01T10:00:00+09:00",
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# AC-1: service token + own daemon + active portfolio → 201 + "new"
# ---------------------------------------------------------------------------
def test_ledger_trade_new() -> None:
settings = _make_settings()
repo = _make_service_repo(created=True)
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 201
body = resp.json()
assert body["status"] == "new"
assert body["asset_id"] == _PORTFOLIO_ID
# ---------------------------------------------------------------------------
# AC-2: same key + same payload → 200 + "duplicate"
# ---------------------------------------------------------------------------
def test_ledger_trade_duplicate_same_payload() -> None:
settings = _make_settings()
repo = _make_service_repo(created=False)
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
assert resp.json()["status"] == "duplicate"
# ---------------------------------------------------------------------------
# AC-3: same key + different payload → 409
# ---------------------------------------------------------------------------
def test_ledger_trade_conflict_different_payload() -> None:
settings = _make_settings()
repo = _make_service_repo(created=False)
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(
_NEW_PATH,
json=_trade_payload(quantity="99.0"),
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# AC-4: service token daemon_id IS NULL → 403 fail-closed
# ---------------------------------------------------------------------------
def test_ledger_trade_no_daemon_id() -> None:
settings = _make_settings()
repo = _make_service_repo(daemon_id=None)
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
repo.insert_ledger_trade.assert_not_called()
# ---------------------------------------------------------------------------
# AC-5: portfolio.managed_by_daemon_id IS NULL → 403 fail-closed
# ---------------------------------------------------------------------------
def test_ledger_trade_portfolio_unassigned() -> None:
settings = _make_settings()
repo = _make_service_repo(portfolio=_make_portfolio(managed_by_daemon_id=None))
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
repo.insert_ledger_trade.assert_not_called()
# ---------------------------------------------------------------------------
# AC-6: portfolio.managed_by_daemon_id != auth.daemon_id → 403 fail-closed
# ---------------------------------------------------------------------------
def test_ledger_trade_daemon_mismatch() -> None:
settings = _make_settings()
repo = _make_service_repo(portfolio=_make_portfolio(managed_by_daemon_id="other-daemon"))
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
repo.insert_ledger_trade.assert_not_called()
# ---------------------------------------------------------------------------
# AC-7: disabled portfolio → 409
# ---------------------------------------------------------------------------
def test_ledger_trade_disabled_portfolio() -> None:
settings = _make_settings()
repo = _make_service_repo(portfolio=_make_portfolio(status="disabled"))
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 409
repo.insert_ledger_trade.assert_not_called()
# ---------------------------------------------------------------------------
# AC-8: non-existent portfolio → 404
# ---------------------------------------------------------------------------
def test_ledger_trade_portfolio_not_found() -> None:
settings = _make_settings()
repo = _make_service_repo()
repo.get.side_effect = Exception("not found")
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# AC-9: old path POST /v1/assets/{asset_id}/ledger/trades → 404
# ---------------------------------------------------------------------------
def test_old_ledger_path_returns_404() -> None:
settings = _make_settings()
repo = _make_service_repo()
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_OLD_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# AC-10: no Bearer token → 401
# ---------------------------------------------------------------------------
def test_ledger_trade_no_auth() -> None:
settings = _make_settings()
repo = _make_service_repo()
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(_NEW_PATH, json=_trade_payload())
assert resp.status_code == 401
+274
View File
@@ -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
+704
View File
@@ -0,0 +1,704 @@
"""Tests for BAT-010: portfolio sub-resources — tags/comments (REQ-030, REQ-031)."""
from __future__ import annotations
from contextlib import ExitStack
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import Portfolio, PortfolioComment, Tag, TokenPrincipal
from app.main import create_app
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
_USER_ID = "user-001"
_ADMIN_ID = "admin-001"
_OTHER_USER_ID = "user-002"
_PORTFOLIO_ID = "pf-0001"
_TAG_ID_1 = "aaaaaaaa-0000-0000-0000-000000000001"
_COMMENT_ID_1 = "cccccccc-0000-0000-0000-000000000001"
_COMMENT_ID_2 = "cccccccc-0000-0000-0000-000000000002"
# ---------------------------------------------------------------------------
# Domain factories
# ---------------------------------------------------------------------------
def _make_portfolio(
*,
portfolio_id: str = _PORTFOLIO_ID,
owner_user_id: str = _USER_ID,
status: str = "enabled",
tag_ids: list[str] | None = None,
) -> Portfolio:
return Portfolio(
portfolio_id=portfolio_id,
broker="kis",
status=status,
name="Test Portfolio",
symbol="TEST",
asset_id="asset-001",
owner_user_id=owner_user_id,
tag_ids=tag_ids or [],
)
def _make_tag(
*,
tag_id: str = _TAG_ID_1,
name: str = "momentum",
created_by_user_id: str = _USER_ID,
is_admin_tag: bool = False,
is_active: bool = True,
) -> Tag:
return Tag(
tag_id=tag_id,
name=name,
created_by_user_id=created_by_user_id,
is_admin_tag=is_admin_tag,
is_active=is_active,
created_at=datetime.now(timezone.utc),
)
def _make_comment(
*,
comment_id: str = _COMMENT_ID_1,
portfolio_id: str = _PORTFOLIO_ID,
content: str = "Great portfolio",
) -> PortfolioComment:
return PortfolioComment(
id=comment_id,
portfolio_id=portfolio_id,
content=content,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# ---------------------------------------------------------------------------
# Test infrastructure
# ---------------------------------------------------------------------------
def _make_settings() -> Settings:
get_settings.cache_clear()
return Settings(
db_enabled=True,
credential_encryption_key=_ENC_KEY,
allowed_origins_raw="http://localhost:3000",
)
def _make_client(settings: Settings) -> TestClient:
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _make_db_repo(
*,
user_id: str = _USER_ID,
permission: str = "trade-write",
token_type: str = "user",
daemon_id: str | None = None,
portfolio: Portfolio | None = None,
tag: Tag | None = None,
tags_dict: dict | None = None,
comments: list[PortfolioComment] | None = None,
comment: PortfolioComment | None = None,
) -> MagicMock:
repo = MagicMock()
principal = TokenPrincipal(
token_id="t1",
user_id=user_id,
permission=permission,
token_type=token_type,
runtime_id="rt-1",
daemon_id=daemon_id,
)
repo.get_token_principal.return_value = principal
repo.get.return_value = portfolio or _make_portfolio()
repo.get_tag_by_id.return_value = tag or _make_tag()
repo.get_tags_by_ids.return_value = tags_dict if tags_dict is not None else {}
repo.append_portfolio_tag_id.return_value = None
repo.remove_portfolio_tag_id.return_value = None
repo.list_comments.return_value = comments or []
repo.create_comment.return_value = comment or _make_comment()
repo.update_comment.return_value = comment or _make_comment()
repo.delete_comment.return_value = None
return repo
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _with_repo(repo: MagicMock) -> ExitStack:
stack = ExitStack()
stack.enter_context(
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)
)
stack.enter_context(
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)
)
return stack
# ===========================================================================
# REQ-030: portfolio tag routes
# ===========================================================================
# AC-1: session token + 본인 portfolio + admin tag → POST 200
def test_post_tag_admin_tag_200() -> None:
settings = _make_settings()
client = _make_client(settings)
tag = _make_tag(is_admin_tag=True, created_by_user_id=_ADMIN_ID)
repo = _make_db_repo(tag=tag)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
repo.append_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
# AC-2: session token + 본인 portfolio + 본인 생성 active tag → POST 200
def test_post_tag_own_tag_200() -> None:
settings = _make_settings()
client = _make_client(settings)
tag = _make_tag(is_admin_tag=False, created_by_user_id=_USER_ID)
repo = _make_db_repo(tag=tag)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
# AC-3: session token + 타 user 생성 non-admin tag → POST 403
def test_post_tag_other_user_non_admin_tag_403() -> None:
settings = _make_settings()
client = _make_client(settings)
tag = _make_tag(is_admin_tag=False, created_by_user_id=_OTHER_USER_ID)
repo = _make_db_repo(tag=tag)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 403
# AC-4: is_active=FALSE tag → POST 403
def test_post_tag_inactive_tag_403() -> None:
settings = _make_settings()
client = _make_client(settings)
tag = _make_tag(is_active=False)
repo = _make_db_repo(tag=tag)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 403
# AC-5: admin token + 타 user portfolio + admin tag → POST 200
def test_post_tag_admin_other_portfolio_admin_tag_200() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID)
tag = _make_tag(is_admin_tag=True, created_by_user_id=_ADMIN_ID)
repo = _make_db_repo(user_id=_ADMIN_ID, permission="admin", portfolio=portfolio, tag=tag)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-admin-tok"),
)
assert resp.status_code == 200
# AC-6: session token + 타 user portfolio → POST 403
def test_post_tag_other_portfolio_403() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID)
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 403
# AC-7: service token (DB) → POST 403
def test_post_tag_service_token_403() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo(token_type="service", permission="trade-write")
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-svc-tok"),
)
assert resp.status_code == 403
# AC-7 (client): client token → POST 403
def test_post_tag_client_token_403() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo(token_type="client", permission="trade-write")
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-cli-tok"),
)
assert resp.status_code == 403
# AC-8: disabled portfolio + POST → 409
def test_post_tag_disabled_portfolio_409() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(status="disabled")
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 409
# AC-8 (DELETE): disabled portfolio + DELETE → 409
def test_delete_tag_disabled_portfolio_409() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(status="disabled")
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.delete(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags/{_TAG_ID_1}",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 409
# AC-9: DELETE tag from own portfolio → 200
def test_delete_tag_own_portfolio_200() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
with _with_repo(repo):
resp = client.delete(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags/{_TAG_ID_1}",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
repo.remove_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
# AC-10: GET tags → 200 + tag list
def test_get_portfolio_tags_200() -> None:
settings = _make_settings()
client = _make_client(settings)
tag = _make_tag()
repo = _make_db_repo(
portfolio=_make_portfolio(tag_ids=[_TAG_ID_1]),
tags_dict={_TAG_ID_1: tag},
)
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["tags"][0]["tag_id"] == _TAG_ID_1
assert data["tags"][0]["name"] == "momentum"
# AC-10 (empty): GET tags on portfolio with no tags → 200 + empty list
def test_get_portfolio_tags_empty_200() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo(portfolio=_make_portfolio(tag_ids=[]), tags_dict={})
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
assert resp.json() == {"tags": [], "total": 0}
# AC-11: non-existent portfolio_id → 404
def test_get_tags_nonexistent_portfolio_404() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
repo.get.side_effect = ValueError("not found")
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/nonexistent/tags",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 404
# ===========================================================================
# REQ-031: portfolio comment routes
# ===========================================================================
# AC-1: session token + 본인 portfolio → POST 201
def test_post_comment_201() -> None:
settings = _make_settings()
client = _make_client(settings)
comment = _make_comment()
repo = _make_db_repo(comment=comment)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "Great portfolio"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 201
data = resp.json()
assert data["comment_id"] == _COMMENT_ID_1
assert data["portfolio_id"] == _PORTFOLIO_ID
assert data["content"] == "Great portfolio"
repo.create_comment.assert_called_once_with(_PORTFOLIO_ID, content="Great portfolio")
# AC-2: GET comments → 200 + list (created_at desc from repo)
def test_get_comments_200() -> None:
settings = _make_settings()
client = _make_client(settings)
comments = [
_make_comment(comment_id=_COMMENT_ID_1, content="First"),
_make_comment(comment_id=_COMMENT_ID_2, content="Second"),
]
repo = _make_db_repo(comments=comments)
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["comments"]) == 2
# AC-3: PATCH comment → 200 + updated content
def test_patch_comment_200() -> None:
settings = _make_settings()
client = _make_client(settings)
updated = _make_comment(content="Updated comment")
repo = _make_db_repo(comment=updated)
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}",
json={"content": "Updated comment"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
assert resp.json()["content"] == "Updated comment"
repo.update_comment.assert_called_once_with(_PORTFOLIO_ID, _COMMENT_ID_1, content="Updated comment")
# AC-4: DELETE comment → 204
def test_delete_comment_204() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
with _with_repo(repo):
resp = client.delete(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 204
repo.delete_comment.assert_called_once_with(_PORTFOLIO_ID, _COMMENT_ID_1)
# AC-5: session token + 타 user portfolio → 403
def test_post_comment_other_portfolio_403() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID)
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "Trying to comment"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 403
# AC-6: service token → POST 403
def test_post_comment_service_token_403() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo(token_type="service", permission="read")
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "Service comment"},
headers=_auth("db-svc-tok"),
)
assert resp.status_code == 403
# AC-6 (client): client token → POST 403
def test_post_comment_client_token_403() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo(token_type="client", permission="read")
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "Client comment"},
headers=_auth("db-cli-tok"),
)
assert resp.status_code == 403
# AC-6 (GET): service token → GET comments 403
def test_get_comments_service_token_403() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo(token_type="service", permission="read")
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
headers=_auth("db-svc-tok"),
)
assert resp.status_code == 403
# AC-7: disabled portfolio + POST → 409
def test_post_comment_disabled_409() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(status="disabled")
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "Comment on disabled"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 409
# AC-7 (PATCH): disabled portfolio → 409
def test_patch_comment_disabled_409() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(status="disabled")
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}",
json={"content": "Update on disabled"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 409
# AC-7 (DELETE): disabled portfolio → 409
def test_delete_comment_disabled_409() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(status="disabled")
repo = _make_db_repo(portfolio=portfolio)
with _with_repo(repo):
resp = client.delete(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 409
# AC-8: disabled portfolio + GET → 200 (조회는 허용)
def test_get_comments_disabled_portfolio_200() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(status="disabled")
comments = [_make_comment()]
repo = _make_db_repo(portfolio=portfolio, comments=comments)
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 200
assert resp.json()["total"] == 1
# AC-9: admin token + 타 user portfolio → POST 201
def test_post_comment_admin_other_portfolio_201() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID)
comment = _make_comment()
repo = _make_db_repo(user_id=_ADMIN_ID, permission="admin", portfolio=portfolio, comment=comment)
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "Admin comment"},
headers=_auth("db-admin-tok"),
)
assert resp.status_code == 201
# AC-9 (admin GET): admin token + 타 user portfolio → GET 200
def test_get_comments_admin_other_portfolio_200() -> None:
settings = _make_settings()
client = _make_client(settings)
portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID)
repo = _make_db_repo(user_id=_ADMIN_ID, permission="admin", portfolio=portfolio)
with _with_repo(repo):
resp = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
headers=_auth("db-admin-tok"),
)
assert resp.status_code == 200
# AC-10: Bearer token 없음 → 401
def test_post_comment_no_auth_401() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments",
json={"content": "No auth"},
)
assert resp.status_code == 401
# AC-11: non-existent comment_id on PATCH → 404
def test_patch_comment_nonexistent_404() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
repo.update_comment.side_effect = ValueError("댓글을 찾을 수 없습니다")
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments/nonexistent",
json={"content": "Update"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 404
# AC-11 (DELETE): non-existent comment_id → 404
def test_delete_comment_nonexistent_404() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
repo.delete_comment.side_effect = ValueError("댓글을 찾을 수 없습니다")
with _with_repo(repo):
resp = client.delete(
f"/v1/portfolios/{_PORTFOLIO_ID}/comments/nonexistent",
headers=_auth("db-user-tok"),
)
assert resp.status_code == 404
# AC-11 (portfolio not found): non-existent portfolio_id → 404
def test_post_comment_nonexistent_portfolio_404() -> None:
settings = _make_settings()
client = _make_client(settings)
repo = _make_db_repo()
repo.get.side_effect = ValueError("portfolio를 찾을 수 없습니다")
with _with_repo(repo):
resp = client.post(
f"/v1/portfolios/nonexistent/comments",
json={"content": "Comment"},
headers=_auth("db-user-tok"),
)
assert resp.status_code == 404