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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user