217 lines
7.8 KiB
Python
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
|