Files
portfolio-hub/tests/test_issue34_runtime_blockers.py
genichin c7d8235bde
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s
fix: make corporate actions atomic and recoverable
2026-09-13 19:33:07 +09:00

192 lines
6.2 KiB
Python

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