Add pytest harness and 55-test suite for core backend logic

Covers: billing-cycle characterization (the BE-06 convention, boundary
instants, year wraps, deferred transactions), projection engine
(recurring schedules, no-double-count, cumulative balances, overrides,
fresh start), auth (JWT roundtrip/expiry, API tokens, constant-time
creds, rate-limit window), fail-fast settings, and the BAC paste
parser. SQLite in-memory fixtures, exchange rates pinned — no network.
Also doubles as the float-behavior baseline for the Phase 2 Decimal
migration (plan 1.5). config.py moves to SettingsConfigDict to clear
the pydantic deprecation warning from test output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-09 19:46:54 -06:00
parent 996bd437a1
commit c725c1487d
9 changed files with 707 additions and 3 deletions

View File

@@ -0,0 +1,202 @@
"""Characterization tests for billing-cycle math (review finding BE-06).
VERDICT: BE-06 is a false positive. The system is internally consistent:
- `get_cycle_range(Y, M)` returns the cycle that STARTS on the 18th of M:
[M/18, (M+1)/18).
- Budget month M covers the credit-card cycle that ENDS on the 18th of M,
i.e. `get_previous_cycle(M)` then `get_cycle_range(M-1)` = [(M-1)/18, M/18) —
exactly what the comment in compute_actuals_by_source says.
- The frontend (Budget.tsx) follows the same convention: viewing month M it
queries cycle_month = M-1.
These tests pin that convention so it can never silently shift.
"""
from datetime import datetime
import pytest
from sqlmodel import Session
from app.models.models import (
Currency,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.budget_projection import (
compute_actuals_by_source,
get_cycle_range,
get_month_range,
get_previous_cycle,
)
def cc_tx(
date: datetime,
amount: float = 1000.0,
*,
tx_type: TransactionType = TransactionType.COMPRA,
source: TransactionSource = TransactionSource.CREDIT_CARD,
currency: Currency = Currency.CRC,
deferred: bool = False,
) -> Transaction:
return Transaction(
amount=amount,
currency=currency,
merchant="TEST",
date=date,
transaction_type=tx_type,
source=source,
deferred_to_next_cycle=deferred,
)
def cc_net(session: Session, year: int, month: int) -> float:
return compute_actuals_by_source(session, year, month)["CREDIT_CARD"]["net"]
class TestRangeHelpers:
def test_cycle_starts_on_the_18th_of_its_label_month(self):
assert get_cycle_range(2026, 3) == (
datetime(2026, 3, 18),
datetime(2026, 4, 18),
)
def test_december_cycle_wraps_into_next_year(self):
assert get_cycle_range(2026, 12) == (
datetime(2026, 12, 18),
datetime(2027, 1, 18),
)
def test_month_range_normal_and_december(self):
assert get_month_range(2026, 4) == (
datetime(2026, 4, 1),
datetime(2026, 5, 1),
)
assert get_month_range(2026, 12) == (
datetime(2026, 12, 1),
datetime(2027, 1, 1),
)
def test_previous_cycle_january_wraps_to_prior_december(self):
assert get_previous_cycle(2026, 1) == (2025, 12)
assert get_previous_cycle(2026, 7) == (2026, 6)
@pytest.mark.parametrize("month", [0, 13, -1, 99])
def test_invalid_month_rejected(self, month):
with pytest.raises(ValueError):
get_cycle_range(2026, month)
with pytest.raises(ValueError):
get_month_range(2026, month)
class TestBudgetMonthCycleConvention:
"""Budget month M ← credit-card cycle [(M-1)/18, M/18)."""
def test_april_budget_covers_march18_to_april17(self, session):
included = [
cc_tx(datetime(2026, 3, 18, 0, 0), 100.0), # first instant of cycle
cc_tx(datetime(2026, 4, 1, 12, 0), 200.0), # mid-cycle
cc_tx(datetime(2026, 4, 17, 23, 59), 400.0), # last day of cycle
]
excluded = [
cc_tx(datetime(2026, 3, 17, 23, 59), 7000.0), # previous cycle
cc_tx(datetime(2026, 4, 18, 0, 0), 9000.0), # next cycle
]
for tx in included + excluded:
session.add(tx)
session.commit()
assert cc_net(session, 2026, 4) == pytest.approx(700.0)
# The boundary transactions land in the adjacent budget months instead.
assert cc_net(session, 2026, 3) == pytest.approx(7000.0)
assert cc_net(session, 2026, 5) == pytest.approx(9000.0)
def test_january_budget_reaches_into_prior_year(self, session):
session.add(cc_tx(datetime(2025, 12, 20), 500.0))
session.commit()
assert cc_net(session, 2026, 1) == pytest.approx(500.0)
def test_cash_uses_calendar_month_not_cycle(self, session):
session.add(
cc_tx(
datetime(2026, 4, 17),
300.0,
source=TransactionSource.CASH,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)
assert actuals["CASH"]["net"] == pytest.approx(300.0)
# Not in March's calendar month, despite being in March's... no —
# Apr 17 belongs to April calendar month only.
assert compute_actuals_by_source(session, 2026, 3)["CASH"][
"net"
] == pytest.approx(0.0)
class TestDeferredTransactions:
def test_deferred_moves_to_next_budget_month(self, session):
# 2026-03-10 lies in cycle [Feb 18, Mar 18) → budget month 3.
# Deferred, it must count in budget month 4 instead.
session.add(cc_tx(datetime(2026, 3, 10), 1500.0, deferred=True))
session.commit()
assert cc_net(session, 2026, 3) == pytest.approx(0.0)
assert cc_net(session, 2026, 4) == pytest.approx(1500.0)
# And it does not leak into month 5.
assert cc_net(session, 2026, 5) == pytest.approx(0.0)
def test_deferred_devolucion_also_moves(self, session):
session.add(cc_tx(datetime(2026, 3, 10), 9000.0)) # normal, month 3
session.add(
cc_tx(
datetime(2026, 3, 10),
2000.0,
tx_type=TransactionType.DEVOLUCION,
deferred=True,
)
)
session.commit()
assert cc_net(session, 2026, 3) == pytest.approx(9000.0)
assert cc_net(session, 2026, 4) == pytest.approx(-2000.0)
class TestAmountSemantics:
def test_devolucion_subtracts_from_net(self, session):
session.add(cc_tx(datetime(2026, 4, 1), 5000.0))
session.add(
cc_tx(
datetime(2026, 4, 2),
1200.0,
tx_type=TransactionType.DEVOLUCION,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)["CREDIT_CARD"]
assert actuals["total_compra"] == pytest.approx(5000.0)
assert actuals["total_devolucion"] == pytest.approx(1200.0)
assert actuals["net"] == pytest.approx(3800.0)
assert actuals["count"] == 2
def test_usd_converted_at_multiplier(self, session):
# conftest pins USD→CRC at 500.
session.add(cc_tx(datetime(2026, 4, 1), 10.0, currency=Currency.USD))
session.commit()
assert cc_net(session, 2026, 4) == pytest.approx(5000.0)
def test_income_types_excluded_from_counts(self, session):
session.add(
cc_tx(
datetime(2026, 4, 1),
999999.0,
tx_type=TransactionType.SALARY,
source=TransactionSource.TRANSFER,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)["TRANSFER"]
assert actuals["count"] == 0
# SALARY is not COMPRA/DEVOLUCION so it contributes nothing to net.
assert actuals["net"] == pytest.approx(0.0)