feat(portfolios): add unified ledger history API

This commit is contained in:
2026-08-20 12:01:28 +09:00
parent fe22be0b66
commit ac742b1150
10 changed files with 307 additions and 25 deletions
+3 -3
View File
@@ -50,7 +50,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
assert 'RELEASE_VERSION=v0.1.3' not in verify
def test_release_version_and_migration_contract_are_v013_head_0029():
def test_release_version_and_migration_contract_are_v013_head_0030():
project = (REPO / "pyproject.toml").read_text()
config = (REPO / "app/core/config.py").read_text()
migrations = sorted(
@@ -60,8 +60,8 @@ def test_release_version_and_migration_contract_are_v013_head_0029():
)
assert 'version = "0.1.3"' in project
assert 'app_version: str = "0.1.3"' in config
assert len(migrations) == 29
assert migrations[-1].startswith("20260814_0029")
assert len(migrations) == 30
assert migrations[-1].startswith("20260820_0030")
def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
@@ -3,41 +3,32 @@
from __future__ import annotations
import subprocess
import sys
# REQ-016 AC-1: 후보 소스에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
# REQ-016 AC-1: codebase에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
def test_no_issue_client_token_symbol_in_codebase() -> None:
def _source_symbol_matches(symbol: str) -> list[str]:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "issue_client_token"],
["grep", "-r", "--exclude-dir=temp", "--exclude-dir=.git", "--exclude-dir=.venv", "--include=*.py", "-l", symbol],
capture_output=True,
check=False,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
return [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
def test_no_issue_client_token_symbol_in_codebase() -> None:
matches = _source_symbol_matches("issue_client_token")
assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}"
def test_no_create_client_token_request_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "CreateClientTokenRequest"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
matches = _source_symbol_matches("CreateClientTokenRequest")
assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}"
def test_no_issued_client_token_response_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "IssuedClientTokenResponse"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
matches = _source_symbol_matches("IssuedClientTokenResponse")
assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}"
+80
View File
@@ -158,6 +158,27 @@ class _FakeRepo:
self.get(portfolio_id)
return list(reversed(self.entries))[:limit]
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
self.get(portfolio_id)
entries = sorted(self.entries, key=lambda entry: (entry.recorded_at, entry.ledger_entry_id), reverse=True)
if source is not None:
entries = [entry for entry in entries if entry.source == source]
if before_recorded_at is not None and before_ledger_entry_id is not None:
entries = [
entry
for entry in entries
if (entry.recorded_at, entry.ledger_entry_id) < (before_recorded_at, before_ledger_entry_id)
]
return entries[:limit]
def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
self.get(portfolio_id)
reversed_ids = {
@@ -399,3 +420,62 @@ def test_reversal_rejects_fee_tax_and_missing_target() -> None:
)
assert missing_target.status_code == 404
assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND"
def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
repo = _FakeRepo()
client = _make_client(repo)
manual = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth()
)
assert manual.status_code == 201
repo.entries.append(
LedgerEntry(
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
recorded_at=datetime.now(timezone.utc), cash_balance_after=900, asset_quantity_after=12,
)
)
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())
assert first.status_code == 200
first_item = first.json()["items"][0]
assert first_item["source"] == "broker"
assert first_item["action"] == "BUY"
assert "side" not in first_item
assert first.json()["next_cursor"]
second = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={first.json()['next_cursor']}", headers=_auth()
)
assert second.status_code == 200
assert second.json()["items"][0]["ledger_entry_id"] == manual.json()["ledger_entry_id"]
assert second.json()["next_cursor"] is None
def test_unified_ledger_rejects_tampered_or_different_source_cursor() -> None:
repo = _FakeRepo()
client = _make_client(repo)
for amount in (100, 200):
assert client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": amount}, headers=_auth()
).status_code == 201
cursor = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()).json()["next_cursor"]
assert client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={cursor[:-1]}x", headers=_auth()).status_code == 422
assert client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&source=manual&cursor={cursor}", headers=_auth()
).status_code == 422
def test_unified_ledger_source_filter_unknown_value_is_empty_and_owner_scoped() -> None:
repo = _FakeRepo()
client = _make_client(repo)
assert client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth()
).status_code == 201
filtered = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=unknown-producer", headers=_auth())
assert filtered.status_code == 200
assert filtered.json() == {"items": [], "next_cursor": None}
foreign_client = _make_client(_FakeRepo(_portfolio(owner=_OTHER_USER_ID)))
assert foreign_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403
service_client = _make_client(_FakeRepo(principal=_token_principal(token_type="service")))
assert service_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403