f5ae63e158
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.
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import { waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { api } from '@/api/endpoints'
|
|
import type { BudgetCategory, Operation } from '@/api/types'
|
|
import { ReviewQueuePage } from '@/pages/ReviewQueuePage'
|
|
import { renderWithProviders } from './helpers'
|
|
|
|
vi.mock('@/api/endpoints', () => ({
|
|
api: {
|
|
accounts: { list: vi.fn() },
|
|
operations: {
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
void: vi.fn(),
|
|
categorize: vi.fn(),
|
|
categorizeBatch: vi.fn(),
|
|
savingsTransfer: vi.fn(),
|
|
savingsTransferRevert: vi.fn(),
|
|
},
|
|
budget: { categories: vi.fn() },
|
|
imports: { list: vi.fn() },
|
|
},
|
|
}))
|
|
|
|
const importsListMock = vi.mocked(api.imports.list)
|
|
const operationsListMock = vi.mocked(api.operations.list)
|
|
const categoriesMock = vi.mocked(api.budget.categories)
|
|
const categorizeBatchMock = vi.mocked(api.operations.categorizeBatch)
|
|
const categorizeMock = vi.mocked(api.operations.categorize)
|
|
|
|
const categories: BudgetCategory[] = [
|
|
{ id: 'cat-groceries', name: 'Продукты', nameKey: 'groceries', kind: 'monthly', system: false, archived: false, creditAccountId: null, plannedKopecks: 0, fundedKopecks: 0, spentKopecks: 0, availableKopecks: 0 },
|
|
{ id: 'cat-others', name: 'Остальное', nameKey: 'others', kind: 'monthly', system: true, archived: false, creditAccountId: null, plannedKopecks: 0, fundedKopecks: 0, spentKopecks: 0, availableKopecks: 0 },
|
|
]
|
|
|
|
function operation(id: string, description: string): Operation {
|
|
return {
|
|
id,
|
|
kind: 'expense',
|
|
status: 'confirmed',
|
|
amountKopecks: 150_00,
|
|
occurredOn: '2026-02-10',
|
|
postedOn: null,
|
|
accountId: 'acc-1',
|
|
counterpartyAccountId: null,
|
|
categoryId: null,
|
|
categoryName: null,
|
|
categoryKey: null,
|
|
payeeName: null,
|
|
incomeSourceName: null,
|
|
description,
|
|
parentOperationId: null,
|
|
convertedFromExpense: false,
|
|
version: 1,
|
|
createdAt: '2026-02-11T10:00:00Z',
|
|
}
|
|
}
|
|
|
|
describe('ReviewQueuePage single save with Other fallback', () => {
|
|
beforeEach(() => {
|
|
importsListMock.mockResolvedValue([])
|
|
operationsListMock.mockResolvedValue({
|
|
items: [operation('op-1', 'Coffee'), operation('op-2', 'Books')],
|
|
nextCursor: null,
|
|
})
|
|
categoriesMock.mockResolvedValue(categories)
|
|
categorizeBatchMock.mockResolvedValue({ updated: ['op-1', 'op-2'], skipped: [] })
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('submits one batch request: picked categories win, the rest fall back to null (Other)', async () => {
|
|
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
|
|
const user = userEvent.setup()
|
|
|
|
await screen.findByText('Coffee')
|
|
|
|
const selects = screen.getAllByRole('combobox', { name: 'Assign category' })
|
|
expect(selects).toHaveLength(2)
|
|
|
|
await user.selectOptions(selects[0]!, 'cat-groceries')
|
|
await user.click(screen.getByRole('button', { name: 'Save' }))
|
|
|
|
await waitFor(() => expect(categorizeBatchMock).toHaveBeenCalledTimes(1))
|
|
expect(categorizeBatchMock).toHaveBeenCalledWith({
|
|
items: [
|
|
{ operationId: 'op-1', categoryId: 'cat-groceries' },
|
|
{ operationId: 'op-2', categoryId: null },
|
|
],
|
|
})
|
|
expect(categorizeMock).not.toHaveBeenCalled()
|
|
await screen.findByText('Categories saved')
|
|
})
|
|
|
|
it('offers exactly one save button and clears drafts after saving', async () => {
|
|
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
|
|
const user = userEvent.setup()
|
|
|
|
await screen.findByText('Books')
|
|
expect(screen.getAllByRole('button', { name: 'Save' })).toHaveLength(1)
|
|
|
|
const selects = screen.getAllByRole('combobox', { name: 'Assign category' })
|
|
await user.selectOptions(selects[1]!, 'cat-others')
|
|
await user.click(screen.getByRole('button', { name: 'Save' }))
|
|
|
|
await waitFor(() => {
|
|
const current = screen.getAllByRole('combobox', { name: 'Assign category' })
|
|
expect(current.every((el) => (el as HTMLSelectElement).value === '')).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('explains the Others fallback in a hint', async () => {
|
|
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
|
|
|
|
await screen.findByText('Books')
|
|
expect(
|
|
screen.getByText('Transactions left without a category will be moved to “Other”.'),
|
|
).toBeInTheDocument()
|
|
})
|
|
})
|