Files
cyclone/backend/tests/test_api_remittances_summary.py
T
Nora 14fcbca5f1 feat(sp27): server-aggregate Remittances KPIs (count, paid, adjustments)
The Remittances page's three KPI tiles — REMITS / TOTAL PAID /
ADJUSTMENTS — were computed page-locally via items.reduce(...) over
the merged tail of the current page + live delta. With a 100-row
default limit, a 1,739-row population showed count=100, paid=$16,934,
adjustments=$147 — silently understating reality because the page
hadn't loaded the remaining rows yet.

This change mirrors the silent-incompleteness fix that
/api/dashboard/kpis (commit 59c3275) and /api/remittances (commit
d81b6ed) made for their tiles:

  * CycloneStore.summarize_remittances() iterates the full filtered
    remittance population (no limit) and returns
    {count, total_paid, total_adjustments}. Mirrors iter_remittances
    with limit=_ITER_UNBOUNDED.
  * GET /api/remittances/summary — server endpoint with the same
    filter parameters as /api/remittances. Registered BEFORE the
    /api/remittances/stream handler so FastAPI doesn't treat
    'summary' as a stream sub-path.
  * api.listRemittanceSummary + useRemittanceSummary hook.
  * Remittances.tsx swaps off items.reduce, consumes the server
    summary. Tiles render the server totals so the values reflect
    the entire DB population, not the page-local sample.

Verified live: /api/remittances/summary returns
{count: 1739, total_paid: 227181.58, total_adjustments: 13792.65},
which matches DB ground truth exactly.

Tests: 8 new backend tests in test_api_remittances_summary.py; 1 new
frontend test in Remittances.test.tsx (kpi_tiles_use_server_summary_not
_page_local_reduce) plus the page-level test for the zero-valued mock
default.
2026-06-29 14:32:10 -06:00

298 lines
10 KiB
Python

