feat(portfolios): add unified ledger history API
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user