"""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 LedgerEntry, 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) -> LedgerEntry: defaults = dict( ledger_entry_id=1, portfolio_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 LedgerEntry(**defaults) def _make_service_repo( *, daemon_id: str | None = _DAEMON_ID, portfolio: Portfolio | None = None, trade: LedgerEntry | 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["portfolio_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" def test_ledger_trade_round_trips_and_forwards_venue_provenance() -> None: settings = _make_settings() repo = _make_service_repo( trade=_make_trade(execution_venue="NXT", order_venue="SOR") ) with _with_repo(repo): client = _make_client(settings) resp = client.post( _NEW_PATH, json=_trade_payload(execution_venue="NXT", order_venue="SOR"), headers={"Authorization": "Bearer svc-token"}, ) assert resp.status_code == 201 assert resp.json()["execution_venue"] == "NXT" assert resp.json()["order_venue"] == "SOR" assert repo.insert_ledger_trade.call_args.kwargs["execution_venue"] == "NXT" assert repo.insert_ledger_trade.call_args.kwargs["order_venue"] == "SOR" def test_ledger_trade_allows_unknown_actual_venue_with_sor_route() -> None: settings = _make_settings() repo = _make_service_repo(trade=_make_trade(execution_venue=None, order_venue="SOR")) with _with_repo(repo): client = _make_client(settings) resp = client.post( _NEW_PATH, json=_trade_payload(execution_venue=None, order_venue="SOR"), headers={"Authorization": "Bearer svc-token"}, ) assert resp.status_code == 201 assert resp.json()["execution_venue"] is None assert resp.json()["order_venue"] == "SOR" def test_ledger_trade_rejects_sor_as_execution_venue() -> None: settings = _make_settings() repo = _make_service_repo() with _with_repo(repo): client = _make_client(settings) resp = client.post( _NEW_PATH, json=_trade_payload(execution_venue="SOR", order_venue="SOR"), headers={"Authorization": "Bearer svc-token"}, ) assert resp.status_code == 422 repo.insert_ledger_trade.assert_not_called() def test_ledger_trade_rejects_unknown_request_fields() -> None: settings = _make_settings() repo = _make_service_repo() with _with_repo(repo): client = _make_client(settings) resp = client.post( _NEW_PATH, json=_trade_payload(broker_ordered_at="2026-05-01T09:59:59+09:00"), headers={"Authorization": "Bearer svc-token"}, ) assert resp.status_code == 422 repo.insert_ledger_trade.assert_not_called() def test_ledger_trade_openapi_exposes_nullable_venue_enums() -> None: settings = _make_settings() repo = _make_service_repo() with _with_repo(repo): client = _make_client(settings) response = client.get("/openapi.json") assert response.status_code == 200 properties = response.json()["components"]["schemas"]["LedgerTradeRequest"][ "properties" ] execution_options = properties["execution_venue"]["anyOf"] order_options = properties["order_venue"]["anyOf"] assert {tuple(option.get("enum", ())) for option in execution_options} == { (), ("KRX", "NXT"), } assert {tuple(option.get("enum", ())) for option in order_options} == { (), ("KRX", "NXT", "SOR"), } assert {option.get("type") for option in execution_options} == {"string", "null"} assert {option.get("type") for option in order_options} == {"string", "null"} # --------------------------------------------------------------------------- # 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