Update file metadata in manifest.json and change MinIO image source in docker-compose.yml
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user