Ledgerly/tests/test_repository.py

189 lines
6 KiB
Python
Raw Normal View History

2026-10-27 16:04:50 +00:00
"""Tests for ledgerly.repository (schema, seeds, CRUD, reporting queries)."""
import sqlite3
import pytest
from ledgerly.errors import ConflictError
from ledgerly.models import DEFAULT_CATEGORIES, EXPENSE, INCOME
from ledgerly.repository import Repository, SqliteRepository
@pytest.fixture()
def repo():
r = SqliteRepository(":memory:")
yield r
r.close()
def test_implements_protocol():
r = SqliteRepository(":memory:")
assert isinstance(r, Repository)
r.close()
def test_seeds_default_categories(repo):
cats = repo.list_categories()
names = {name for name, _ in cats}
assert "Salary" in names
assert "Rent" in names
assert len(cats) == len(DEFAULT_CATEGORIES)
def test_seed_idempotent(tmp_path):
path = tmp_path / "sub" / "t.db"
first = SqliteRepository(path)
count = len(first.list_categories())
first.close()
second = SqliteRepository(path)
assert len(second.list_categories()) == count
second.close()
def test_deleted_categories_do_not_resurrect(tmp_path):
path = tmp_path / "t.db"
first = SqliteRepository(path)
for name, _kind in first.list_categories():
first.delete_category(first.find_category(name)[0])
first.close()
second = SqliteRepository(path)
assert second.list_categories() == []
second.close()
def test_create_category_duplicate_raises(repo):
with pytest.raises(ConflictError):
repo.create_category("Groceries", EXPENSE)
def test_find_category_case_insensitive(repo):
_id, name, kind = repo.find_category("grocErIes")
assert name == "Groceries"
assert kind == EXPENSE
def test_find_category_missing(repo):
assert repo.find_category("Nope") is None
def test_list_categories_income_first(repo):
cats = repo.list_categories()
kinds = [kind for _name, kind in cats]
assert kinds.index(INCOME) < kinds.index(EXPENSE)
def test_category_in_use_transitions(repo):
rent_id = repo.find_category("Rent")[0]
assert not repo.category_in_use(rent_id)
tx_id = repo.insert_transaction(100, rent_id, "2026-10-01", None)
assert repo.category_in_use(rent_id)
repo.delete_transaction(tx_id)
assert not repo.category_in_use(rent_id)
def test_category_in_use_via_budget(repo):
rent_id = repo.find_category("Rent")[0]
repo.set_budget(rent_id, 1000)
assert repo.category_in_use(rent_id)
def test_delete_category(repo):
cat_id = repo.find_category("Rent")[0]
repo.delete_category(cat_id)
assert repo.find_category("Rent") is None
def test_insert_and_list_transactions(repo):
cat_id = repo.find_category("Groceries")[0]
repo.insert_transaction(8210, cat_id, "2026-10-02", "weekly shop")
repo.insert_transaction(4500, cat_id, "2026-10-20", None)
rows = repo.list_transactions(None, None, None)
assert rows[0]["date"] == "2026-10-20" # newest first
assert rows[0]["category"] == "Groceries"
assert rows[0]["note"] is None
def test_list_transactions_filters(repo):
grocery_id = repo.find_category("Groceries")[0]
salary_id = repo.find_category("Salary")[0]
repo.insert_transaction(8210, grocery_id, "2026-10-02", None)
repo.insert_transaction(4500, salary_id, "2026-11-01", None)
assert len(repo.list_transactions("2026-10-01", "2026-10-31", None)) == 1
assert len(repo.list_transactions(None, None, grocery_id)) == 1
def test_transaction_exists_and_delete(repo):
cat_id = repo.find_category("Rent")[0]
tx_id = repo.insert_transaction(150000, cat_id, "2026-10-01", None)
assert repo.transaction_exists(tx_id)
repo.delete_transaction(tx_id)
assert not repo.transaction_exists(tx_id)
def test_insert_enforces_positive_amount(repo):
cat_id = repo.find_category("Rent")[0]
with pytest.raises(sqlite3.IntegrityError):
repo.insert_transaction(0, cat_id, "2026-10-01", None)
def test_insert_enforces_foreign_key(repo):
with pytest.raises(sqlite3.IntegrityError):
repo.insert_transaction(100, 99999, "2026-10-01", None)
def test_set_budget_upsert(repo):
cat_id = repo.find_category("Rent")[0]
repo.set_budget(cat_id, 100000)
assert repo.get_budget(cat_id) == 100000
repo.set_budget(cat_id, 120000)
assert repo.get_budget(cat_id) == 120000
assert len(repo.list_budgets()) == 1
def test_delete_budget(repo):
cat_id = repo.find_category("Rent")[0]
assert not repo.delete_budget(cat_id)
repo.set_budget(cat_id, 1000)
assert repo.delete_budget(cat_id)
assert repo.get_budget(cat_id) is None
def test_list_budgets_shape(repo):
repo.set_budget(repo.find_category("Rent")[0], 12345)
row = repo.list_budgets()[0]
assert row["category"] == "Rent"
assert row["kind"] == EXPENSE
assert row["amount_cents"] == 12345
def test_income_expense_totals_empty(repo):
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (0, 0)
def test_income_expense_totals(repo):
salary_id = repo.find_category("Salary")[0]
grocery_id = repo.find_category("Groceries")[0]
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
repo.insert_transaction(7777, grocery_id, "2026-09-05", None) # out of range
assert repo.income_expense_totals("2026-10-01", "2026-10-31") == (300000, 50000)
def test_category_totals_ordering(repo):
salary_id = repo.find_category("Salary")[0]
grocery_id = repo.find_category("Groceries")[0]
rent_id = repo.find_category("Rent")[0]
repo.insert_transaction(300000, salary_id, "2026-10-01", None)
repo.insert_transaction(50000, grocery_id, "2026-10-05", None)
repo.insert_transaction(99999, rent_id, "2026-10-03", None)
rows = repo.category_totals("2026-10-01", "2026-10-31")
assert [r["category"] for r in rows] == ["Salary", "Rent", "Groceries"]
def test_context_manager_closes(tmp_path):
path = tmp_path / "t.db"
with SqliteRepository(path) as repo:
assert repo is not None
with pytest.raises(sqlite3.ProgrammingError):
repo.list_categories()