192 lines
6.2 KiB
Python
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
|