023612ce0a
- Updated timestamps and AST hashes for multiple files in the manifest.json. - Added new test file backend/tests/test_expected_income.py. - Added documentation files: перево́д_между_своими.md and 0002-fixes.md. - Introduced pytest.ini to configure pytest settings, including test paths and warning filters.
162 lines
5.7 KiB
Python
162 lines
5.7 KiB
Python
from datetime import date
|
|
|
|
import pytest
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.budgeting.models import PeriodClosure
|
|
from app.budgeting.service import (
|
|
close_period,
|
|
compute_period_state,
|
|
copy_previous_month,
|
|
create_category,
|
|
get_or_create_period,
|
|
reopen_period,
|
|
set_assignments,
|
|
)
|
|
from app.categorization import service as categorization
|
|
from app.errors import AppError
|
|
from app.identity.models import User
|
|
from app.ledger.models import Category
|
|
from app.ledger.service import create_account, create_expense
|
|
from app.spaces.service import create_space
|
|
|
|
|
|
def seed(db: Session):
|
|
user = User(email="budget@example.test", password_hash="unused-in-domain-tests")
|
|
db.add(user)
|
|
db.flush()
|
|
return user, create_space(db, user=user, name="Budget")
|
|
|
|
|
|
def test_uncategorized_expense_blocks_close(db: Session) -> None:
|
|
user, space = seed(db)
|
|
account = create_account(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
name="Cash",
|
|
kind="cash",
|
|
opened_on=date(2026, 3, 1),
|
|
opening_amount_kopecks=10_000,
|
|
)
|
|
create_expense(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
account_id=account.id,
|
|
amount_kopecks=1_000,
|
|
occurred_on=date(2026, 3, 2),
|
|
)
|
|
period = get_or_create_period(db, space_id=space.id, year=2026, month=3)
|
|
with pytest.raises(AppError) as exc:
|
|
close_period(db, space_id=space.id, user=user, period=period)
|
|
assert exc.value.code == "BUDGET_CLOSE_BLOCKED"
|
|
|
|
|
|
def test_default_mcc_categories_are_budgetable_and_editable_mapping(db: Session) -> None:
|
|
user, space = seed(db)
|
|
defaults = db.query(Category).filter_by(space_id=space.id).all()
|
|
by_key = {category.system_key: category for category in defaults}
|
|
assert len([key for key in by_key if key not in {None, "savings"}]) == 21
|
|
groceries = by_key["groceries"]
|
|
others = by_key["others"]
|
|
assert {5411, 5422}.issubset(
|
|
categorization.get_category_mcc_codes(db, space_id=space.id, category_id=groceries.id)
|
|
)
|
|
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
|
|
set_assignments(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
period=period,
|
|
items=[{"category_id": groceries.id, "planned_kopecks": 2_000}],
|
|
)
|
|
account = create_account(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
name="Cash",
|
|
kind="cash",
|
|
opened_on=date(2026, 5, 1),
|
|
)
|
|
create_expense(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
account_id=account.id,
|
|
amount_kopecks=500,
|
|
occurred_on=date(2026, 5, 2),
|
|
category_id=others.id,
|
|
)
|
|
groceries_match = categorization.category_for_mcc(db, space_id=space.id, code=5999)
|
|
assert groceries_match is not None
|
|
assert groceries_match.id == others.id
|
|
|
|
custom = create_category(db, space_id=space.id, user=user, kind="monthly", name="My shops")
|
|
codes = categorization.set_category_mcc_codes(
|
|
db, space_id=space.id, category_id=custom.id, codes=[5999, 5994]
|
|
)
|
|
assert codes == [5994, 5999]
|
|
custom_match = categorization.category_for_mcc(db, space_id=space.id, code=5999)
|
|
assert custom_match is not None
|
|
assert custom_match.id == custom.id
|
|
custom.archived = True
|
|
fallback_match = categorization.category_for_mcc(db, space_id=space.id, code=5999)
|
|
assert fallback_match is not None
|
|
assert fallback_match.id == others.id
|
|
custom.archived = False
|
|
with pytest.raises(AppError) as exc:
|
|
categorization.set_category_mcc_codes(
|
|
db, space_id=space.id, category_id=custom.id, codes=[5411]
|
|
)
|
|
assert exc.value.code == "CATEGORY_MCC_TAKEN"
|
|
|
|
|
|
def test_copy_previous_month_clamps_expected_day(db: Session) -> None:
|
|
from app.budgeting.service import add_expected_income
|
|
|
|
user, space = seed(db)
|
|
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Food")
|
|
january = get_or_create_period(db, space_id=space.id, year=2026, month=1)
|
|
february = get_or_create_period(db, space_id=space.id, year=2026, month=2)
|
|
set_assignments(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
period=january,
|
|
items=[{"category_id": category.id, "planned_kopecks": 30_000}],
|
|
)
|
|
add_expected_income(
|
|
db,
|
|
space_id=space.id,
|
|
user=user,
|
|
period=january,
|
|
expected_on=date(2026, 1, 31),
|
|
amount_kopecks=100_000,
|
|
source_name="Salary",
|
|
)
|
|
result = copy_previous_month(db, space_id=space.id, user=user, period=february)
|
|
assert result == {"assignments_copied": 1, "expected_incomes_copied": 1}
|
|
state = compute_period_state(db, space_id=space.id, period=february)
|
|
assert state["expected_incomes"][0]["expected_on"] == "2026-02-28"
|
|
|
|
|
|
def test_reopen_preserves_immutable_closure_history(db: Session) -> None:
|
|
user, space = seed(db)
|
|
period = get_or_create_period(db, space_id=space.id, year=2026, month=4)
|
|
close_period(db, space_id=space.id, user=user, period=period)
|
|
reopen_period(db, space_id=space.id, user=user, period=period)
|
|
close_period(db, space_id=space.id, user=user, period=period)
|
|
assert db.query(PeriodClosure).filter_by(period_id=period.id).count() == 2
|
|
|
|
|
|
def test_periods_must_close_in_calendar_order(db: Session) -> None:
|
|
user, space = seed(db)
|
|
august = get_or_create_period(db, space_id=space.id, year=2026, month=8)
|
|
september = get_or_create_period(db, space_id=space.id, year=2026, month=9)
|
|
with pytest.raises(AppError) as exc:
|
|
close_period(db, space_id=space.id, user=user, period=september)
|
|
assert exc.value.code == "BUDGET_CLOSE_OUT_OF_ORDER"
|
|
close_period(db, space_id=space.id, user=user, period=august)
|
|
close_period(db, space_id=space.id, user=user, period=september)
|