From 3a17dee4ef6d8905424d4e83215c3544d901c196 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:04:18 -0600 Subject: [PATCH] test: cover CRUD, debt target selection, and ON DELETE SET NULL Service-level and route-level coverage. Route tests share an in-memory SQLite engine across threads via StaticPool and override the get_session dependency. Refs #1 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/__init__.py | 0 tests/conftest.py | 63 +++++++++++++++++++++++++++++++ tests/test_routes.py | 88 +++++++++++++++++++++++++++++++++++++++++++ tests/test_service.py | 43 +++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_routes.py create mode 100644 tests/test_service.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2446418 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..60ba013 --- /dev/null +++ b/tests/test_routes.py @@ -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 diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..c03ede5 --- /dev/null +++ b/tests/test_service.py @@ -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