"""Tests for ``GET /api/remittances/summary`` (server-aggregated KPI
backend for the Remittances page tiles).
Anomaly recap (see ``docs/superpowers/specs/2026-06-29-cyclone-...
-design.md`` if it lands later):
* The Remittances page's "Total paid" + "Adjustments" KPI tiles
previously summed ``items.reduce(...)`` over the *current page* (25
rows) plus whatever live-tail events had arrived in the session.
* For an empty-looking page with most-paid=0 rows, the UI rendered
e.g. ``$16,934`` paid / ``$147`` adjustments — neither the page
total ($1,334 / $143) nor the true DB total ($227,181 / $13,792).
Same silent-incompleteness pattern the count bug retired in
``d81b6ed``: the page looked complete but the numbers were wrong.
The fix adds a server-aggregated summary endpoint — ``count + paid
+ adjustments`` summed over the full persisted row set with the same
filter pipeline as ``/api/remittances``. ``Remittances.tsx`` swaps
``items.reduce(...)`` for the new hook so the tiles reflect the true
DB population regardless of dataset size.
The covered cases match ``test_list_endpoint_counts.py`` /
``test_acks_aggregates.py`` for shape consistency.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Remittance
from cyclone.store import store as global_store
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_remit(
s,
remit_id: str,
batch_id: str,
*,
received_at: datetime,
total_paid: Decimal = Decimal("100.00"),
adjustment_amount: Decimal = Decimal("0"),
claim_id: str | None = None,
payer_name: str = "Colorado Medicaid",
status_code: str = "1",
) -> None:
# The remittance's batch row carries ``raw_result_json`` with the
# payer name because ``iter_remittances`` reads payer name from
# there. We stage a fresh ``Batch`` row per call so unique
# ``payer_name`` values for tests like the per-payer filter case
# don't collide — the existing pattern from
# ``test_list_endpoint_counts.py:88``.
s.add(Batch(
id=batch_id,
kind="835",
input_filename=f"{remit_id}.edi",
parsed_at=received_at,
totals_json={"total_claims": 1},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"payer": {"name": payer_name}},
))
s.add(Remittance(
id=remit_id,
batch_id=batch_id,
payer_claim_control_number=remit_id,
claim_id=claim_id,
status_code=status_code,
total_charge=Decimal("100.00"),
total_paid=total_paid,
adjustment_amount=adjustment_amount,
received_at=received_at,
))
# --------------------------------------------------------------------------- #
# Module-level: summarize_remittances
# --------------------------------------------------------------------------- #
def test_summarize_remittances_zero_when_empty():
assert global_store.summarize_remittances() == {
"count": 0,
"total_paid": 0.0,
"total_adjustments": 0.0,
}
def test_summarize_remittances_unfiltered_returns_full_population():
"""Regression: with 5 remits seeded, the OLD ``items.reduce(...)``
page-local sum was per-page (25 rows); the new helper must
aggregate over all 5."""
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
with db.SessionLocal()() as s:
# 5 remits, paid=10/20/30/40/50 → sum=150, adj all 0.
for i, amt in enumerate([10, 20, 30, 40, 50]):
_seed_remit(
s, f"RMT-SUM-{i:04d}", f"b-sum-pop-{i}",
received_at=base + timedelta(minutes=i),
total_paid=Decimal(amt),
adjustment_amount=Decimal("0"),
)
s.commit()
assert global_store.summarize_remittances() == {
"count": 5,
"total_paid": 150.0,
"total_adjustments": 0.0,
}
def test_summarize_remittances_sums_adjustments_across_rows():
"""Adjustments accumulate independently from paid."""
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
with db.SessionLocal()() as s:
_seed_remit(
s, "RMT-A", "b-sum-adj-A",
received_at=base, total_paid=Decimal("25.96"),
adjustment_amount=Decimal("16.87"),
)
_seed_remit(
s, "RMT-B", "b-sum-adj-B",
received_at=base + timedelta(minutes=1),
total_paid=Decimal("0"), adjustment_amount=Decimal("50.19"),
)
_seed_remit(
s, "RMT-C", "b-sum-adj-C",
received_at=base + timedelta(minutes=2),
total_paid=Decimal("0"), adjustment_amount=Decimal("9.95"),
)
s.commit()
assert global_store.summarize_remittances() == {
"count": 3,
"total_paid": 25.96,
"total_adjustments": 77.01, # 16.87 + 50.19 + 9.95
}
def test_summarize_remittances_filters_by_claim_id():
"""claim_id filter narrows the summary the same way it narrows
iter_remittances (DB-side WHERE remittance.claim_id = :claim_id).
"""
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
with db.SessionLocal()() as s:
_seed_remit(
s, "RMT-X", "b-sum-cid-X",
received_at=base, claim_id="CLM-1",
total_paid=Decimal("10"), adjustment_amount=Decimal("0"),
)
_seed_remit(
s, "RMT-Y", "b-sum-cid-Y",
received_at=base, claim_id="CLM-2",
total_paid=Decimal("20"), adjustment_amount=Decimal("1"),
)
_seed_remit(
s, "RMT-Z", "b-sum-cid-Z",
received_at=base, claim_id=None,
total_paid=Decimal("30"), adjustment_amount=Decimal("2"),
)
s.commit()
assert global_store.summarize_remittances(claim_id="CLM-1") == {
"count": 1,
"total_paid": 10.0,
"total_adjustments": 0.0,
}
assert global_store.summarize_remittances(claim_id="CLM-2") == {
"count": 1,
"total_paid": 20.0,
"total_adjustments": 1.0,
}
assert global_store.summarize_remittances(claim_id="CLM-missing") == {
"count": 0,
"total_paid": 0.0,
"total_adjustments": 0.0,
}
def test_summarize_remittances_filters_by_payer_exact_match():
"""``payer`` filter is in-memory + case-sensitive exact match —
the helper must apply it the same way ``iter_remittances`` does
so summary and list views agree under the same chip."""
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
with db.SessionLocal()() as s:
_seed_remit(
s, "RMT-CO-1", "b-sum-payer-co-1",
received_at=base, payer_name="Colorado Medicaid",
total_paid=Decimal("7"), adjustment_amount=Decimal("0.50"),
)
_seed_remit(
s, "RMT-CO-2", "b-sum-payer-co-2",
received_at=base + timedelta(minutes=1),
payer_name="Colorado Medicaid",
total_paid=Decimal("13"), adjustment_amount=Decimal("0.25"),
)
_seed_remit(
s, "RMT-AZ-1", "b-sum-payer-az-1",
received_at=base + timedelta(minutes=2),
payer_name="Arizona Medicaid",
total_paid=Decimal("99"), adjustment_amount=Decimal("0"),
)
s.commit()
assert global_store.summarize_remittances(payer="Colorado Medicaid") == {
"count": 2,
"total_paid": 20.0,
"total_adjustments": 0.75,
}
assert global_store.summarize_remittances(payer="Arizona Medicaid") == {
"count": 1,
"total_paid": 99.0,
"total_adjustments": 0.0,
}
assert global_store.summarize_remittances(payer="colorado medicaid") == {
"count": 0,
"total_paid": 0.0,
"total_adjustments": 0.0,
}
# --------------------------------------------------------------------------- #
# HTTP: /api/remittances/summary
# --------------------------------------------------------------------------- #
def test_api_remittances_summary_empty(client: TestClient):
"""Empty DB → zero totals, well-formed response."""
resp = client.get("/api/remittances/summary")
assert resp.status_code == 200, resp.text
assert resp.json() == {
"count": 0,
"total_paid": 0,
"total_adjustments": 0,
}
def test_api_remittances_summary_full_population(client: TestClient):
"""Bug repro: seed 5 remits with known amounts, assert the
endpoint reports the FULL sum, not a 25-row sample.
"""
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
with db.SessionLocal()() as s:
for i, amt in enumerate([100, 200, 300, 400, 500]):
_seed_remit(
s, f"RMT-HTTP-SUM-{i}", f"b-http-sum-{i}",
received_at=base + timedelta(minutes=i),
total_paid=Decimal(amt),
adjustment_amount=Decimal("5"),
)
s.commit()
resp = client.get("/api/remittances/summary")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body == {
"count": 5,
"total_paid": 1500, # 100+200+300+400+500
"total_adjustments": 25, # 5*5
}
def test_api_remittances_summary_respects_claim_id_filter(client: TestClient):
"""Filter narrows both count and sums."""
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
with db.SessionLocal()() as s:
_seed_remit(
s, "RMT-F-1", "b-http-cid-1",
received_at=base, claim_id="CLM-F1",
total_paid=Decimal("11"), adjustment_amount=Decimal("1"),
)
_seed_remit(
s, "RMT-F-2", "b-http-cid-2",
received_at=base, claim_id="CLM-F2",
total_paid=Decimal("22"), adjustment_amount=Decimal("2"),
)
s.commit()
resp = client.get(
"/api/remittances/summary",
params={"claim_id": "CLM-F1"},
)
assert resp.status_code == 200
assert resp.json() == {
"count": 1,
"total_paid": 11,
"total_adjustments": 1,
}