Files
finance/backend/tests/test_expected_income.py
T
k1nq 023612ce0a Update manifest.json with new file metadata and add pytest configuration
- 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.
2026-09-24 08:34:13 +05:00

347 lines
11 KiB
Python

from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.budgeting.service import (
add_expected_income,
close_period,
delete_expected_income,
expected_income_items,
get_or_create_period,
match_expected_income,
unmatch_expected_income,
update_expected_income,
)
from app.errors import AppError
from app.identity.models import User
from app.ledger.service import create_account, create_income
from app.spaces.service import create_space
def seed(db: Session):
user = User(email="expected@example.test", password_hash="unused-in-domain-tests")
db.add(user)
db.flush()
return user, create_space(db, user=user, name="Expected")
def test_expected_income_crud_and_validation(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=9)
with pytest.raises(AppError) as exc:
add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 10, 1),
amount_kopecks=1_000,
source_name=None,
)
assert exc.value.code == "BUDGET_EXPECTED_INCOME_DATE_OUTSIDE"
income = add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 9, 25),
amount_kopecks=120_000,
source_name="Salary",
note="net",
)
assert expected_income_items(db, space_id=space.id, period=period) == [
{
"id": str(income.id),
"expected_on": "2026-09-25",
"amount_kopecks": 120_000,
"source_name": "Salary",
"note": "net",
"matched_kopecks": 0,
}
]
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"amount_kopecks": 130_000, "note": None, "expected_on": date(2026, 9, 30)},
)
refreshed = expected_income_items(db, space_id=space.id, period=period)[0]
assert refreshed["amount_kopecks"] == 130_000
assert refreshed["expected_on"] == "2026-09-30"
assert refreshed["note"] is None
with pytest.raises(AppError) as exc:
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"expected_on": date(2026, 8, 31)},
)
assert exc.value.code == "BUDGET_EXPECTED_INCOME_DATE_OUTSIDE"
with pytest.raises(AppError) as exc:
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"amount_kopecks": 0},
)
assert exc.value.code == "MONEY_AMOUNT_MUST_BE_POSITIVE"
delete_expected_income(db, space_id=space.id, user=user, period=period, income_id=income.id)
assert expected_income_items(db, space_id=space.id, period=period) == []
def test_expected_income_match_unmatch_and_delete_cascade(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=9)
account = create_account(
db,
space_id=space.id,
user=user,
name="Wallet",
kind="cash",
opened_on=date(2026, 9, 1),
)
income = add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 9, 25),
amount_kopecks=100_000,
source_name="Salary",
)
operation = create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=40_000,
occurred_on=date(2026, 9, 20),
)
with pytest.raises(AppError) as exc:
match_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
amount_kopecks=50_000,
)
assert exc.value.code == "BUDGET_MATCH_AMOUNT_INVALID"
match_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert (
expected_income_items(db, space_id=space.id, period=period)[0]["matched_kopecks"] == 40_000
)
other_period = get_or_create_period(db, space_id=space.id, year=2026, month=10)
with pytest.raises(AppError) as exc:
match_expected_income(
db,
space_id=space.id,
user=user,
period=other_period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert exc.value.code == "BUDGET_EXPECTED_INCOME_NOT_FOUND"
unmatch_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert expected_income_items(db, space_id=space.id, period=period)[0]["matched_kopecks"] == 0
with pytest.raises(AppError) as exc:
unmatch_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert exc.value.code == "BUDGET_MATCH_NOT_FOUND"
match_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
delete_expected_income(db, space_id=space.id, user=user, period=period, income_id=income.id)
from app.budgeting.models import ExpectedIncomeMatch
assert db.query(ExpectedIncomeMatch).filter_by(space_id=space.id).count() == 0
def test_expected_income_mutations_guard_closed_period(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=9)
income = add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 9, 5),
amount_kopecks=5_000,
source_name=None,
)
close_period(db, space_id=space.id, user=user, period=period)
with pytest.raises(AppError) as exc:
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"amount_kopecks": 1},
)
assert exc.value.code == "BUDGET_PERIOD_CLOSED"
with pytest.raises(AppError) as exc:
delete_expected_income(db, space_id=space.id, user=user, period=period, income_id=income.id)
assert exc.value.code == "BUDGET_PERIOD_CLOSED"
def _login(client: TestClient) -> dict[str, str]:
invite = client.post(
"/api/v1/admin/invitations",
headers={"X-Admin-Token": "test-admin-token"},
json={"email": "Expected@Example.com"},
)
assert invite.status_code == 201, invite.text
accepted = client.post(
"/api/v1/auth/invitations/accept",
json={
"token": invite.json()["token"],
"password": "a-long-password-for-tests",
"fullName": "Test Person",
"locale": "en",
},
)
assert accepted.status_code == 201, accepted.text
return {"X-CSRF-Token": client.cookies["csrf_token"]}
def test_expected_income_month_api_crud_and_summary(client: TestClient) -> None:
headers = _login(client)
account = client.post(
"/api/v1/accounts",
headers=headers,
json={
"name": "Wallet",
"type": "cash",
"openedOn": "2026-09-01",
"openingBalanceKopecks": 0,
},
)
assert account.status_code == 201, account.text
account_id = account.json()["id"]
created = client.post(
"/api/v1/budget/2026/9/expected-incomes",
headers=headers,
json={"expectedOn": "2026-09-25", "amountKopecks": 120_000, "sourceName": "Salary"},
)
assert created.status_code == 201, created.text
income = created.json()
assert income["status"] == "expected"
assert income["amountKopecks"] == 120_000
legacy = client.post(
"/api/v1/budget/2026/9/expected-incomes",
headers=headers,
json={"expected_on": "2026-09-26", "amount_kopecks": 1_000, "source_name": "Gift"},
)
assert legacy.status_code == 201, legacy.text
assert legacy.json()["amountKopecks"] == 1_000
legacy_removed = client.delete(
f"/api/v1/budget/2026/9/expected-incomes/{legacy.json()['id']}", headers=headers
)
assert legacy_removed.status_code == 204, legacy_removed.text
listed = client.get("/api/v1/budget/2026/9/expected-incomes")
assert listed.status_code == 200, listed.text
assert [item["id"] for item in listed.json()] == [income["id"]]
patched = client.patch(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}",
headers=headers,
json={"amountKopecks": 130_000, "note": None},
)
assert patched.status_code == 200, patched.text
assert patched.json()["amountKopecks"] == 130_000
assert patched.json()["note"] is None
rejected = client.patch(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}",
headers=headers,
json={"expectedOn": "2026-10-01"},
)
assert rejected.status_code == 400, rejected.text
assert rejected.json()["error"]["code"] == "BUDGET_EXPECTED_INCOME_DATE_OUTSIDE"
operation = client.post(
"/api/v1/operations",
headers=headers,
json={
"kind": "income",
"amountKopecks": 80_000,
"accountId": account_id,
"occurredOn": "2026-09-20",
},
)
assert operation.status_code == 201, operation.text
operation_id = operation.json()["id"]
matched = client.post(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}/match",
headers=headers,
json={"operationId": operation_id},
)
assert matched.status_code == 201, matched.text
assert matched.json()["matchedKopecks"] == 80_000
assert matched.json()["status"] == "partially_matched"
summary = client.get("/api/v1/budget/summary", params={"month": "2026-09"})
assert summary.status_code == 200, summary.text
body = summary.json()
assert body["expectedIncomes"][0]["status"] == "partially_matched"
assert body["projectedPoolKopecks"] == 130_000
unmatched = client.delete(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}/match/{operation_id}",
headers=headers,
)
assert unmatched.status_code == 204, unmatched.text
deleted = client.delete(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}", headers=headers
)
assert deleted.status_code == 204, deleted.text
assert client.get("/api/v1/budget/2026/9/expected-incomes").json() == []