From fa824dfd3654ea642d21343760fd71be6a24a493 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 13 Sep 2026 14:22:41 +0900 Subject: [PATCH] fix corporate action runtime blockers --- app/api/v1_portfolios.py | 3 +- app/domain/services.py | 4 +- app/repositories/postgres.py | 2 +- tests/test_issue34_runtime_blockers.py | 65 ++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue34_runtime_blockers.py diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index a8cfec7..e59318e 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -24,6 +24,7 @@ from app.core.auth import AuthContext, Permission, require_read, require_trade_w from app.core.config import get_settings from app.domain.errors import DaemonInactiveError, DaemonNotFoundError from app.domain.models import CorporateAction, Portfolio, PortfolioComment +from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE from app.domain.pnl import calculate_current_wac, calculate_realized_pnl from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import ( @@ -1332,7 +1333,7 @@ def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest, return CorporateActionResponse(**saved.__dict__) except ValueError as exc: code = str(exc) - if code == "portfolio not found": + if code == PORTFOLIO_NOT_FOUND_MESSAGE: raise HTTPException(status_code=404, detail="Portfolio not found") from exc raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc finally: diff --git a/app/domain/services.py b/app/domain/services.py index e6d6055..6211433 100644 --- a/app/domain/services.py +++ b/app/domain/services.py @@ -7,7 +7,7 @@ from typing import Callable, Protocol from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet -_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다" +PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다" _INVEST_ACTION_MAP = { "deposit": "INVEST", "profit": "PROFIT", @@ -250,7 +250,7 @@ def apply_reset( portfolio = repo.reset(portfolio_id) return portfolio, False except ValueError as exc: - if str(exc) != _NOT_FOUND_MSG: + if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE: raise symbol = infer_symbol(portfolio_id) portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0) diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 96cff58..c2af3ba 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1654,7 +1654,7 @@ class PostgresPortfolioRepository: return action if row[4] != "enabled": raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED") - if row[3] != action.asset_id: + if str(row[3]) != str(action.asset_id): raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH") if Decimal(str(row[1])) != action.cash_balance or Decimal(str(row[2])) != action.profit_buffer: raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH") diff --git a/tests/test_issue34_runtime_blockers.py b/tests/test_issue34_runtime_blockers.py new file mode 100644 index 0000000..16987ca --- /dev/null +++ b/tests/test_issue34_runtime_blockers.py @@ -0,0 +1,65 @@ +from datetime import date +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 + + +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 _action(asset_id="asset-1"): + return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal("0"), 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("0"), Decimal("10"), Decimal("0"), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))] + cur.fetchall.return_value = [] + 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")