Initial scaffold: single-month budget MVP #2

Merged
claude-code merged 6 commits from feat/1-scaffold into main 2026-04-17 11:31:12 -06:00
4 changed files with 194 additions and 0 deletions
Showing only changes of commit 3a17dee4ef - Show all commits

0
tests/__init__.py Normal file
View file

63
tests/conftest.py Normal file
View 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
View 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
View 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