"""Tests for GET /v1/portfolios/{portfolio_id}/trades (REQ-039, REQ-042). Uses TestClient with MagicMock repo injection — no DB required. """ 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 # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- _ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" _USER_ID = "user-001" _OTHER_USER_ID = "user-002" _PORTFOLIO_ID = "pf-trades-001" _PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/trades" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- 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(*, owner: str = _USER_ID, status: str = "enabled") -> Portfolio: return Portfolio( portfolio_id=_PORTFOLIO_ID, broker="kis", status=status, name="test-portfolio", symbol="005930", asset_id="asset-001", owner_user_id=owner, cash_balance=1_000_000.0, asset_quantity=0.0, tag_ids=[], managed_by_daemon_id=None, ) def _make_ledger_entry( *, ledger_entry_id: int = 1, side: str = "buy", quantity: float = 100.0, price: float = 10_000.0, fee: float = 0.0, tax: float = 0.0, executed_at: datetime | None = None, ) -> LedgerEntry: if executed_at is None: executed_at = datetime(2026, 6, 15, 9, 0, 0, tzinfo=timezone.utc) return LedgerEntry( ledger_entry_id=ledger_entry_id, portfolio_id=_PORTFOLIO_ID, order_id=f"ord-{ledger_entry_id:03d}", execution_id=f"exec-{ledger_entry_id:03d}", side=side, quantity=quantity, price=price, fee_amount=fee, tax_amount=tax, executed_at=executed_at, recorded_at=executed_at, ) def _make_repo( *, portfolio: Portfolio | None = None, trades: list[LedgerEntry] | None = None, user_id: str = _USER_ID, permission: str = "read", ) -> MagicMock: repo = MagicMock() principal = TokenPrincipal( token_id="t1", user_id=user_id, permission=permission, token_type="db", runtime_id=None, daemon_id=None, ) repo.get_token_principal.return_value = principal repo.get.return_value = portfolio or _make_portfolio() repo.list_trades_by_date.return_value = trades if trades is not None else [] 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 # --------------------------------------------------------------------------- # AC-1: date=YYYY-MM-DD single date → 200 + items count matches # --------------------------------------------------------------------------- def test_trades_single_date_returns_matching_count() -> None: """AC-1: date param → HTTP 200 + items list.""" trades = [ _make_ledger_entry(ledger_entry_id=1, executed_at=datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc)), _make_ledger_entry(ledger_entry_id=2, side="sell", executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), _make_ledger_entry(ledger_entry_id=3, executed_at=datetime(2026, 6, 15, 11, 0, tzinfo=timezone.utc)), ] settings = _make_settings() repo = _make_repo(trades=trades) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() assert body["total"] == 3 assert len(body["items"]) == 3 def test_trades_single_date_item_fields() -> None: """AC-1: each item has required fields.""" trade = _make_ledger_entry(ledger_entry_id=1, side="buy", quantity=100.0, price=10_000.0, fee=50.0, tax=25.0, executed_at=datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc)) settings = _make_settings() repo = _make_repo(trades=[trade]) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 item = resp.json()["items"][0] assert item["side"] == "buy" assert item["quantity"] == 100.0 assert item["price"] == 10_000.0 assert item["fee_amount"] == 50.0 assert item["tax_amount"] == 25.0 assert "executed_at" in item assert "order_id" in item assert "execution_id" in item # --------------------------------------------------------------------------- # AC-2: from/to range filter → 200 + items from boundary dates # --------------------------------------------------------------------------- def test_trades_range_filter_returns_items() -> None: """AC-2: from/to → HTTP 200 + items count.""" trades = [_make_ledger_entry(ledger_entry_id=i) for i in range(1, 8)] settings = _make_settings() repo = _make_repo(trades=trades) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"from": "2026-06-01", "to": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["total"] == 7 # --------------------------------------------------------------------------- # AC-3: no date params → HTTP 422 # --------------------------------------------------------------------------- def test_trades_no_date_params_returns_422() -> None: settings = _make_settings() repo = _make_repo() with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 422 # --------------------------------------------------------------------------- # AC-4: bad date format → HTTP 422 # --------------------------------------------------------------------------- def test_trades_bad_date_format_returns_422() -> None: settings = _make_settings() repo = _make_repo() with _with_repo(repo): client = _make_client(settings) # "20260615" is not YYYY-MM-DD resp = client.get(_PATH, params={"date": "20260615"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 422 def test_trades_slash_date_format_returns_422() -> None: settings = _make_settings() repo = _make_repo() with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"date": "2026/06/15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 422 # --------------------------------------------------------------------------- # AC-5: no trades in range → 200 + empty items # --------------------------------------------------------------------------- def test_trades_empty_range_returns_200_empty() -> None: settings = _make_settings() repo = _make_repo(trades=[]) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() assert body["total"] == 0 assert body["items"] == [] # --------------------------------------------------------------------------- # AC-6: non-owner token → HTTP 403 # --------------------------------------------------------------------------- def test_trades_non_owner_returns_403() -> None: settings = _make_settings() # Repo returns a portfolio owned by _USER_ID, but token belongs to _OTHER_USER_ID repo = _make_repo(portfolio=_make_portfolio(owner=_USER_ID), user_id=_OTHER_USER_ID) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 # --------------------------------------------------------------------------- # AC-7: non-existent portfolio → HTTP 404 # --------------------------------------------------------------------------- def test_trades_nonexistent_portfolio_returns_404() -> None: settings = _make_settings() repo = _make_repo() repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다") with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 404 # --------------------------------------------------------------------------- # AC-8: endpoint is documented in OpenAPI schema # --------------------------------------------------------------------------- def test_trades_endpoint_in_openapi() -> None: settings = _make_settings() repo = _make_repo() with _with_repo(repo): client = _make_client(settings) resp = client.get("/openapi.json") assert resp.status_code == 200 openapi = resp.json() paths = openapi.get("paths", {}) trade_path = f"/v1/portfolios/{{portfolio_id}}/trades" assert trade_path in paths, f"Expected {trade_path!r} in OpenAPI paths" assert "get" in paths[trade_path] # --------------------------------------------------------------------------- # Partial range: from-only or to-only → 422 # --------------------------------------------------------------------------- def test_trades_from_only_without_to_returns_422() -> None: settings = _make_settings() repo = _make_repo() with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"from": "2026-06-01"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 422 def test_trades_to_only_without_from_returns_422() -> None: settings = _make_settings() repo = _make_repo() with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, params={"to": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 422