Files
portfolio-hub/tests/test_v1.py
T
genichin e2226e28b6 feat: implement asset catalog database foundation (BAT-001, REQ-009/011/014)
Asset Catalog Model Implementation (BAT-001):
- Introduce assets table with UUID PK, symbol UNIQUE, markets TEXT[] array
- Define MarketCode enum (KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITHUMB)
- Add Asset domain model with catalog properties
- Implement asset CRUD protocols in portfolio_repository
- Add PostgreSQL asset CRUD operations with GIN index on markets

Portfolio Model Refactoring (REQ-011):
- Add portfolios.asset_id UUID FK to assets(asset_id) ON DELETE RESTRICT
- Remove portfolios.symbol and portfolios.market columns
- Update Portfolio domain model to reference asset_id instead of symbol/market
- Refactor all portfolio queries to JOIN assets catalog
- Update all repository methods to use asset references

Data Migration (REQ-014):
- Create 20260519_0017 Alembic revision for assets table creation
- Create 20260519_0018 Alembic revision for atomic data migration:
  * Extract unique symbol combinations from portfolios
  * Aggregate markets using ARRAY_AGG(DISTINCT market)
  * Populate assets catalog with extracted data
  * Update portfolios.asset_id to reference new assets
  * Remove legacy symbol/market columns in single transaction
- Implement migration with rollback support

Requirements Approved (REQ-001~008):
- REQ-001~008 transitioned from Proposed to Approved
- Complete tag model requirement set with implementation readiness

New Requirements Approved (REQ-009~015):
- REQ-009: assets table schema (approved, included in BAT-001)
- REQ-010: admin asset catalog CRUD (approved, future BAT-002)
- REQ-011: portfolios asset_id FK (approved, included in BAT-001)
- REQ-012: portfolio create catalog validation (approved, future BAT-002)
- REQ-013: GET /v1/assets token branching (approved, future BAT-002)
- REQ-014: portfolios data migration (approved, included in BAT-001)
- REQ-015: asset catalog baseline docs (approved)

Code Changes:
- app/domain/models.py: Add Asset model, MarketCode enum, Portfolio asset_id FK
- app/repositories/portfolio_repository.py: Define asset CRUD protocol
- app/repositories/postgres.py: Implement asset operations with full CRUD
- app/api/v1_assets.py: Remove symbol/market direct references
- app/cli/commands/portfolio.py: Update portfolio commands for asset references
- app/schemas/portfolio.py: Update portfolio schema with asset_id
- All portfolio operations throughout codebase: Use asset references

Testing:
- Add test_asset_catalog.py for asset CRUD operations
- Verify REQ-009 AC-1~5 (asset catalog creation, lookup, uniqueness)
- Verify REQ-011 AC-1~5 (FK constraint, portfolio asset reference)
- Verify REQ-014 AC-1~6 (migration atomicity, data correctness, rollback)
- Update existing tests for asset model usage

Documentation:
- Create BAT-001 batch documents (planning, design, implementation, verification)
- Update dcy-001 with implementation details and confirmed architecture
- Add 8 new requirement documents (REQ-009~015)
- Update docs/discovery/index.md with batch entry

Impact:
- Database: assets catalog table, portfolios refactored to reference assets
- Migration: 2 new Alembic revisions (0017 assets, 0018 portfolio migration)
- API: Symbol/market now accessed through asset references
- CLI: Portfolio commands require asset reference pattern
- All existing portfolio data preserved and correctly migrated
2026-05-19 15:59:20 +00:00

