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.
347 lines
11 KiB
Python
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() == []
|