Files
aurum/backend/tests/test_transactions.py
ZProger 140f58d33f fix: restrict transaction splits to one parent category, show subcategory breakdown
Раньше сплит позволял смешивать вообще любые категории. Реальная задача —
разложить одну покупку по подкатегориям ОДНОЙ базовой категории (100 € на
продукты: сколько на сладости, сколько на уход). А раз Dashboard и рейтинг
категорий сворачивают подкатегории в родителя, эта разбивка нигде не была
видна.

- _build_splits() требует, чтобы все категории сплита были одной родительской
  категорией или её прямыми подкатегориями.
- category_rollup.py отдаёт для каждого родительского тотала список
  category-источников, которые в него скинулись (children) — пусто, если
  источник один.
- Dashboard и рейтинг категорий: строка с непустым children раскрывается по
  клику, показывая вклад каждой подкатегории.
- Форма транзакции: выбор категории при сплите — сначала базовая (верхний
  уровень), затем строки сплита выбирают саму базовую или её подкатегорию.
2026-08-30 19:06:29 +03:00

676 lines
27 KiB
Python

"""Transactions: creation rules, filtering, pagination, sorting, and the
/transactions/years picker endpoint.
These assert the actual contract the frontend relies on — e.g. the year/month
filter test below is the backend half of the "Recent Transactions showed last
month's data too" regression from Aug 2026 (see UPDATES.md): the bug was in
the frontend not passing year/month at all, but if the backend ever stopped
honoring those filters correctly, the frontend fix would silently stop
working and nothing would catch it except this test.
"""
from datetime import date
from decimal import Decimal
from httpx import AsyncClient
from tests.helpers import money, txn_payload as _txn
async def test_bulk_create_transactions(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
resp = await client.post(
"/transactions/bulk",
json={
"items": [
_txn(account_id, category_id=groceries, description="csv row 1", date="2026-01-01"),
_txn(account_id, category_id=groceries, description="csv row 2", date="2026-01-02"),
]
},
)
assert resp.status_code == 201
assert resp.json()["created"] == 2
listed = await client.get("/transactions", params={"year": 2026, "month": 1})
assert listed.json()["total"] == 2
async def test_bulk_create_is_all_or_nothing(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
salary = categories["Salary"]["id"]
resp = await client.post(
"/transactions/bulk",
json={
"items": [
_txn(account_id, category_id=groceries, description="good row"),
# An expense using an income category — rejected, and it
# should take the whole batch down with it.
_txn(account_id, category_id=salary, description="bad row"),
]
},
)
assert resp.status_code == 400
listed = await client.get("/transactions")
assert listed.json()["total"] == 0
async def test_create_expense_rejects_income_category(client: AsyncClient, account_id, categories):
income_category = categories["Salary"]
resp = await client.post(
"/transactions", json=_txn(account_id, type="expense", category_id=income_category["id"])
)
assert resp.status_code == 400
async def test_create_capitalizes_description_first_letter(client: AsyncClient, account_id, categories):
category = categories["Groceries"]
resp = await client.post(
"/transactions",
json=_txn(account_id, category_id=category["id"], description="траты на продукты"),
)
assert resp.status_code == 201
assert resp.json()["description"] == "Траты на продукты"
async def test_update_capitalizes_description_first_letter(client: AsyncClient, account_id, categories):
category = categories["Groceries"]
created = await client.post("/transactions", json=_txn(account_id, category_id=category["id"]))
txn_id = created.json()["id"]
resp = await client.patch(f"/transactions/{txn_id}", json={"description": "new coffee shop"})
assert resp.status_code == 200
assert resp.json()["description"] == "New coffee shop"
async def test_create_income_rejects_expense_category(client: AsyncClient, account_id, categories):
expense_category = categories["Groceries"]
resp = await client.post(
"/transactions", json=_txn(account_id, type="income", amount="500.00", category_id=expense_category["id"])
)
assert resp.status_code == 400
async def test_create_expense_with_matching_category_succeeds(client: AsyncClient, account_id, categories):
category = categories["Groceries"]
resp = await client.post("/transactions", json=_txn(account_id, category_id=category["id"]))
assert resp.status_code == 201
body = resp.json()
assert body["category"]["id"] == category["id"]
assert money(body["amount"]) == Decimal("10.00")
async def test_transfer_requires_destination_account(client: AsyncClient, account_id):
resp = await client.post("/transactions", json=_txn(account_id, type="transfer", amount="50.00"))
assert resp.status_code == 422
async def test_transfer_rejects_same_source_and_destination(client: AsyncClient, account_id):
resp = await client.post(
"/transactions",
json=_txn(account_id, type="transfer", amount="50.00", transfer_account_id=account_id),
)
assert resp.status_code == 422
async def test_transfer_rejects_a_category(client: AsyncClient, account_id, categories):
other = await client.post("/accounts", json={"name": "Savings", "type": "savings", "currency": "USD"})
other_id = other.json()["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
type="transfer",
amount="50.00",
transfer_account_id=other_id,
category_id=categories["Groceries"]["id"],
),
)
assert resp.status_code == 422
async def test_valid_transfer_between_two_accounts_succeeds(client: AsyncClient, account_id):
other = await client.post("/accounts", json={"name": "Savings", "type": "savings", "currency": "USD"})
other_id = other.json()["id"]
resp = await client.post(
"/transactions", json=_txn(account_id, type="transfer", amount="50.00", transfer_account_id=other_id)
)
assert resp.status_code == 201
async def test_list_filters_by_year_and_month_excludes_other_months(client: AsyncClient, account_id, categories):
"""The exact shape of the Aug 2026 Dashboard bug: three transactions in
three different months must never leak into a query scoped to just one
of them."""
category_id = categories["Groceries"]["id"]
for txn_date, description in [
("2021-07-26", "july transaction"),
("2021-08-08", "august transaction"),
("2026-08-16", "current year august"),
]:
resp = await client.post(
"/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description)
)
assert resp.status_code == 201
resp = await client.get("/transactions", params={"year": 2021, "month": 8})
body = resp.json()
assert body["total"] == 1
assert [item["description"] for item in body["items"]] == ["August transaction"]
async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for txn_date in ["2024-01-01", "2024-06-15", "2024-12-31"]:
await client.post("/transactions", json=_txn(account_id, category_id=category_id, date=txn_date))
resp = await client.get("/transactions", params={"start_date": "2024-02-01", "end_date": "2024-11-01"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["date"] == "2024-06-15"
async def test_search_matches_description_across_any_period_ignoring_month_filter(
client: AsyncClient, account_id, categories
):
"""The point of search is finding a purchase from an unknown month without
already knowing which month to filter by, so it must not be scoped by the
year/month params even when the caller happens to also pass them."""
category_id = categories["Groceries"]["id"]
for txn_date, description in [
("2021-03-12", "PlayStation 5 console"),
("2024-11-02", "grocery run"),
("2026-08-16", "current month coffee"),
]:
resp = await client.post(
"/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description)
)
assert resp.status_code == 201
resp = await client.get("/transactions", params={"search": "playstation"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["description"] == "PlayStation 5 console"
async def test_search_matches_merchant_and_notes(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
await client.post(
"/transactions",
json=_txn(account_id, category_id=category_id, description="misc", merchant="Best Buy"),
)
await client.post(
"/transactions",
json=_txn(account_id, category_id=category_id, description="misc", notes="bought for a birthday gift"),
)
merchant_resp = await client.get("/transactions", params={"search": "best buy"})
assert merchant_resp.json()["total"] == 1
notes_resp = await client.get("/transactions", params={"search": "birthday"})
assert notes_resp.json()["total"] == 1
async def test_search_with_no_matches_returns_empty(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
await client.post("/transactions", json=_txn(account_id, category_id=category_id, description="groceries"))
resp = await client.get("/transactions", params={"search": "nonexistent item xyz"})
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for day in range(1, 26):
await client.post(
"/transactions",
json=_txn(account_id, category_id=category_id, date=f"2025-03-{day:02d}", description=f"txn-{day}"),
)
seen_ids: set[int] = set()
page = 1
total = None
while True:
resp = await client.get("/transactions", params={"page": page, "page_size": 10, "sort": "date_desc"})
body = resp.json()
total = body["total"]
if not body["items"]:
break
for item in body["items"]:
assert item["id"] not in seen_ids, "pagination must not repeat an item across pages"
seen_ids.add(item["id"])
page += 1
assert total == 25
assert len(seen_ids) == 25
async def test_sort_amount_desc_and_asc_are_opposite_orders(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for amount in ["5.00", "50.00", "20.00"]:
await client.post("/transactions", json=_txn(account_id, category_id=category_id, amount=amount))
desc = await client.get("/transactions", params={"sort": "amount_desc"})
asc = await client.get("/transactions", params={"sort": "amount_asc"})
desc_amounts = [money(item["amount"]) for item in desc.json()["items"]]
asc_amounts = [money(item["amount"]) for item in asc.json()["items"]]
assert desc_amounts == [Decimal("50.00"), Decimal("20.00"), Decimal("5.00")]
assert asc_amounts == list(reversed(desc_amounts))
async def test_update_transaction_persists_changes(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
created = await client.post("/transactions", json=_txn(account_id, category_id=category_id))
txn_id = created.json()["id"]
resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "99.99", "description": "updated"})
assert resp.status_code == 200
assert money(resp.json()["amount"]) == Decimal("99.99")
assert resp.json()["description"] == "Updated"
refetched = await client.get("/transactions", params={"page_size": 1})
assert money(refetched.json()["items"][0]["amount"]) == Decimal("99.99")
async def test_update_transaction_rejects_category_kind_mismatch(client: AsyncClient, account_id, categories):
created = await client.post(
"/transactions", json=_txn(account_id, type="expense", category_id=categories["Groceries"]["id"])
)
txn_id = created.json()["id"]
resp = await client.patch(f"/transactions/{txn_id}", json={"category_id": categories["Salary"]["id"]})
assert resp.status_code == 400
async def test_delete_transaction_removes_it_from_listing(client: AsyncClient, account_id, categories):
created = await client.post(
"/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"])
)
txn_id = created.json()["id"]
delete_resp = await client.delete(f"/transactions/{txn_id}")
assert delete_resp.status_code == 204
listing = await client.get("/transactions")
assert txn_id not in [item["id"] for item in listing.json()["items"]]
async def test_years_endpoint_spans_earliest_transaction_through_current_year(
client: AsyncClient, account_id, categories
):
category_id = categories["Groceries"]["id"]
await client.post("/transactions", json=_txn(account_id, category_id=category_id, date="2021-03-01"))
await client.post("/transactions", json=_txn(account_id, category_id=category_id, date="2023-11-20"))
resp = await client.get("/transactions/years")
current_year = date.today().year
assert resp.json() == list(range(2021, current_year + 1))
async def test_years_endpoint_with_no_transactions_returns_only_current_year(client: AsyncClient):
resp = await client.get("/transactions/years")
assert resp.json() == [date.today().year]
async def test_update_cannot_turn_a_transaction_into_a_transfer_without_a_destination(
client: AsyncClient, account_id, categories
):
"""The create path rejects a transfer with no transfer_account_id; the
update path has to reject it too, or the row ends up moving money out of
an account and into nowhere — account_service subtracts the amount from
account_id but only credits transfer_account_id when it isn't NULL, so
the balance (and Net Worth's cash line, which walks the same rows) just
loses it."""
created = await client.post(
"/transactions", json=_txn(account_id, amount="40.00", category_id=categories["Groceries"]["id"])
)
txn_id = created.json()["id"]
balance_before = money((await client.get("/accounts")).json()[0]["balance"])
resp = await client.patch(f"/transactions/{txn_id}", json={"type": "transfer", "category_id": None})
assert resp.status_code == 400
assert money((await client.get("/accounts")).json()[0]["balance"]) == balance_before
async def test_update_rejects_a_transfer_pointing_at_its_own_account(client: AsyncClient, account_id):
"""Same rule as on create: a transfer to itself is a no-op row that still
shows up in the ledger as a transfer."""
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
created = await client.post(
"/transactions", json=_txn(account_id, type="transfer", transfer_account_id=other["id"], category_id=None)
)
txn_id = created.json()["id"]
resp = await client.patch(f"/transactions/{txn_id}", json={"transfer_account_id": account_id})
assert resp.status_code == 400
async def test_update_rejects_a_transfer_destination_on_a_non_transfer(
client: AsyncClient, account_id, categories
):
"""transfer_account_id on an expense is meaningless — balances only read
it for TRANSFER rows — and create already refuses it."""
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
created = await client.post(
"/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"])
)
resp = await client.patch(
f"/transactions/{created.json()['id']}", json={"transfer_account_id": other["id"]}
)
assert resp.status_code == 400
async def test_update_rejects_a_category_on_a_transfer(client: AsyncClient, account_id, categories):
"""A categorized transfer would be counted as spending by the reports that
join on category — create rejects it, update must as well."""
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
created = await client.post(
"/transactions", json=_txn(account_id, type="transfer", transfer_account_id=other["id"], category_id=None)
)
resp = await client.patch(
f"/transactions/{created.json()['id']}", json={"category_id": categories["Groceries"]["id"]}
)
assert resp.status_code == 400
async def test_a_transfer_whose_destination_account_was_deleted_can_still_be_listed(
client: AsyncClient, account_id
):
"""Deleting an account nulls transfer_account_id on the transfers that
pointed at it (the FK is ON DELETE SET NULL), leaving a row that create
would reject. Refusing to *serialize* such a row takes the whole
transactions list down with a 500 — and with the list gone there is no
way left in the UI to delete the row and recover. Reading has to stay
possible; only writing is guarded."""
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
created = await client.post(
"/transactions", json=_txn(account_id, type="transfer", transfer_account_id=other["id"], category_id=None)
)
txn_id = created.json()["id"]
assert (await client.delete(f"/accounts/{other['id']}")).status_code == 204
listing = await client.get("/transactions")
assert listing.status_code == 200
assert txn_id in [item["id"] for item in listing.json()["items"]]
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
async def _subcategory(client: AsyncClient, parent_id: int, name: str) -> int:
resp = await client.post(
"/categories", json={"name": name, "kind": "expense", "color": "#7a869a", "parent_id": parent_id}
)
return resp.json()["id"]
async def test_create_split_transaction_across_categories(client: AsyncClient, account_id, categories):
"""The hypermarket-receipt case: one purchase, part everyday groceries,
part sweets — a split's categories must share one parent (see
_build_splits), so both lines here are Groceries itself and one of its
own subcategories. category_id stays empty on the row itself, and its
splits carry the categorization instead."""
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00", "note": "candy and snacks"},
],
),
)
assert resp.status_code == 201
body = resp.json()
assert body["category"] is None
splits = {s["category"]["name"]: s for s in body["splits"]}
assert money(splits["Groceries"]["amount"]) == Decimal("70.00")
assert money(splits["Sweets"]["amount"]) == Decimal("30.00")
assert splits["Sweets"]["note"] == "candy and snacks"
async def test_create_split_rejects_categories_from_different_parents(client: AsyncClient, account_id, categories):
"""The whole point of a split is dividing one purchase across the
subcategories of ONE parent — Groceries and Shopping are two unrelated
top-level categories, so mixing them must be rejected even though both
are individually valid expense categories."""
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
],
),
)
assert resp.status_code == 400
async def test_create_split_rejects_two_different_parents_subcategories(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
electronics = await _subcategory(client, shopping, "Electronics")
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": sweets, "amount": "70.00"},
{"category_id": electronics, "amount": "30.00"},
],
),
)
assert resp.status_code == 400
async def test_create_split_rejects_category_id_alongside_splits(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=groceries,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
assert resp.status_code == 422
async def test_create_split_rejects_a_single_split(client: AsyncClient, account_id, categories):
resp = await client.post(
"/transactions",
json=_txn(
account_id, amount="100.00", category_id=None, splits=[{"category_id": categories["Groceries"]["id"], "amount": "100.00"}]
),
)
assert resp.status_code == 422
async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
resp = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "20.00"},
],
),
)
assert resp.status_code == 422
async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, categories):
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
resp = await client.post(
"/transactions",
json=_txn(
account_id,
type="transfer",
amount="100.00",
category_id=None,
transfer_account_id=other["id"],
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
assert resp.status_code == 422
async def test_update_can_convert_a_transaction_into_a_split_and_back(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
created = await client.post("/transactions", json=_txn(account_id, amount="100.00", category_id=groceries))
txn_id = created.json()["id"]
split_resp = await client.patch(
f"/transactions/{txn_id}",
json={
"category_id": None,
"splits": [
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
},
)
assert split_resp.status_code == 200
assert len(split_resp.json()["splits"]) == 2
assert split_resp.json()["category"] is None
back_resp = await client.patch(f"/transactions/{txn_id}", json={"category_id": groceries, "splits": []})
assert back_resp.status_code == 200
assert back_resp.json()["splits"] == []
assert back_resp.json()["category"]["id"] == groceries
async def test_update_rejects_splits_not_summing_to_the_new_amount(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
txn_id = created.json()["id"]
# Splits weren't touched by this patch, but the new amount no longer
# matches their sum — the update must still be rejected.
resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "50.00"})
assert resp.status_code == 400
async def test_update_of_unrelated_field_tolerates_a_split_whose_category_was_deleted(
client: AsyncClient, account_id, categories
):
"""Same lesson as the transfer_account_id/deleted-account case: deleting
a category SET NULLs any split that pointed at it, and an edit to some
other field on the transaction must not be blocked by that pre-existing
state — the split's own category was never re-submitted, so nothing here
needs it to still exist."""
groceries = categories["Groceries"]["id"]
deletable = await _subcategory(client, groceries, "Deletable")
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": deletable, "amount": "30.00"},
],
),
)
txn_id = created.json()["id"]
assert (await client.delete(f"/categories/{deletable}")).status_code == 204
resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"})
assert resp.status_code == 200
assert resp.json()["description"] == "Renamed"
assert resp.json()["splits"][1]["category"] is None
async def test_category_filter_matches_transactions_split_into_that_category(
client: AsyncClient, account_id, categories
):
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
resp = await client.get("/transactions", params={"category_id": sweets})
assert resp.json()["total"] == 1
async def test_delete_transaction_cascades_its_splits(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
sweets = await _subcategory(client, groceries, "Sweets")
created = await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
txn_id = created.json()["id"]
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
# If the split rows outlived their parent, this budget/dashboard-style
# category filter would still find a (now-orphaned) match.
resp = await client.get("/transactions", params={"category_id": sweets})
assert resp.json()["total"] == 0