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