970fe9dacf
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 14s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Successful in 45s
Gitea Actions CI / Backend (lint / type / test / build) (push) Successful in 1m31s
176 lines
5.9 KiB
Python
176 lines
5.9 KiB
Python
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": "Queue@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": "Test Person",
|
|
"locale": "en",
|
|
},
|
|
)
|
|
assert accepted.status_code == 201, accepted.text
|
|
return client.cookies["csrf_token"]
|
|
|
|
|
|
def create_account(client: TestClient, csrf: str) -> str:
|
|
response = client.post(
|
|
"/api/v1/accounts",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={
|
|
"name": "Wallet",
|
|
"type": "cash",
|
|
"openedOn": "2026-02-01",
|
|
"openingBalanceKopecks": 100_000,
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()["id"]
|
|
|
|
|
|
def create_expense(client: TestClient, csrf: str, account_id: str, description: str) -> str:
|
|
response = client.post(
|
|
"/api/v1/operations",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={
|
|
"kind": "expense",
|
|
"accountId": account_id,
|
|
"amountKopecks": 1_500,
|
|
"occurredOn": "2026-02-10",
|
|
"description": description,
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()["id"]
|
|
|
|
|
|
def category_ids(client: TestClient) -> dict[str, str]:
|
|
response = client.get(
|
|
"/api/v1/budget/category-definitions", params={"includeArchived": "false"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return {
|
|
item["systemKey"]: item["id"] for item in response.json() if item["systemKey"] is not None
|
|
}
|
|
|
|
|
|
def operations_by_id(client: TestClient) -> dict[str, dict]:
|
|
response = client.get("/api/v1/operations", params={"kind": "expense", "limit": 200})
|
|
assert response.status_code == 200, response.text
|
|
return {item["id"]: item for item in response.json()["items"]}
|
|
|
|
|
|
def test_batch_assigns_picked_categories_and_moves_leftovers_to_others(
|
|
client: TestClient,
|
|
) -> None:
|
|
csrf = bootstrap(client)
|
|
account_id = create_account(client, csrf)
|
|
categories = category_ids(client)
|
|
first = create_expense(client, csrf, account_id, "Coffee")
|
|
second = create_expense(client, csrf, account_id, "Groceries")
|
|
|
|
response = client.post(
|
|
"/api/v1/operations/categorize-batch",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={
|
|
"items": [
|
|
{"operationId": first, "categoryId": categories["groceries"]},
|
|
{"operationId": second},
|
|
]
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"updated": [first, second], "skipped": []}
|
|
|
|
operations = operations_by_id(client)
|
|
assert operations[first]["categoryKey"] == "groceries"
|
|
assert operations[second]["categoryKey"] == "others"
|
|
assert operations[second]["categoryName"] == "Остальное"
|
|
|
|
left = client.get("/api/v1/operations", params={"uncategorized": "true"})
|
|
assert left.json()["items"] == []
|
|
|
|
|
|
def test_batch_never_overwrites_existing_categories_or_voided_operations(
|
|
client: TestClient,
|
|
) -> None:
|
|
csrf = bootstrap(client)
|
|
account_id = create_account(client, csrf)
|
|
categories = category_ids(client)
|
|
categorized = create_expense(client, csrf, account_id, "Already done")
|
|
voided = create_expense(client, csrf, account_id, "Voided")
|
|
pending = create_expense(client, csrf, account_id, "Pending")
|
|
|
|
patch = client.patch(
|
|
f"/api/v1/operations/{categorized}",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"categoryId": categories["groceries"]},
|
|
)
|
|
assert patch.status_code == 200, patch.text
|
|
void = client.post(
|
|
f"/api/v1/operations/{voided}/void",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"reason": "duplicate"},
|
|
)
|
|
assert void.status_code == 200, void.text
|
|
|
|
response = client.post(
|
|
"/api/v1/operations/categorize-batch",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={
|
|
"items": [
|
|
{"operationId": categorized, "categoryId": categories["cafes"]},
|
|
{"operationId": voided, "categoryId": categories["cafes"]},
|
|
{"operationId": pending, "categoryId": categories["cafes"]},
|
|
]
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"updated": [pending], "skipped": [categorized, voided]}
|
|
|
|
operations = operations_by_id(client)
|
|
assert operations[categorized]["categoryKey"] == "groceries"
|
|
assert operations[pending]["categoryKey"] == "cafes"
|
|
|
|
|
|
def test_batch_is_atomic_when_one_category_is_unknown(client: TestClient) -> None:
|
|
csrf = bootstrap(client)
|
|
account_id = create_account(client, csrf)
|
|
first = create_expense(client, csrf, account_id, "First")
|
|
second = create_expense(client, csrf, account_id, "Second")
|
|
|
|
response = client.post(
|
|
"/api/v1/operations/categorize-batch",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={
|
|
"items": [
|
|
{"operationId": first, "categoryId": "00000000-0000-0000-0000-000000000000"},
|
|
{"operationId": second},
|
|
]
|
|
},
|
|
)
|
|
assert response.status_code == 404, response.text
|
|
assert response.json()["error"]["code"] == "BUDGET_CATEGORY_NOT_FOUND"
|
|
|
|
operations = operations_by_id(client)
|
|
assert operations[first]["categoryId"] is None
|
|
assert operations[second]["categoryId"] is None
|
|
|
|
|
|
def test_batch_rejects_empty_payload(client: TestClient) -> None:
|
|
csrf = bootstrap(client)
|
|
response = client.post(
|
|
"/api/v1/operations/categorize-batch",
|
|
headers={"X-CSRF-Token": csrf},
|
|
json={"items": []},
|
|
)
|
|
assert response.status_code == 422, response.text
|