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"}