- Add V1 API endpoints: assets, ledger, user-info - Implement Fernet-based credential payload encryption (req-010) - Extend health response schema with runtime info (req-004) - Add service token runtime ID issuance (req-005) - Implement database schema migration for V1 API (req-009) - Add comprehensive test coverage for V1 endpoints - Update SRS with 10 new requirements (REQ-004 through REQ-011) - Create batch planning, design, and verification for BAT-001 - Integrate review-discovery StagePilot skill - Update documentation indices and baseline specs
286 lines
9.5 KiB
Python
286 lines
9.5 KiB
Python
"""Tests for v1 API endpoints (health, assets, user_info, ledger/trades)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.auth import AuthContext, Permission
|
|
from app.core.config import Settings
|
|
from app.domain.models import AuthToken, Portfolio, TokenPrincipal, User, LedgerTrade
|
|
from app.main import create_app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_settings(**kwargs) -> Settings:
|
|
return Settings(
|
|
allowed_origins_raw="http://localhost:3000",
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def _make_app(settings: Settings | None = None) -> TestClient:
|
|
s = settings or _make_settings()
|
|
return TestClient(create_app(settings_factory=lambda: s))
|
|
|
|
|
|
def _service_token_header(token: str = "svc-token") -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /health
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_health_returns_full_response() -> None:
|
|
client = _make_app()
|
|
resp = client.get("/health")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "ok"
|
|
assert body["service"] == "portfolio-hub"
|
|
assert "version" in body
|
|
assert "checked_at" in body
|
|
# checked_at should contain KST offset (+09:00)
|
|
assert "+09:00" in body["checked_at"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/assets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
|
return Portfolio(
|
|
portfolio_id=portfolio_id,
|
|
broker="kis",
|
|
status="enabled",
|
|
name="Test",
|
|
symbol="005930",
|
|
market="KRW",
|
|
tags=["tag1"],
|
|
cash_balance=1000.0,
|
|
asset_quantity=10.0,
|
|
price=50000.0,
|
|
profit_buffer=0.0,
|
|
active_strategy_id=None,
|
|
avabile_strategies=None,
|
|
owner_user_id="user-1",
|
|
)
|
|
|
|
|
|
def test_v1_assets_requires_service_token(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
|
settings = _make_settings(db_enabled=False)
|
|
client = _make_app(settings)
|
|
resp = client.get("/v1/assets")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_v1_assets_rejects_env_token() -> None:
|
|
from app.core.config import get_settings
|
|
settings = _make_settings(db_enabled=False, read_tokens_raw="env-token")
|
|
app = create_app(settings_factory=lambda: settings)
|
|
app.dependency_overrides[get_settings] = lambda: settings
|
|
client = TestClient(app)
|
|
resp = client.get("/v1/assets", headers=_service_token_header("env-token"))
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
portfolio = _make_portfolio()
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-1"
|
|
)
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
|
|
|
|
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
|
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app()
|
|
resp = client.get("/v1/assets", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["total"] == 1
|
|
assert body["assets"][0]["asset_id"] == "p1"
|
|
assert body["assets"][0]["market"] == "KRW"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/user_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id=None, # no runtime_id
|
|
)
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
|
|
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app()
|
|
resp = client.get("/v1/user_info", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 404
|
|
assert "runtime_id" in resp.json()["detail"]
|
|
|
|
|
|
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-abc"
|
|
)
|
|
user = User(
|
|
id="user-1", username="alice", email="alice@example.com",
|
|
permission="read", is_active=True,
|
|
)
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.get_user.return_value = user
|
|
|
|
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
|
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app()
|
|
resp = client.get("/v1/user_info", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["runtime_id"] == "rt-abc"
|
|
assert body["username"] == "alice"
|
|
assert body["disabled_reason"] is None
|
|
|
|
|
|
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-2", permission="read",
|
|
token_type="service", runtime_id="rt-xyz"
|
|
)
|
|
user = User(
|
|
id="user-2", username="bob", email=None,
|
|
permission="read", is_active=False,
|
|
)
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.get_user.return_value = user
|
|
|
|
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
|
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app()
|
|
resp = client.get("/v1/user_info", headers=_service_token_header())
|
|
|
|
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)
|
|
|
|
|
|
def test_v1_ledger_trade_created(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="trade-write",
|
|
token_type="service", runtime_id="rt-1"
|
|
)
|
|
trade = _make_ledger_trade()
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.insert_ledger_trade.return_value = (trade, 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):
|
|
client = _make_app()
|
|
resp = client.post(
|
|
"/v1/assets/005930/ledger/trades",
|
|
json=_trade_payload(),
|
|
headers=_service_token_header(),
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
body = resp.json()
|
|
assert body["created"] is True
|
|
assert body["asset_id"] == "005930"
|
|
|
|
|
|
def test_v1_ledger_trade_idempotent(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="trade-write",
|
|
token_type="service", runtime_id="rt-1"
|
|
)
|
|
trade = _make_ledger_trade()
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.insert_ledger_trade.return_value = (trade, False) # already existed
|
|
|
|
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):
|
|
client = _make_app()
|
|
resp = client.post(
|
|
"/v1/assets/005930/ledger/trades",
|
|
json=_trade_payload(),
|
|
headers=_service_token_header(),
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
assert resp.json()["created"] is False
|