"""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