This commit is contained in:
Joe Arndt 2026-05-06 18:25:17 -05:00
commit ac93e6074b
38 changed files with 7162 additions and 0 deletions

240
tests/test_db.py Normal file
View file

@ -0,0 +1,240 @@
from pathlib import Path
import pytest
from common_cents.db import Database
@pytest.fixture
def db(tmp_path: Path) -> Database:
return Database(tmp_path / "test.db")
def test_add_metadata_basic(db):
cat_id = db.add_metadata("category", "Food")
assert cat_id > 0
rows = db.get_metadata("category")
assert [r["name"] for r in rows] == ["Food"]
def test_add_metadata_duplicate(db):
db.add_metadata("category", "Food")
with pytest.raises(ValueError, match="already exists"):
db.add_metadata("category", "Food")
def test_add_metadata_duplicate_case_insensitive(db):
db.add_metadata("category", "Food")
with pytest.raises(ValueError, match="already exists"):
db.add_metadata("category", "food")
def test_add_metadata_invalid_kind(db):
with pytest.raises(ValueError, match="Invalid kind"):
db.add_metadata("bogus", "X")
def test_add_metadata_rejects_comma_in_name(db):
with pytest.raises(ValueError, match="cannot contain commas"):
db.add_metadata("tag", "has,comma")
def test_update_metadata_unknown_id(db):
with pytest.raises(ValueError, match="No metadata row"):
db.update_metadata(99999, "Anything")
def test_update_metadata_rejects_comma(db):
cat_id = db.add_metadata("category", "Food")
with pytest.raises(ValueError, match="cannot contain commas"):
db.update_metadata(cat_id, "Foo,Bar")
def test_delete_metadata_blocked_when_in_use(db):
db.add_spending("2026-01-01", 100, "Food", None, None, [])
cat = db.get_metadata("category")[0]
with pytest.raises(ValueError, match="Cannot delete"):
db.delete_metadata(cat["id"])
def test_delete_metadata_succeeds_when_unused(db):
cat_id = db.add_metadata("category", "Food")
db.delete_metadata(cat_id)
assert db.get_metadata("category") == []
def test_add_spending_creates_metadata_inline(db):
db.add_spending("2026-05-01", 1500, "Food:Restaurants", "Joe's", "lunch", [])
rows = db.get_spending()
assert len(rows) == 1
r = rows[0]
assert r["category"] == "Food:Restaurants"
assert r["merchant"] == "Joe's"
assert r["notes"] == "lunch"
assert r["cents"] == 1500
def test_add_spending_rejects_comma_in_category(db):
with pytest.raises(ValueError, match="cannot contain commas"):
db.add_spending("2026-05-01", 100, "a,b", None, None, [])
def test_update_spending(db):
db.add_spending("2026-05-01", 1500, "Food", None, None, [])
sid = db.get_spending()[0]["id"]
db.update_spending(sid, "2026-05-02", 2000, "Travel", "Delta", "flight", [])
r = db.get_spending_by_id(sid)
assert r["date"] == "2026-05-02"
assert r["cents"] == 2000
assert r["category_name"] == "Travel"
assert r["merchant_name"] == "Delta"
def test_delete_spending_cascades_links(db):
db.add_spending("2026-05-01", 1500, "Food", None, None, [])
sid = db.get_spending()[0]["id"]
db.delete_spending(sid)
assert db.get_spending() == []
def test_budget_set_get_clear(db):
cat_id = db.add_metadata("category", "Food")
assert db.get_budget(cat_id) is None
db.set_budget(cat_id, 50000)
assert db.get_budget(cat_id) == 50000
db.set_budget(cat_id, 60000) # upsert
assert db.get_budget(cat_id) == 60000
db.clear_budget(cat_id)
assert db.get_budget(cat_id) is None
def test_budget_rejects_non_positive(db):
cat_id = db.add_metadata("category", "Food")
with pytest.raises(ValueError, match="positive"):
db.set_budget(cat_id, 0)
with pytest.raises(ValueError, match="positive"):
db.set_budget(cat_id, -100)
def test_find_duplicate_indices_against_existing(db):
db.add_spending("2026-01-15", 1500, "Food", "TJ", None, [])
rows = [
{
"date": "2026-01-15",
"cents": 1500,
"category": "Food",
"merchant": "TJ",
"notes": None,
"tags": [],
},
{
"date": "2026-01-16",
"cents": 1500,
"category": "Food",
"merchant": "TJ",
"notes": None,
"tags": [],
},
]
assert db.find_duplicate_indices(rows) == {0}
def test_find_duplicate_indices_within_batch(db):
rows = [
{
"date": "2026-01-15",
"cents": 1500,
"category": "Food",
"merchant": None,
"notes": None,
"tags": [],
},
{
"date": "2026-01-15",
"cents": 1500,
"category": "Food",
"merchant": None,
"notes": None,
"tags": [],
},
]
assert db.find_duplicate_indices(rows) == {1}
def test_import_spending_skips_duplicates(db):
db.add_spending("2026-01-15", 1500, "Food", None, None, [])
inserted, skipped = db.import_spending(
[
{
"date": "2026-01-15",
"cents": 1500,
"category": "Food",
"merchant": None,
"notes": None,
"tags": [],
},
{
"date": "2026-01-16",
"cents": 1500,
"category": "Food",
"merchant": None,
"notes": None,
"tags": [],
},
]
)
assert (inserted, skipped) == (1, 1)
def test_get_spending_for_export_sorts_tags(db):
db.add_spending(
"2026-01-01",
100,
"Food",
None,
None,
[
db.get_or_create_metadata("tag", "zeta"),
db.get_or_create_metadata("tag", "alpha"),
],
)
rows = db.get_spending_for_export()
assert rows[0]["tags"] == ["alpha", "zeta"]
def test_get_yearly_totals(db):
db.add_spending("2026-01-15", 1000, "Food", None, None, [])
db.add_spending("2026-01-20", 500, "Food", None, None, [])
db.add_spending("2026-03-01", 2000, "Food", None, None, [])
totals = db.get_yearly_totals(2026)
assert totals == {1: 1500, 3: 2000}
def test_get_category_totals_for_month(db):
db.add_spending("2026-05-01", 1000, "Food", None, None, [])
db.add_spending("2026-05-15", 500, "Food:Restaurants", None, None, [])
db.add_spending("2026-04-01", 9999, "Food", None, None, [])
totals = db.get_category_totals_for_month("2026-05")
assert totals == {"Food": 1000, "Food:Restaurants": 500}
def test_get_spending_by_category_tag_filter(db):
work_id = db.add_metadata("tag", "work")
travel_id = db.add_metadata("tag", "travel")
db.add_spending("2026-05-01", 1000, "Food", None, None, [work_id])
db.add_spending("2026-05-02", 2000, "Food", None, None, [travel_id])
db.add_spending("2026-05-03", 500, "Travel", None, None, [travel_id])
# Substring match, single tag
rows = db.get_spending_by_category("2026-05-01", "2026-05-31", tag="work")
assert {(r["name"], r["total"]) for r in rows} == {("Food", 1000)}
# Tag + category filter combine (AND)
rows = db.get_spending_by_category(
"2026-05-01", "2026-05-31", category="Travel", tag="travel"
)
assert {(r["name"], r["total"]) for r in rows} == {("Travel", 500)}
# Empty tag means "no tag filter" (back-compat with default)
rows = db.get_spending_by_category("2026-05-01", "2026-05-31", tag="")
assert {r["name"] for r in rows} == {"Food", "Travel"}