63d065011b
- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes. - Added new files related to MCC categories and optional hold reasons in the manifest. - Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
262 lines
9.6 KiB
Python
262 lines
9.6 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_categories_expose_and_update_multiple_mcc_codes(client: TestClient) -> None:
|
|
csrf = bootstrap(client)
|
|
definitions = client.get("/api/v1/budget/category-definitions").json()
|
|
groceries = next(item for item in definitions if item["systemKey"] == "groceries")
|
|
assert {5411, 5422}.issubset(groceries["mccCodes"])
|
|
assert any(item["systemKey"] == "others" for item in definitions)
|
|
|
|
created = client.post(
|
|
"/api/v1/budget/categories",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"name": "Shops", "kind": "monthly", "mccCodes": [5999, 5994]},
|
|
)
|
|
assert created.status_code == 201, created.text
|
|
assert created.json()["mccCodes"] == [5994, 5999]
|
|
category_id = created.json()["id"]
|
|
|
|
updated = client.patch(
|
|
f"/api/v1/budget/category-definitions/{category_id}",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"mccCodes": [5999]},
|
|
)
|
|
assert updated.status_code == 200, updated.text
|
|
assert updated.json()["mccCodes"] == [5999]
|
|
|
|
taken = client.patch(
|
|
f"/api/v1/budget/category-definitions/{category_id}",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"mccCodes": [5411]},
|
|
)
|
|
assert taken.status_code == 409
|
|
assert taken.json()["error"]["code"] == "CATEGORY_MCC_TAKEN"
|
|
|
|
released = client.patch(
|
|
f"/api/v1/budget/category-definitions/{groceries['id']}",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"mccCodes": [code for code in groceries["mccCodes"] if code != 5411]},
|
|
)
|
|
assert released.status_code == 200, released.text
|
|
remapped = client.patch(
|
|
f"/api/v1/budget/category-definitions/{category_id}",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"mccCodes": [5411, 5999]},
|
|
)
|
|
assert remapped.status_code == 200, remapped.text
|
|
assert remapped.json()["mccCodes"] == [5411, 5999]
|
|
|
|
|
|
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
|