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:
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -39,8 +39,7 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
|
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
class Config:
|
model_config = SettingsConfigDict(env_file=".env")
|
||||||
env_file = ".env"
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
1
backend/requirements-dev.txt
Normal file
1
backend/requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pytest
|
||||||
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