feat(V1-API): implement TREX-Basic consumer API contract with encryption and persistence
- 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
This commit is contained in:
+5
-1
@@ -13,7 +13,11 @@ def test_healthcheck() -> None:
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"] == "portfolio-hub"
|
||||
assert "version" in body
|
||||
assert "checked_at" in body
|
||||
|
||||
|
||||
def test_docs_are_exposed() -> None:
|
||||
|
||||
@@ -98,6 +98,7 @@ class _AuthRepo:
|
||||
permission: str,
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._seq}",
|
||||
@@ -108,6 +109,7 @@ class _AuthRepo:
|
||||
token_type=token_type,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
)
|
||||
self._seq += 1
|
||||
self.tokens[token.id] = token
|
||||
|
||||
@@ -122,6 +122,7 @@ class FakeRepo:
|
||||
permission: str,
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._token_seq}",
|
||||
@@ -132,6 +133,7 @@ class FakeRepo:
|
||||
token_type=token_type,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
)
|
||||
self._token_seq += 1
|
||||
self._auth_tokens[token.id] = token
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user