from datetime import UTC, date, datetime from decimal import Decimal from unittest.mock import MagicMock from uuid import UUID import pytest from fastapi.testclient import TestClient from app.api import v1_portfolios from app.domain.models import CorporateAction, Portfolio from app.main import create_app from app.repositories.postgres import PostgresPortfolioRepository class MissingPortfolioRepo: def get(self, portfolio_id): raise ValueError("포트폴리오를 찾을 수 없습니다") def close(self): pass class ResumeCaptureRepo: def __init__(self): self.resume_partial = None def get(self, portfolio_id): return Portfolio( portfolio_id=portfolio_id, broker="kis", status="enabled", name="대교우B", symbol="019685", asset_id="asset-1", owner_user_id="u1", cash_balance=400060.6249, asset_quantity=726, price=643, profit_buffer=0, ) def record_corporate_action( self, portfolio_id, *, action, idempotency_key, payload_hash, resume_partial=False, ): self.resume_partial = resume_partial action.average_price_before = Decimal("1086.23414594") action.average_price_after = Decimal("2172.46829187") action.total_cost_basis = Decimal("1577211.97990000") action.ledger_entry_id = 7 action.recorded_at = datetime(2026, 9, 13, tzinfo=UTC) return action def close(self): pass def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypatch): app = create_app() repo = MissingPortfolioRepo() monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo) app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type("Auth", (), {"user_id": "u1", "permission": "trade_write"})() client = TestClient(app) response = client.post( "/v1/portfolios/missing/corporate-actions", headers={"Idempotency-Key": "idem-1"}, json={"effective_date": "2026-01-01", "quantity_before": "10", "quantity_after": "5", "evidence_ref": "ev"}, ) assert response.status_code == 404 assert response.json()["detail"] == "Portfolio not found" def test_corporate_action_api_passes_explicit_resume_mode(monkeypatch): app = create_app() repo = ResumeCaptureRepo() monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo) app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type( "Auth", (), {"user_id": "u1", "permission": "trade_write"} )() response = TestClient(app).post( "/v1/portfolios/pf-1/corporate-actions", headers={"Idempotency-Key": "resume-1"}, json={ "effective_date": "2026-09-10", "quantity_before": "1452", "quantity_after": "726", "evidence_ref": "KIS 019685 2026-09-10", "resume_partial": True, }, ) assert response.status_code == 201 assert response.json()["quantity_after"] == "726" assert repo.resume_partial is True def _action(asset_id="asset-1"): return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(2), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0) def _repo_for_row(asset_id): repo = object.__new__(PostgresPortfolioRepository) repo.ensure_schema = MagicMock() conn = MagicMock() cur = MagicMock() conn.__enter__.return_value = conn conn.cursor.return_value.__enter__.return_value = cur repo._connect = MagicMock(return_value=conn) cur.fetchone.side_effect = [ (Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled", Decimal(643)), None, None, (7, date(2026, 1, 1)), ] cur.fetchall.return_value = [("buy", Decimal(2), Decimal(0), Decimal(0), Decimal(0), datetime(2025, 12, 31, tzinfo=UTC))] return repo def test_record_corporate_action_accepts_uuid_row_against_string_domain_id(): asset = UUID("12345678-1234-5678-1234-567812345678") action = _action(str(asset)) saved = _repo_for_row(asset).record_corporate_action("pf-1", action=action, idempotency_key="idem", payload_hash="hash") assert saved.ledger_entry_id == 7 def test_record_corporate_action_rejects_different_asset_id(): repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678")) with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"): repo.record_corporate_action("pf-1", action=_action("87654321-4321-8765-4321-876543218765"), idempotency_key="idem", payload_hash="hash") def test_record_corporate_action_accepts_daekyo_rounding_residue(): repo = object.__new__(PostgresPortfolioRepository) repo.ensure_schema = MagicMock() conn = MagicMock() cur = MagicMock() conn.__enter__.return_value = conn conn.cursor.return_value.__enter__.return_value = cur repo._connect = MagicMock(return_value=conn) cur.fetchone.side_effect = [ (Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled", Decimal(643)), None, None, (7, datetime(2026, 9, 13, tzinfo=UTC)), ] cur.fetchall.return_value = [ ( "buy", Decimal(1452), Decimal("1086.234145936639256198347107438016528926"), Decimal(0), Decimal(0), datetime(2026, 9, 9, tzinfo=UTC), ) ] action = CorporateAction( "ca-1", "pf-1", "asset-1", "stock_consolidation", date(2026, 9, 10), Decimal(1452), Decimal(726), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "KIS 019685 2026-09-10", "idem-daekyo", "hash-daekyo", 0, ) saved = repo.record_corporate_action( "pf-1", action=action, idempotency_key="idem-daekyo", payload_hash="hash-daekyo", ) assert saved.quantity_after == Decimal(726) assert saved.average_price_after == Decimal("2172.46829187") assert saved.total_cost_basis == Decimal("1577211.97990000") assert saved.ledger_entry_id == 7