"""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 UTC, datetime, timezone from decimal import Decimal 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) def test_corporate_action_row_returns_authoritative_holdings_cost_basis() -> None: buy = _make_entry( ledger_entry_id=1, side="buy", quantity=1452, price=1086.2341459366393, executed_at=datetime(2026, 9, 1, tzinfo=UTC), ) corporate_action = LedgerEntry( ledger_entry_id=2, portfolio_id=_PORTFOLIO_ID, source="corporate_action", side="stock_consolidation", quantity=726, price=2172.46829187, recorded_at=datetime(2026, 9, 2, tzinfo=UTC), corporate_action={ "corporate_action_id": "ca-019685", "action_type": "stock_consolidation", "quantity_before": Decimal(1452), "quantity_after": Decimal(726), "average_price_after": Decimal("2172.46829187"), "total_cost_basis": Decimal("1577211.97990000"), }, ) repo = _make_service_repo( # Corporate-action replay is authoritative for the returned quantity. portfolio=_make_portfolio(symbol="019685", asset_quantity=999), trades=[buy, corporate_action], ) with _with_repo(repo): response = _make_client(_make_settings()).get( _PATH, headers={"Authorization": "Bearer tok"}, ) assert response.status_code == 200 body = response.json() assert body["current_quantity"] == pytest.approx(726) assert body["cost_basis_per_unit"] == 1577211.9799 / 726 assert body["total_cost_basis"] == 1577211.9799 assert body["data_completeness_warning"] is None @pytest.mark.parametrize( "corporate_action", [ {"action_type": "stock_consolidation", "total_cost_basis": Decimal(1000)}, { "action_type": "stock_consolidation", "quantity_after": Decimal(0), "total_cost_basis": Decimal(1000), }, {"action_type": "stock_consolidation", "quantity_after": Decimal(10)}, ], ) def test_corporate_action_missing_required_metadata_returns_null_costs( corporate_action: dict[str, object], ) -> None: buy = _make_entry( ledger_entry_id=1, side="buy", quantity=20, price=100, executed_at=datetime(2026, 9, 1, tzinfo=UTC), ) action = LedgerEntry( ledger_entry_id=2, portfolio_id=_PORTFOLIO_ID, source="corporate_action", side="stock_consolidation", quantity=10, price=200, recorded_at=datetime(2026, 9, 2, tzinfo=UTC), corporate_action=corporate_action, ) repo = _make_service_repo( portfolio=_make_portfolio(asset_quantity=10), trades=[buy, action], ) with _with_repo(repo): response = _make_client(_make_settings()).get( _PATH, headers={"Authorization": "Bearer tok"}, ) assert response.status_code == 200 body = response.json() assert body["cost_basis_per_unit"] is None assert body["total_cost_basis"] is None assert "기업행동 metadata" in body["data_completeness_warning"] # --------------------------------------------------------------------------- # 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)