"""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