Update manifest.json with new file metadata and add new test files
Gitea Actions CI / detect (push) Successful in 33s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 28s
Gitea Actions CI / Compose & nginx validation (push) Failing after 1m8s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Successful in 50s

- Updated timestamps and AST hashes for existing files in the backend and frontend.
- Added new test files: `backend/tests/test_review_queue_batch.py`, `frontend/src/test/review-queue-save.test.tsx`, `frontend/src/test/app-layout.test.tsx`, and `frontend/src/test/ledger-view.test.tsx`.
- Updated documentation file `docs/tasks/0001-fixes.md` with new metadata.
This commit is contained in:
k1nq
2026-09-23 15:06:45 +05:00
parent 63d065011b
commit f5ae63e158
28 changed files with 101997 additions and 6849 deletions
+20 -11
View File
@@ -125,6 +125,25 @@ def ensure_mcc_dictionary(db: Session) -> None:
db.flush()
def ensure_others_category(db: Session, *, space_id: uuid.UUID) -> Category:
"""The system «Остальное» fallback category, created on first use."""
others = (
db.query(Category)
.filter_by(space_id=space_id, system_key=mcc.OTHERS_SYSTEM_KEY)
.one_or_none()
)
if others is None:
others = Category(
space_id=space_id,
kind="monthly",
system_key=mcc.OTHERS_SYSTEM_KEY,
custom_name=mcc.OTHERS_CATEGORY_NAME,
)
db.add(others)
db.flush()
return others
def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str, Category]:
"""Idempotently pre-create the 20 popular categories plus «Остальное».
@@ -162,17 +181,7 @@ def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str,
db.add(CategoryMcc(space_id=space_id, category_id=category.id, mcc_code=code))
claimed.add(code)
result[definition.system_key] = category
others = existing.get(mcc.OTHERS_SYSTEM_KEY)
if others is None:
others = Category(
space_id=space_id,
kind="monthly",
system_key=mcc.OTHERS_SYSTEM_KEY,
custom_name=mcc.OTHERS_CATEGORY_NAME,
)
db.add(others)
db.flush()
result[mcc.OTHERS_SYSTEM_KEY] = others
result[mcc.OTHERS_SYSTEM_KEY] = ensure_others_category(db, space_id=space_id)
db.flush()
return result
+25
View File
@@ -52,6 +52,19 @@ class OperationPatchBody(BaseModel):
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
class CategorizeBatchItemBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
operation_id: uuid.UUID = Field(alias="operationId")
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
class CategorizeBatchBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: Annotated[list[CategorizeBatchItemBody], Field(min_length=1, max_length=200)]
class AdjustBalanceBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@@ -289,6 +302,18 @@ def patch_operation(
return operation_json(db, operation)
@router.post("/operations/categorize-batch")
def categorize_operations_batch(
body: CategorizeBatchBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
return service.categorize_batch(
db,
space_id=context.space.id,
user=context.user,
items=[(item.operation_id, item.category_id) for item in body.items],
)
@router.post("/accounts/{account_id}/adjust-balance")
def adjust_balance(
account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf
+39
View File
@@ -480,6 +480,45 @@ def update_operation(
return operation
def categorize_batch(
db: Session,
*,
space_id: uuid.UUID,
user: User,
items: list[tuple[uuid.UUID, uuid.UUID | None]],
) -> dict[str, list[str]]:
"""Assign categories to uncategorized operations in one transaction.
Items without an explicit category fall back to the system «Остальное»
category, so nothing stays uncategorized after a review-queue save.
Operations that already carry a category (user work done elsewhere) or
that were voided in the meantime are skipped, never overwritten.
"""
from app.categorization.service import ensure_others_category
fallback = ensure_others_category(db, space_id=space_id)
updated: list[str] = []
skipped: list[str] = []
for operation_id, category_id in items:
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
if operation.status == "voided" or operation.category_id is not None:
skipped.append(str(operation.id))
continue
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
target = category_id if category_id is not None else fallback.id
_validate_category(db, space_id=space_id, category_id=target)
operation.category_id = target
updated.append(str(operation.id))
audit.record(
db,
event_type="operations.batch_categorized",
space_id=space_id,
user_id=user.id,
detail={"updated": updated, "skipped": skipped},
)
return {"updated": updated, "skipped": skipped}
def archive_account(
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
) -> Account:
+177
View File
@@ -0,0 +1,177 @@
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