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