933 lines
34 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, get_settings
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerTrade
from app.main import create_app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Valid Fernet test key (32 null bytes base64-encoded)
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
**kwargs,
)
def _make_app(settings: Settings | None = None) -> TestClient:
get_settings.cache_clear()
s = settings or _make_settings()
app = create_app(settings_factory=lambda: s)
app.dependency_overrides[get_settings] = lambda: s
return TestClient(app)
def _make_app_db(monkeypatch, **extra_settings) -> Settings:
"""Return settings with db_enabled=True and a valid encryption key for startup."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _TEST_ENC_KEY)
return _make_settings(**extra_settings)
def _service_token_header(token: str = "svc-token") -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
class _RuntimeIdRoundTripRepo:
def __init__(self) -> None:
self.users = {
"user-1": User(
id="user-1",
username="alice",
email="alice@example.com",
permission="read",
is_active=True,
)
}
self.users_by_username = {"alice": self.users["user-1"]}
self.tokens_by_hash: dict[str, AuthToken] = {}
self._seq = 1
def close(self) -> None:
return None
def get_user_by_username(self, username: str) -> User | None:
return self.users_by_username.get(username)
def get_user(self, user_id: str) -> User:
user = self.users.get(user_id)
if user is None:
raise ValueError("사용자를 찾을 수 없습니다")
return user
def create_auth_token(
self,
user_id: str,
*,
name: str,
token_hash: str,
token_prefix: str,
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._seq}",
user_id=user_id,
name=name,
token_prefix=token_prefix,
permission=permission,
token_type=token_type,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
daemon_id=daemon_id,
)
self._seq += 1
self.tokens_by_hash[token_hash] = token
return token
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
token = self.tokens_by_hash.get(token_hash)
if token is None:
return None
return TokenPrincipal(
token_id=token.id,
user_id=token.user_id,
permission=token.permission,
token_type=token.token_type,
runtime_id=token.runtime_id,
daemon_id=token.daemon_id,
)
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
return []
# ---------------------------------------------------------------------------
# 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"]
# ---------------------------------------------------------------------------
# POST /auth/service-tokens (B-1: REQ-005 AC2)
# ---------------------------------------------------------------------------
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
repo = _RuntimeIdRoundTripRepo()
settings = _make_settings(
db_enabled=True,
admin_tokens_raw="admin-token",
credential_encryption_key=_TEST_ENC_KEY,
)
with patch("app.services.auth_service.get_portfolio_repository_from_settings", return_value=repo), \
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=repo):
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
issue_resp = client.post(
"/auth/service-tokens",
headers={"Authorization": "Bearer admin-token"},
json={"name": "bot-1", "username": "alice"},
)
assert issue_resp.status_code == 201
issue_body = issue_resp.json()
assert issue_body["runtime_id"] is not None
assert "token" in issue_body
user_info_resp = client.get(
"/v1/user_info",
headers=_service_token_header(issue_body["token"]),
)
assert user_info_resp.status_code == 200
user_info_body = user_info_resp.json()
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
assert user_info_body["username"] == "alice"
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None:
repo = _RuntimeIdRoundTripRepo()
settings = _make_settings(
db_enabled=True,
admin_tokens_raw="admin-token",
credential_encryption_key=_TEST_ENC_KEY,
)
with patch("app.services.auth_service.get_portfolio_repository_from_settings", return_value=repo):
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
first = client.post(
"/auth/service-tokens",
headers={"Authorization": "Bearer admin-token"},
json={"name": "bot-1", "username": "alice"},
)
second = client.post(
"/auth/service-tokens",
headers={"Authorization": "Bearer admin-token"},
json={"name": "bot-2", "username": "alice"},
)
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["runtime_id"] is not None
assert second.json()["runtime_id"] is not None
assert first.json()["runtime_id"] != second.json()["runtime_id"]
# ---------------------------------------------------------------------------
# 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",
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",
managed_by_daemon_id="d-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:
settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
item = body["assets"][0]
assert item["asset_id"] == "p1"
assert "market" not in item
assert item["broker_type"] == "kis"
assert item["cash"] == 1000.0
assert item["quantity"] == 10.0
assert item["strategy_params"] == {}
assert item["strategy_type"] is None
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
)
def test_v1_assets_status_filter(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
p_enabled = _make_portfolio("p1") # status="enabled"
p_active = Portfolio(
portfolio_id="p2", broker="kis", status="active", name="Active",
symbol="000660", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0,
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p_enabled, p_active]
mock_repo.get_strategy_set.return_value = None
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?status=active", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p2"
def test_v1_assets_strategy_params(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
from app.domain.models import StrategySet
import datetime as _dt
ss = StrategySet(id="ss-1", name="strategy", config='{"buy_threshold": 0.05}', nick="s1",
created_at=_dt.datetime.now(_dt.timezone.utc))
p = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
cash_balance=0.0, asset_quantity=0.0, price=0.0,
profit_buffer=0.0, active_strategy_id="ss-1",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.return_value = ss
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["assets"][0]["strategy_params"] == {"buy_threshold": 0.05}
assert resp.json()["assets"][0]["strategy_type"] == "strategy"
def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
"""strategy_set lookup failure → strategy_params == {}."""
settings = _make_app_db(monkeypatch)
p = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
cash_balance=0.0, asset_quantity=0.0, price=0.0,
profit_buffer=0.0, active_strategy_id="ss-bad",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.side_effect = RuntimeError("DB error")
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["assets"][0]["strategy_params"] == {}
assert resp.json()["assets"][0]["strategy_type"] is None
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["runtime_id"] is None
def test_v1_user_info_returns_user(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
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
mock_repo.list_broker_credentials.return_value = []
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
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["trading_enabled"] is True
assert body["disabled_reason"] is None
assert body["brokers"] == []
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
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
mock_repo.list_broker_credentials.return_value = []
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(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["trading_enabled"] is False
assert body["disabled_reason"] == "account_inactive"
def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
"""REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환."""
from app.core.encryption import encrypt_payload
import json as _json
settings = _make_app_db(monkeypatch)
plain = _json.dumps({"app_key": "mykey", "app_secret": "mysecret"})
encrypted = encrypt_payload(_TEST_ENC_KEY, plain)
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", permission="read", is_active=True)
cred = BrokerCredential(
user_id="user-1", broker="kis", account_ref="12345678",
encrypted_payload=encrypted, credential_version=1,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [cred]
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):
settings = _make_settings(credential_encryption_key=_TEST_ENC_KEY)
from app.core.config import get_settings as _gs
app_instance = create_app(settings_factory=lambda: settings)
app_instance.dependency_overrides[_gs] = lambda: settings
client = TestClient(app_instance)
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert len(body["brokers"]) == 1
broker = body["brokers"][0]
assert broker["broker"] == "kis"
assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"}
# ---------------------------------------------------------------------------
# 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 _make_ledger_principal() -> TokenPrincipal:
return TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1", daemon_id="d-1"
)
def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, found: bool = True) -> MagicMock:
settings = _make_app_db(monkeypatch)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = _make_ledger_principal()
if found:
mock_portfolio = MagicMock()
mock_portfolio.managed_by_daemon_id = "d-1"
mock_repo.get.return_value = mock_portfolio
else:
mock_repo.get.side_effect = Exception("not found")
mock_repo.insert_ledger_trade.return_value = (trade, created)
return mock_repo
def test_v1_ledger_trade_created(monkeypatch) -> None:
trade = _make_ledger_trade()
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
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["status"] == "new"
assert body["asset_id"] == "005930"
def test_v1_ledger_trade_idempotent_same_payload(monkeypatch) -> None:
"""Duplicate with same payload → HTTP 200 + status='duplicate'."""
trade = _make_ledger_trade()
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(), # same payload
headers=_service_token_header(),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "duplicate"
def test_v1_ledger_trade_conflict_different_payload(monkeypatch) -> None:
"""Duplicate key with different payload → HTTP 409."""
trade = _make_ledger_trade() # side=buy, quantity=10.5
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(quantity="99.0"), # different quantity → conflict
headers=_service_token_header(),
)
assert resp.status_code == 409
def test_v1_ledger_trade_asset_not_found(monkeypatch) -> None:
"""Non-existent asset_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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/nonexistent/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 404
def test_v1_ledger_trade_requires_trade_write_permission(monkeypatch) -> None:
"""Read-only service token must be rejected before asset lookup."""
_make_app_db(monkeypatch)
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
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",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 403
assert resp.json()["detail"] == "Insufficient permission"
mock_repo.get.assert_not_called()
mock_repo.insert_ledger_trade.assert_not_called()
# ---------------------------------------------------------------------------
# REQ-010: startup validation
# ---------------------------------------------------------------------------
def test_startup_fails_when_encryption_key_missing() -> None:
"""B-10: Settings.db_enabled=True + no key → lifespan raises RuntimeError."""
import pytest
settings = _make_settings(db_enabled=True, credential_encryption_key="")
with pytest.raises(RuntimeError, match="CREDENTIAL_ENCRYPTION_KEY"):
with TestClient(create_app(settings_factory=lambda: settings)):
pass # lifespan should raise before this
def test_startup_ok_when_encryption_key_present() -> None:
"""B-10 AC1: valid key → app starts normally."""
settings = _make_settings(db_enabled=False, credential_encryption_key=_TEST_ENC_KEY)
with TestClient(create_app(settings_factory=lambda: settings)) as client:
resp = client.get("/health")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# REQ-010: encrypt/decrypt round-trip (B-11 evidence)
# ---------------------------------------------------------------------------
def test_encrypt_decrypt_roundtrip() -> None:
"""Fernet encrypt→upsert→decrypt round-trip works for credential payload."""
import json as _json
from app.core.encryption import encrypt_payload, decrypt_payload
plain = _json.dumps({"app_key": "mykey", "app_secret": "mysecret"})
ciphertext = encrypt_payload(_TEST_ENC_KEY, plain)
# ciphertext is not plaintext
assert ciphertext != plain
assert "mykey" not in ciphertext
# roundtrip
recovered = decrypt_payload(_TEST_ENC_KEY, ciphertext)
assert _json.loads(recovered) == {"app_key": "mykey", "app_secret": "mysecret"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
**kwargs,
)
def _make_app(settings: Settings | None = None) -> TestClient:
get_settings.cache_clear()
s = settings or _make_settings()
app = create_app(settings_factory=lambda: s)
app.dependency_overrides[get_settings] = lambda: s
return TestClient(app)
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",
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",
managed_by_daemon_id="d-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", daemon_id="d-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 "market" not in body["assets"][0]
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_null_runtime_id_returns_200_with_null(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
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
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()["runtime_id"] is None
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)