"""Tests for GET /v1/portfolios/{portfolio_id}/holdings/cost-basis (REQ-043). Uses TestClient with MagicMock repo injection — no DB required. Covers AC-1~11 from REQ-043. """ from __future__ import annotations from contextlib import ExitStack from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest 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-holdings-001" _DAEMON_ID = "daemon-001" _OTHER_DAEMON_ID = "daemon-002" _PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/holdings/cost-basis" # --------------------------------------------------------------------------- # 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, symbol: str = "005930", asset_quantity: float = 0.0, managed_by_daemon_id: str | None = _DAEMON_ID, ) -> Portfolio: return Portfolio( portfolio_id=_PORTFOLIO_ID, broker="kis", status="enabled", name="test-holdings-portfolio", symbol=symbol, asset_id="asset-001", owner_user_id=owner, cash_balance=1_000_000.0, asset_quantity=asset_quantity, tag_ids=[], managed_by_daemon_id=managed_by_daemon_id, ) def _make_entry( *, ledger_entry_id: int, side: str, quantity: float, price: float, executed_at: datetime, fee_amount: float = 0.0, ) -> LedgerEntry: 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_amount, tax_amount=0.0, executed_at=executed_at, recorded_at=executed_at, ) def _make_service_repo( *, daemon_id: str | None = _DAEMON_ID, portfolio: Portfolio | None = None, trades: list[LedgerEntry] | None = None, ) -> MagicMock: """Repo mock with service token principal.""" repo = MagicMock() principal = TokenPrincipal( token_id="t-svc", user_id=_USER_ID, permission="read", 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.get_all_trades_for_pnl.return_value = trades if trades is not None else [] return repo def _make_user_repo( *, user_id: str = _USER_ID, permission: str = "read", portfolio: Portfolio | None = None, trades: list[LedgerEntry] | None = None, ) -> MagicMock: """Repo mock with user (DB) token principal.""" repo = MagicMock() principal = TokenPrincipal( token_id="t-usr", 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.get_all_trades_for_pnl.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: service token + buy 100@10,000 + buy 100@12,000 → WAC=11,000, qty=200 # --------------------------------------------------------------------------- class TestAC1WacBuysOnly: """AC-1: buy-only portfolio → WAC = weighted average, no warning.""" def _make_trades(self) -> list[LedgerEntry]: return [ _make_entry( ledger_entry_id=1, side="buy", quantity=100, price=10_000, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), ), _make_entry( ledger_entry_id=2, side="buy", quantity=100, price=12_000, executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc), ), ] def _make_portfolio_with_qty(self) -> Portfolio: return _make_portfolio(asset_quantity=200.0) def test_status_200(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 def test_current_quantity_200(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.json()["current_quantity"] == pytest.approx(200.0) def test_cost_basis_per_unit_is_11000(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.json()["cost_basis_per_unit"] == pytest.approx(11_000.0) def test_total_cost_basis_is_2_200_000(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.json()["total_cost_basis"] == pytest.approx(2_200_000.0) def test_data_completeness_warning_is_null(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.json()["data_completeness_warning"] is None # --------------------------------------------------------------------------- # AC-2: buy 200@10,000 + sell 150 → remaining qty=50, WAC unchanged=10,000 # --------------------------------------------------------------------------- class TestAC2PartialSellWacUnchanged: """AC-2: partial sell → WAC unchanged, quantity reduced.""" def _make_trades(self) -> list[LedgerEntry]: return [ _make_entry( ledger_entry_id=1, side="buy", quantity=200, price=10_000, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), ), _make_entry( ledger_entry_id=2, side="sell", quantity=150, price=12_000, executed_at=datetime(2026, 6, 10, 9, 0, tzinfo=timezone.utc), ), ] def _make_portfolio_with_qty(self) -> Portfolio: return _make_portfolio(asset_quantity=50.0) def test_current_quantity_50(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["current_quantity"] == pytest.approx(50.0) def test_wac_unchanged_at_10000(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.json()["cost_basis_per_unit"] == pytest.approx(10_000.0) def test_total_cost_basis_is_500_000(self) -> None: settings = _make_settings() repo = _make_service_repo( portfolio=self._make_portfolio_with_qty(), trades=self._make_trades(), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0) # --------------------------------------------------------------------------- # AC-3: user read_token (owner) → 200 + normal response # --------------------------------------------------------------------------- def test_ac3_user_owner_read_token_returns_200() -> None: """AC-3: portfolio owner with user read token → 200.""" settings = _make_settings() trades = [ _make_entry( ledger_entry_id=1, side="buy", quantity=100, price=10_000, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), ), ] repo = _make_user_repo( user_id=_USER_ID, portfolio=_make_portfolio(owner=_USER_ID, asset_quantity=100.0), trades=trades, ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() assert body["current_quantity"] == pytest.approx(100.0) assert body["cost_basis_per_unit"] == pytest.approx(10_000.0) # --------------------------------------------------------------------------- # AC-4: user read_token non-owner → 403 # --------------------------------------------------------------------------- def test_ac4_user_non_owner_returns_403() -> None: """AC-4: non-owner user token → 403.""" settings = _make_settings() repo = _make_user_repo( user_id=_OTHER_USER_ID, portfolio=_make_portfolio(owner=_USER_ID), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 # --------------------------------------------------------------------------- # AC-5: service token daemon_id != managed_by_daemon_id → 403 # --------------------------------------------------------------------------- def test_ac5_daemon_mismatch_returns_403() -> None: """AC-5: service token daemon_id != portfolio.managed_by_daemon_id → 403.""" settings = _make_settings() # Portfolio is assigned to DAEMON_ID, but token is for OTHER_DAEMON_ID repo = _make_service_repo( daemon_id=_OTHER_DAEMON_ID, portfolio=_make_portfolio(managed_by_daemon_id=_DAEMON_ID), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 # --------------------------------------------------------------------------- # AC-6: service token, portfolio.managed_by_daemon_id=null → 403 # --------------------------------------------------------------------------- def test_ac6_null_daemon_returns_403() -> None: """AC-6: managed_by_daemon_id=null → 403 (daemon 미배정).""" settings = _make_settings() repo = _make_service_repo( daemon_id=_DAEMON_ID, portfolio=_make_portfolio(managed_by_daemon_id=None), ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 # --------------------------------------------------------------------------- # AC-7: non-existent portfolio_id → 404 # --------------------------------------------------------------------------- def test_ac7_nonexistent_portfolio_returns_404() -> None: """AC-7: portfolio not found → 404.""" settings = _make_settings() repo = _make_service_repo() repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다") with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 404 # --------------------------------------------------------------------------- # AC-8: no buy/sell history (current_quantity=0) → 200 + null fields # --------------------------------------------------------------------------- def test_ac8_empty_ledger_returns_200_null_fields() -> None: """AC-8: no history → 200 + current_quantity=0 + null cost basis fields.""" settings = _make_settings() repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=0.0), trades=[], ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() assert body["current_quantity"] == pytest.approx(0.0) assert body["cost_basis_per_unit"] is None assert body["total_cost_basis"] is None assert body["data_completeness_warning"] is None # --------------------------------------------------------------------------- # AC-9: sell without prior buy → data_completeness_warning non-null # --------------------------------------------------------------------------- def test_ac9_sell_without_buy_triggers_warning() -> None: """AC-9: sell before any buy → data_completeness_warning non-null.""" settings = _make_settings() trades = [ _make_entry( ledger_entry_id=1, side="sell", quantity=50, price=15_000, executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc), ), ] repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=0.0), trades=trades, ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() assert body["data_completeness_warning"] is not None assert len(body["data_completeness_warning"]) > 0 # --------------------------------------------------------------------------- # AC-10: no auth token → 401 # --------------------------------------------------------------------------- def test_ac10_no_auth_token_returns_401() -> None: """AC-10: missing Authorization header → 401.""" settings = _make_settings() repo = _make_service_repo() with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH) assert resp.status_code == 401 # --------------------------------------------------------------------------- # AC-11: endpoint documented in OpenAPI # --------------------------------------------------------------------------- def test_ac11_endpoint_in_openapi() -> None: """AC-11: GET /v1/portfolios/{portfolio_id}/holdings/cost-basis is in OpenAPI spec.""" settings = _make_settings() repo = _make_service_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", {}) endpoint_path = "/v1/portfolios/{portfolio_id}/holdings/cost-basis" assert endpoint_path in paths, f"Expected {endpoint_path!r} in OpenAPI paths" assert "get" in paths[endpoint_path] # Verify response schema contains expected fields get_op = paths[endpoint_path]["get"] assert "responses" in get_op assert "200" in get_op["responses"] # --------------------------------------------------------------------------- # Additional: response contains all required fields # --------------------------------------------------------------------------- def test_response_contains_all_required_fields() -> None: """Response payload contains all REQ-043 specified fields.""" settings = _make_settings() trades = [ _make_entry( ledger_entry_id=1, side="buy", quantity=100, price=10_000, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), ), ] repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=100.0), trades=trades, ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() for field in ( "portfolio_id", "symbol", "current_quantity", "cost_basis_per_unit", "total_cost_basis", "cost_basis_from", "data_completeness_warning", ): assert field in body, f"Missing field in response: {field}" def test_cost_basis_from_is_first_buy_date() -> None: """cost_basis_from reflects the earliest buy executed_at.""" settings = _make_settings() first_buy = datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc) trades = [ _make_entry( ledger_entry_id=1, side="buy", quantity=100, price=10_000, executed_at=first_buy, ), _make_entry( ledger_entry_id=2, side="buy", quantity=50, price=12_000, executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc), ), ] repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=150.0), trades=trades, ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) body = resp.json() assert body["cost_basis_from"] is not None def test_portfolio_id_in_response_matches_request() -> None: """portfolio_id in response equals the path parameter.""" settings = _make_settings() repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=0.0), trades=[], ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["portfolio_id"] == _PORTFOLIO_ID # --------------------------------------------------------------------------- # QC보정: buy fee>0 케이스 — cost_basis_per_unit이 fee 편입값인지 확인 # buy 10@1000 fee=5 → cost_basis_per_unit = (10*1000 + 5) / 10 = 1000.5 # --------------------------------------------------------------------------- def test_cost_basis_per_unit_includes_buy_fee() -> None: """buy fee>0 케이스: cost_basis_per_unit이 매수 fee 편입값을 반환한다. buy 10주 @ 1,000원, fee=5원 → WAC = (10*1000 + 5) / 10 = 1000.5 REQ-043 Notes: 매수 fee를 cost basis에 포함. """ settings = _make_settings() trades = [ _make_entry( ledger_entry_id=1, side="buy", quantity=10, price=1_000, fee_amount=5.0, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), ), ] repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=10.0), trades=trades, ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() # (10 * 1000 + 5) / 10 = 1000.5 assert body["cost_basis_per_unit"] == pytest.approx(1000.5) assert body["total_cost_basis"] == pytest.approx(10_005.0) def test_cost_basis_per_unit_zero_fee_unchanged() -> None: """buy fee=0 케이스: fee 편입 추가 후에도 fee=0이면 기존 값 그대로. buy 10주 @ 1,000원, fee=0 → WAC = 1000.0 (회귀 확인) """ settings = _make_settings() trades = [ _make_entry( ledger_entry_id=1, side="buy", quantity=10, price=1_000, fee_amount=0.0, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), ), ] repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=10.0), trades=trades, ) with _with_repo(repo): client = _make_client(settings) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 body = resp.json() assert body["cost_basis_per_unit"] == pytest.approx(1000.0) assert body["total_cost_basis"] == pytest.approx(10_000.0)