Initial scaffold: single-month budget MVP #2
4 changed files with 194 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
63
tests/conftest.py
Normal file
63
tests/conftest.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from quartermaster.db import get_session
|
||||||
|
from quartermaster.main import create_app
|
||||||
|
from quartermaster.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine():
|
||||||
|
eng = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
future=True,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
@event.listens_for(eng, "connect")
|
||||||
|
def _fk_on(conn, _): # type: ignore[no-untyped-def]
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
Base.metadata.create_all(eng)
|
||||||
|
yield eng
|
||||||
|
eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_factory(engine):
|
||||||
|
return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(session_factory) -> Iterator[Session]:
|
||||||
|
session = session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(session_factory) -> Iterator[TestClient]:
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def override_get_session() -> Iterator[Session]:
|
||||||
|
session = session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = override_get_session
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
88
tests/test_routes.py
Normal file
88
tests/test_routes.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_renders_all_sections(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
for label in [
|
||||||
|
"Incomes",
|
||||||
|
"Fixed Amount Bills",
|
||||||
|
"Debt Minimums",
|
||||||
|
"Food and Essentials",
|
||||||
|
"Subscriptions",
|
||||||
|
"Other",
|
||||||
|
"Primary Debt Target",
|
||||||
|
]:
|
||||||
|
assert label in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_entry_updates_total(client):
|
||||||
|
response = client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "2500.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Paycheck" in response.text
|
||||||
|
assert "$2500.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_debt_minimum_also_returns_target_card(client):
|
||||||
|
response = client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card A", "amount": "40.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "section-debt_minimum" in response.text
|
||||||
|
assert "section-debt_target" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_entry(client):
|
||||||
|
create = client.post(
|
||||||
|
"/sections/other/entries", data={"name": "Gift", "amount": "25.00"}
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
response = client.delete("/entries/1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Gift" not in response.text
|
||||||
|
assert "$0.00" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_and_clear_debt_target(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card A", "amount": "40.00"},
|
||||||
|
)
|
||||||
|
set_resp = client.post("/debt-target", data={"debt_minimum_id": "1"})
|
||||||
|
assert set_resp.status_code == 200
|
||||||
|
assert "Card A" in set_resp.text
|
||||||
|
|
||||||
|
clear_resp = client.post("/debt-target", data={"debt_minimum_id": ""})
|
||||||
|
assert clear_resp.status_code == 200
|
||||||
|
assert "No target selected" in clear_resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_debt_target_clears_when_referenced_row_deleted(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card A", "amount": "40.00"},
|
||||||
|
)
|
||||||
|
client.post("/debt-target", data={"debt_minimum_id": "1"})
|
||||||
|
del_resp = client.delete("/entries/1")
|
||||||
|
assert del_resp.status_code == 200
|
||||||
|
assert "No target selected" in del_resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_negative_amount(client):
|
||||||
|
response = client.post(
|
||||||
|
"/sections/other/entries", data={"name": "Bad", "amount": "-5"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_non_debt_minimum_target(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "10.00"},
|
||||||
|
)
|
||||||
|
response = client.post("/debt-target", data={"debt_minimum_id": "1"})
|
||||||
|
assert response.status_code == 400
|
||||||
43
tests/test_service.py
Normal file
43
tests/test_service.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quartermaster import service
|
||||||
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_total(db):
|
||||||
|
service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00"))
|
||||||
|
service.add_entry(db, Section.income, "Side gig", Decimal("250.50"))
|
||||||
|
entries = service.list_entries(db, Section.income)
|
||||||
|
assert [e.name for e in entries] == ["Paycheck", "Side gig"]
|
||||||
|
assert service.section_total(entries) == Decimal("2750.50")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_entry(db):
|
||||||
|
entry = service.add_entry(db, Section.other, "One-off", Decimal("10.00"))
|
||||||
|
service.delete_entry(db, entry.id)
|
||||||
|
assert service.list_entries(db, Section.other) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_debt_target(db):
|
||||||
|
dm = service.add_entry(db, Section.debt_minimum, "Card A", Decimal("50.00"))
|
||||||
|
target = service.set_debt_target(db, dm.id)
|
||||||
|
assert target.debt_minimum_id == dm.id
|
||||||
|
assert target.entry is not None and target.entry.name == "Card A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_debt_target_rejects_non_debt_minimum(db):
|
||||||
|
income = service.add_entry(db, Section.income, "Paycheck", Decimal("1.00"))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
service.set_debt_target(db, income.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_debt_target_cleared_on_delete(db):
|
||||||
|
dm = service.add_entry(db, Section.debt_minimum, "Card B", Decimal("75.00"))
|
||||||
|
service.set_debt_target(db, dm.id)
|
||||||
|
service.delete_entry(db, dm.id)
|
||||||
|
target = service.get_debt_target(db)
|
||||||
|
assert target.debt_minimum_id is None
|
||||||
Loading…
Reference in a new issue