mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:08:47 +02:00
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:
202
backend/tests/test_cycle_math.py
Normal file
202
backend/tests/test_cycle_math.py
Normal 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)
|
||||
Reference in New Issue
Block a user