mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:08:46 +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:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
48
backend/tests/conftest.py
Normal file
48
backend/tests/conftest.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Shared fixtures. Env vars are set before any app import so config.Settings()
|
||||
fail-fast validation passes with deterministic test values regardless of the
|
||||
developer's .env."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault(
|
||||
"SECRET_KEY", "test-secret-key-0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
import app.models.models # noqa: F401 — register all tables on the metadata
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine():
|
||||
eng = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(engine):
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixed_exchange_rates(monkeypatch):
|
||||
"""Never hit the network: CRC passthrough, USD pinned at 500."""
|
||||
from app.services import exchange_rate
|
||||
|
||||
monkeypatch.setattr(
|
||||
exchange_rate,
|
||||
"get_crc_multipliers",
|
||||
lambda session: {"CRC": 1.0, "USD": 500.0},
|
||||
)
|
||||
154
backend/tests/test_auth.py
Normal file
154
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""JWT lifecycle, API-token validation, constant-time creds, login rate limit."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from jose import jwt
|
||||
|
||||
from app import auth as auth_mod
|
||||
from app.auth import (
|
||||
ALGORITHM,
|
||||
LOGIN_RATE_LIMIT,
|
||||
_login_attempts,
|
||||
_validate_token,
|
||||
check_login_rate_limit,
|
||||
create_access_token,
|
||||
hash_token,
|
||||
verify_admin_credentials,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.models.models import APIToken
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
def make_request(ip: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", ip.encode())],
|
||||
"client": ("127.0.0.1", 1234),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestJWT:
|
||||
def test_roundtrip_and_seven_day_expiry(self):
|
||||
token = create_access_token("charlie")
|
||||
assert _validate_token(token) == "charlie"
|
||||
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
# exp is a true UTC epoch; a naive datetime's .timestamp() would be
|
||||
# interpreted as local time, so compare against an aware now.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lifetime = payload["exp"] - datetime.now(timezone.utc).timestamp()
|
||||
assert lifetime == pytest.approx(7 * 24 * 3600, abs=60)
|
||||
|
||||
def test_expired_token_rejected(self, engine, monkeypatch):
|
||||
# The API-token fallback path opens a DB session; point it at the
|
||||
# test engine so the lookup runs (and finds nothing).
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
expired = jwt.encode(
|
||||
{"sub": "charlie", "exp": utcnow() - timedelta(minutes=1)},
|
||||
settings.SECRET_KEY,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_token(expired)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_garbage_token_rejected(self, engine, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_token("not-a-jwt")
|
||||
|
||||
|
||||
class TestAPIToken:
|
||||
def test_active_api_token_accepted(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(name="n8n", token_hash=hash_token("tok-123"), is_active=True)
|
||||
)
|
||||
session.commit()
|
||||
assert _validate_token("tok-123") == "api:n8n"
|
||||
|
||||
def test_expired_api_token_rejected(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(
|
||||
name="old",
|
||||
token_hash=hash_token("tok-old"),
|
||||
is_active=True,
|
||||
expires_at=utcnow() - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_token("tok-old")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_revoked_api_token_rejected(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(
|
||||
name="revoked",
|
||||
token_hash=hash_token("tok-rev"),
|
||||
is_active=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_token("tok-rev")
|
||||
|
||||
|
||||
class TestCredentials:
|
||||
def test_correct_credentials_pass(self):
|
||||
verify_admin_credentials(
|
||||
settings.ADMIN_USERNAME, settings.ADMIN_PASSWORD
|
||||
) # no exception
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user,pw",
|
||||
[
|
||||
("wrong", "test-password"),
|
||||
("testadmin", "wrong"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_wrong_credentials_rejected(self, user, pw):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_admin_credentials(user, pw)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
class TestLoginRateLimit:
|
||||
def setup_method(self):
|
||||
_login_attempts.clear()
|
||||
|
||||
def test_limit_allows_then_blocks(self):
|
||||
req = make_request("203.0.113.1")
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(req)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_login_rate_limit(req)
|
||||
assert exc.value.status_code == 429
|
||||
assert "Retry-After" in exc.value.headers
|
||||
|
||||
def test_limit_is_per_ip(self):
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(make_request("203.0.113.2"))
|
||||
# A different IP is unaffected.
|
||||
check_login_rate_limit(make_request("203.0.113.3"))
|
||||
|
||||
def test_window_slides(self):
|
||||
req = make_request("203.0.113.4")
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(req)
|
||||
# Age every recorded attempt past the window; the next attempt passes.
|
||||
attempts = _login_attempts["203.0.113.4"]
|
||||
for i in range(len(attempts)):
|
||||
attempts[i] -= auth_mod.LOGIN_RATE_WINDOW_SECONDS + 1
|
||||
check_login_rate_limit(req) # no exception
|
||||
196
backend/tests/test_budget_projection.py
Normal file
196
backend/tests/test_budget_projection.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Projection engine: recurring-item schedules, no-double-count, cumulative
|
||||
balances with overrides and the 2026-03 fresh start. Also serves as the
|
||||
float-behavior baseline ahead of the Phase 2 Decimal migration (plan 1.5)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models.models import (
|
||||
BalanceOverride,
|
||||
Category,
|
||||
RecurringFrequency,
|
||||
RecurringItem,
|
||||
RecurringItemType,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.budget_projection import (
|
||||
compute_monthly_projection,
|
||||
compute_yearly_projection_with_cumulative,
|
||||
get_effective_amount,
|
||||
)
|
||||
|
||||
|
||||
def make_item(**kw) -> RecurringItem:
|
||||
defaults = dict(
|
||||
name="item",
|
||||
amount=1000.0,
|
||||
item_type=RecurringItemType.EXPENSE,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return RecurringItem(**defaults)
|
||||
|
||||
|
||||
class TestEffectiveAmount:
|
||||
def test_monthly_base_and_override(self):
|
||||
item = make_item(amount=100.0, override_amounts={"4": 250.0})
|
||||
assert get_effective_amount(item, 3, 2026) == 100.0
|
||||
assert get_effective_amount(item, 4, 2026) == 250.0
|
||||
|
||||
def test_weekly_counts_weekday_occurrences(self):
|
||||
# day_of_month stores weekday, 0=Monday. March 2026 has 5 Mondays
|
||||
# (2, 9, 16, 23, 30); April 2026 has 4 (6, 13, 20, 27).
|
||||
item = make_item(
|
||||
amount=100.0, frequency=RecurringFrequency.WEEKLY, day_of_month=0
|
||||
)
|
||||
assert get_effective_amount(item, 3, 2026) == 500.0
|
||||
assert get_effective_amount(item, 4, 2026) == 400.0
|
||||
|
||||
def test_quarterly_active_in_quarter_end_months_only(self):
|
||||
item = make_item(frequency=RecurringFrequency.QUARTERLY, amount=900.0)
|
||||
for month in (3, 6, 9, 12):
|
||||
assert get_effective_amount(item, month, 2026) == 900.0
|
||||
for month in (1, 2, 4, 5, 7, 8, 10, 11):
|
||||
assert get_effective_amount(item, month, 2026) is None
|
||||
|
||||
def test_biannual_base_and_six_months_later(self):
|
||||
item = make_item(
|
||||
frequency=RecurringFrequency.BIANNUAL, month_of_year=2, amount=70.0
|
||||
)
|
||||
assert get_effective_amount(item, 2, 2026) == 70.0
|
||||
assert get_effective_amount(item, 8, 2026) == 70.0
|
||||
assert get_effective_amount(item, 5, 2026) is None
|
||||
|
||||
late = make_item(
|
||||
frequency=RecurringFrequency.BIANNUAL, month_of_year=9, amount=70.0
|
||||
)
|
||||
assert get_effective_amount(late, 9, 2026) == 70.0
|
||||
assert get_effective_amount(late, 3, 2026) == 70.0
|
||||
|
||||
def test_yearly_defaults_to_december(self):
|
||||
item = make_item(frequency=RecurringFrequency.YEARLY, amount=50.0)
|
||||
assert get_effective_amount(item, 12, 2026) == 50.0
|
||||
assert get_effective_amount(item, 6, 2026) is None
|
||||
july = make_item(
|
||||
frequency=RecurringFrequency.YEARLY, month_of_year=7, amount=50.0
|
||||
)
|
||||
assert get_effective_amount(july, 7, 2026) == 50.0
|
||||
|
||||
|
||||
class TestMonthlyProjection:
|
||||
def _seed(self, session: Session):
|
||||
groceries = Category(name="Groceries")
|
||||
other = Category(name="Other")
|
||||
session.add(groceries)
|
||||
session.add(other)
|
||||
session.commit()
|
||||
session.refresh(groceries)
|
||||
session.refresh(other)
|
||||
|
||||
session.add(
|
||||
RecurringItem(
|
||||
name="Salary",
|
||||
amount=500_000.0,
|
||||
item_type=RecurringItemType.INCOME,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
RecurringItem(
|
||||
name="Groceries budget",
|
||||
amount=100_000.0,
|
||||
item_type=RecurringItemType.EXPENSE,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
category_id=groceries.id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return groceries, other
|
||||
|
||||
def _cc(self, date, amount, category_id=None):
|
||||
return Transaction(
|
||||
amount=amount,
|
||||
merchant="X",
|
||||
date=date,
|
||||
transaction_type=TransactionType.COMPRA,
|
||||
source=TransactionSource.CREDIT_CARD,
|
||||
category_id=category_id,
|
||||
)
|
||||
|
||||
def test_actuals_replace_projection_for_covered_category(self, session):
|
||||
groceries, _ = self._seed(session)
|
||||
# April budget ⇒ cycle [Mar 18, Apr 18)
|
||||
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
|
||||
session.commit()
|
||||
|
||||
data = compute_monthly_projection(session, 2026, 4)
|
||||
grocery_line = next(
|
||||
i for i in data["expense_items"] if i["name"] == "Groceries budget"
|
||||
)
|
||||
assert grocery_line["used_actual"] is True
|
||||
assert grocery_line["amount"] == pytest.approx(80_000.0)
|
||||
assert grocery_line["projected_amount"] == pytest.approx(100_000.0)
|
||||
assert data["projected_fixed_expenses"] == pytest.approx(80_000.0)
|
||||
|
||||
def test_projection_used_when_no_actuals(self, session):
|
||||
self._seed(session)
|
||||
data = compute_monthly_projection(session, 2026, 4)
|
||||
assert data["projected_fixed_expenses"] == pytest.approx(100_000.0)
|
||||
assert data["projected_income"] == pytest.approx(500_000.0)
|
||||
assert data["net_balance"] == pytest.approx(400_000.0)
|
||||
|
||||
def test_uncovered_and_uncategorized_actuals_add_up(self, session):
|
||||
groceries, other = self._seed(session)
|
||||
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
|
||||
session.add(self._cc(datetime(2026, 4, 2), 5_000.0, other.id))
|
||||
session.add(self._cc(datetime(2026, 4, 3), 2_000.0, None))
|
||||
session.commit()
|
||||
|
||||
data = compute_monthly_projection(session, 2026, 4)
|
||||
assert data["uncovered_actual"] == pytest.approx(7_000.0)
|
||||
assert data["gran_total_egresos"] == pytest.approx(87_000.0)
|
||||
assert data["net_balance"] == pytest.approx(500_000.0 - 87_000.0)
|
||||
|
||||
|
||||
class TestYearlyCumulative:
|
||||
def _income_only(self, session: Session, amount=100.0):
|
||||
session.add(
|
||||
RecurringItem(
|
||||
name="Income",
|
||||
amount=amount,
|
||||
item_type=RecurringItemType.INCOME,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_fresh_start_zeroes_months_before_march_2026(self, session):
|
||||
self._income_only(session)
|
||||
months = compute_yearly_projection_with_cumulative(session, 2026)
|
||||
assert months[0]["cumulative_balance"] == 0.0 # January
|
||||
assert months[1]["cumulative_balance"] == 0.0 # February
|
||||
assert months[2]["cumulative_balance"] == pytest.approx(100.0) # March
|
||||
assert months[11]["cumulative_balance"] == pytest.approx(1000.0) # Dec
|
||||
|
||||
def test_override_resets_cumulative_chain(self, session):
|
||||
self._income_only(session)
|
||||
session.add(BalanceOverride(year=2026, month=4, override_balance=500.0))
|
||||
session.commit()
|
||||
|
||||
months = compute_yearly_projection_with_cumulative(session, 2026)
|
||||
april, may = months[3], months[4]
|
||||
assert april["balance_overridden"] is True
|
||||
assert april["cumulative_balance"] == pytest.approx(500.0)
|
||||
assert may["carryover_balance"] == pytest.approx(500.0)
|
||||
assert may["cumulative_balance"] == pytest.approx(600.0)
|
||||
|
||||
def test_next_year_carries_december_forward(self, session):
|
||||
self._income_only(session)
|
||||
months_2027 = compute_yearly_projection_with_cumulative(session, 2027)
|
||||
# 2026 accrues 10 months (Mar–Dec) of 100 → Jan 2027 carries 1000.
|
||||
assert months_2027[0]["carryover_balance"] == pytest.approx(1000.0)
|
||||
assert months_2027[0]["cumulative_balance"] == pytest.approx(1100.0)
|
||||
44
backend/tests/test_config.py
Normal file
44
backend/tests/test_config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Fail-fast settings validation (SEC-01 / BE-15)."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
GOOD = {
|
||||
"SECRET_KEY": "f" * 64,
|
||||
"ADMIN_USERNAME": "charlie",
|
||||
"ADMIN_PASSWORD": "a-real-password",
|
||||
}
|
||||
|
||||
|
||||
def make(**overrides) -> Settings:
|
||||
return Settings(_env_file=None, **{**GOOD, **overrides})
|
||||
|
||||
|
||||
def test_valid_settings_boot():
|
||||
s = make()
|
||||
assert s.ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 * 7
|
||||
assert s.COOKIE_SECURE is True
|
||||
assert s.cors_origins_list[0] == "https://wealth.cescalante.dev"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"SECRET_KEY": "change-me-in-production"},
|
||||
{"SECRET_KEY": "short"},
|
||||
{"SECRET_KEY": ""},
|
||||
{"ADMIN_PASSWORD": "admin"},
|
||||
{"ADMIN_PASSWORD": ""},
|
||||
{"ADMIN_USERNAME": ""},
|
||||
],
|
||||
)
|
||||
def test_weak_or_missing_secrets_rejected(overrides):
|
||||
with pytest.raises(ValidationError):
|
||||
make(**overrides)
|
||||
|
||||
|
||||
def test_cors_origins_parse_and_strip():
|
||||
s = make(CORS_ORIGINS=" https://a.example , http://b.example ,")
|
||||
assert s.cors_origins_list == ["https://a.example", "http://b.example"]
|
||||
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)
|
||||
60
backend/tests/test_import_parse.py
Normal file
60
backend/tests/test_import_parse.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Characterization of the BAC paste-import line parser."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.api.v1.endpoints.import_transactions import (
|
||||
make_reference_hash,
|
||||
parse_bac_line,
|
||||
)
|
||||
from app.models.models import TransactionType
|
||||
|
||||
|
||||
class TestParseBacLine:
|
||||
def test_tab_separated_purchase(self):
|
||||
line = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
|
||||
parsed = parse_bac_line(line)
|
||||
assert parsed is not None
|
||||
assert parsed["date"] == datetime(2026, 4, 15)
|
||||
assert parsed["merchant"] == "WALMART"
|
||||
assert parsed["city"] == "SAN JOSE"
|
||||
assert parsed["amount"] == 25000.50
|
||||
assert parsed["currency"] == "CRC"
|
||||
assert parsed["transaction_type"] == TransactionType.COMPRA
|
||||
|
||||
def test_multispace_separated_usd(self):
|
||||
line = "01/05/2026 AMAZON\\SEATTLE\\US 12.99 USD"
|
||||
parsed = parse_bac_line(line)
|
||||
assert parsed is not None
|
||||
assert parsed["amount"] == 12.99
|
||||
assert parsed["currency"] == "USD"
|
||||
|
||||
def test_negative_amount_is_refund(self):
|
||||
parsed = parse_bac_line("15/04/2026\tSTORE\\X\\CR\t-5,000.00 CRC")
|
||||
assert parsed is not None
|
||||
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
|
||||
assert parsed["amount"] == 5000.0 # stored as absolute value
|
||||
|
||||
def test_pago_recibido_is_refund(self):
|
||||
parsed = parse_bac_line("15/04/2026\tPAGO RECIBIDO\\\\\t100.00 CRC")
|
||||
assert parsed is not None
|
||||
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
|
||||
|
||||
def test_unparseable_lines_return_none(self):
|
||||
assert parse_bac_line("") is None
|
||||
assert parse_bac_line("not a transaction") is None
|
||||
assert parse_bac_line("99/99/2026\tX\\Y\\Z\t1.00 CRC") is None
|
||||
assert parse_bac_line("15/04/2026\tX\\Y\\Z\t1.00 GBP") is None
|
||||
|
||||
|
||||
class TestReferenceHash:
|
||||
def test_deterministic_and_merchant_normalized(self):
|
||||
a = make_reference_hash(datetime(2026, 4, 15), " walmart ", 100.0, "CRC")
|
||||
b = make_reference_hash(datetime(2026, 4, 15), "WALMART", 100.0, "CRC")
|
||||
assert a == b
|
||||
assert len(a) == 16
|
||||
|
||||
def test_different_inputs_differ(self):
|
||||
base = make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "CRC")
|
||||
assert make_reference_hash(datetime(2026, 4, 16), "X", 100.0, "CRC") != base
|
||||
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.1, "CRC") != base
|
||||
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "USD") != base
|
||||
Reference in New Issue
Block a user