189 lines
6 KiB
Python
189 lines
6 KiB
Python
|
|
"""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()
|