Files
finance/backend/tests/test_category_definitions.py
T

217 lines
7.8 KiB
Python

"""HTTP contract for category definitions, system protections and CSRF."""
from fastapi.testclient import TestClient
def bootstrap(client: TestClient) -> str:
invite = client.post(
"/api/v1/admin/invitations",
headers={"X-Admin-Token": "test-admin-token"},
json={"email": "categories@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": "Category Tester",
"locale": "en",
},
)
assert accepted.status_code == 201, accepted.text
return client.cookies["csrf_token"]
def test_space_has_protected_savings_pair(client: TestClient) -> None:
csrf = bootstrap(client)
accounts = client.get("/api/v1/accounts").json()
savings = [item for item in accounts if item["systemKey"] == "savings"]
assert len(savings) == 1
assert savings[0]["type"] == "savings"
assert savings[0]["nameKey"] == "account.savings"
assert savings[0]["balanceKopecks"] == 0
definitions = client.get(
"/api/v1/budget/category-definitions", params={"includeArchived": "true"}
).json()
savings_definitions = [item for item in definitions if item["systemKey"] == "savings"]
assert len(savings_definitions) == 1
assert savings_definitions[0]["nameKey"] == "savings"
assert savings_definitions[0]["name"] is None
assert savings_definitions[0]["archived"] is False
assert savings_definitions[0]["kind"] == "savings_envelope"
assert savings_definitions[0]["linkedAccountId"] == savings[0]["id"]
# CSRF is enforced on the new mutation endpoints.
denied = client.patch(
f"/api/v1/budget/category-definitions/{savings_definitions[0]['id']}",
json={"name": "My stash"},
)
assert denied.status_code == 403
# System categories reject rename and archive.
for payload in ({"name": "My stash"}, {"archived": True}):
rejected = client.patch(
f"/api/v1/budget/category-definitions/{savings_definitions[0]['id']}",
json=payload,
headers={"X-CSRF-Token": csrf},
)
assert rejected.status_code == 409, rejected.text
# System accounts reject archive and delete.
account_id = savings[0]["id"]
archive_response = client.post(
f"/api/v1/accounts/{account_id}/archive", headers={"X-CSRF-Token": csrf}
)
assert archive_response.status_code == 409
delete_response = client.delete(
f"/api/v1/accounts/{account_id}", headers={"X-CSRF-Token": csrf}
)
assert delete_response.status_code == 409
def test_category_lifecycle_create_rename_archive_restore(client: TestClient) -> None:
csrf = bootstrap(client)
created = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": " Groceries ", "kind": "monthly"},
)
assert created.status_code == 201, created.text
body = created.json()
assert body["name"] == "Groceries"
assert body["kind"] == "monthly"
assert body["systemKey"] is None
assert body["archived"] is False
category_id = body["id"]
renamed = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"name": "Food"},
)
assert renamed.status_code == 200, renamed.text
assert renamed.json()["name"] == "Food"
archived = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"archived": True},
)
assert archived.status_code == 200
assert archived.json()["archived"] is True
active_only = client.get("/api/v1/budget/category-definitions").json()
assert all(item["id"] != category_id for item in active_only)
with_archived = client.get(
"/api/v1/budget/category-definitions", params={"includeArchived": "true"}
).json()
assert any(item["id"] == category_id and item["archived"] for item in with_archived)
restored = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"archived": False},
)
assert restored.status_code == 200
assert restored.json()["archived"] is False
# Empty names are rejected after trim.
empty = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": " ", "kind": "monthly"},
)
assert empty.status_code == 400
blank_rename = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"name": " "},
)
assert blank_rename.status_code == 400
def test_openapi_contains_new_contracts(client: TestClient) -> None:
bootstrap(client)
paths = client.get("/api/v1/openapi.json").json()["paths"]
assert "/api/v1/accounts/{account_id}/adjust-balance" in paths
assert "/api/v1/operations/{operation_id}/savings-transfer" in paths
assert "/api/v1/operations/{operation_id}/savings-transfer/revert" in paths
assert "/api/v1/budget/category-definitions" in paths
assert "/api/v1/budget/category-definitions/{category_id}" in paths
assert "/api/v1/imports/{document_id}/rows/{row_id}/resolve" in paths
def test_adjust_and_savings_conversion_http_contracts(client: TestClient) -> None:
csrf = bootstrap(client)
account = client.post(
"/api/v1/accounts",
headers={"X-CSRF-Token": csrf},
json={
"name": "Debit",
"type": "debit",
"openedOn": "2026-09-01",
"openingBalanceKopecks": 100_000,
},
)
assert account.status_code == 201, account.text
account_id = account.json()["id"]
adjustment_body = {
"targetBalanceKopecks": 90_000,
"occurredOn": "2026-09-10",
"reason": "Statement reconciliation",
}
denied = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body
)
assert denied.status_code == 403
adjusted = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance",
headers={"X-CSRF-Token": csrf},
json=adjustment_body,
)
assert adjusted.status_code == 200, adjusted.text
assert adjusted.json()["oldBalanceKopecks"] == 100_000
assert adjusted.json()["newBalanceKopecks"] == 90_000
assert adjusted.json()["kind"] == "adjustment"
category = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": "Food", "kind": "monthly"},
)
assert category.status_code == 201, category.text
expense = client.post(
"/api/v1/operations",
headers={"X-CSRF-Token": csrf},
json={
"kind": "expense",
"amountKopecks": 5_000,
"accountId": account_id,
"categoryId": category.json()["id"],
"occurredOn": "2026-09-11",
},
)
assert expense.status_code == 201, expense.text
operation_id = expense.json()["id"]
denied = client.post(f"/api/v1/operations/{operation_id}/savings-transfer")
assert denied.status_code == 403
converted = client.post(
f"/api/v1/operations/{operation_id}/savings-transfer",
headers={"X-CSRF-Token": csrf},
)
assert converted.status_code == 200, converted.text
assert converted.json()["kind"] == "transfer"
assert converted.json()["convertedFromExpense"] is True
reverted = client.post(
f"/api/v1/operations/{operation_id}/savings-transfer/revert",
headers={"X-CSRF-Token": csrf},
)
assert reverted.status_code == 200, reverted.text
assert reverted.json()["kind"] == "expense"
assert reverted.json()["convertedFromExpense"] is False