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.
156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { screen, within } from '@testing-library/react'
|
|
import { api } from '@/api/endpoints'
|
|
import type { Account, BudgetCategory, Operation } from '@/api/types'
|
|
import { OperationsPage } from '@/pages/OperationsPage'
|
|
import { renderWithProviders } from './helpers'
|
|
|
|
vi.mock('@/api/endpoints', () => ({
|
|
api: {
|
|
accounts: {
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
archive: vi.fn(),
|
|
adjustBalance: vi.fn(),
|
|
},
|
|
operations: {
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
void: vi.fn(),
|
|
categorize: vi.fn(),
|
|
savingsTransfer: vi.fn(),
|
|
savingsTransferRevert: vi.fn(),
|
|
},
|
|
budget: {
|
|
summary: vi.fn(),
|
|
categories: vi.fn(),
|
|
updatePlan: vi.fn(),
|
|
fund: vi.fn(),
|
|
close: vi.fn(),
|
|
reopen: vi.fn(),
|
|
copyPrevious: vi.fn(),
|
|
categoryDefinitions: vi.fn(),
|
|
createCategory: vi.fn(),
|
|
updateCategoryDefinition: vi.fn(),
|
|
},
|
|
},
|
|
}))
|
|
|
|
const listAccountsMock = vi.mocked(api.accounts.list)
|
|
const listOperationsMock = vi.mocked(api.operations.list)
|
|
const budgetCategoriesMock = vi.mocked(api.budget.categories)
|
|
|
|
function account(overrides: Partial<Account> = {}): Account {
|
|
return {
|
|
id: 'a1',
|
|
name: 'Alfa Card',
|
|
type: 'debit',
|
|
status: 'active',
|
|
bankCode: null,
|
|
maskedNumber: null,
|
|
openedOn: '2026-01-15',
|
|
currency: 'RUB',
|
|
systemKey: null,
|
|
nameKey: null,
|
|
balanceKopecks: 123456,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function operation(overrides: Partial<Operation> & { id: string }): Operation {
|
|
return {
|
|
kind: 'expense',
|
|
status: 'confirmed',
|
|
amountKopecks: 500000,
|
|
occurredOn: '2026-09-05',
|
|
postedOn: null,
|
|
accountId: 'a1',
|
|
counterpartyAccountId: null,
|
|
categoryId: null,
|
|
categoryName: null,
|
|
categoryKey: null,
|
|
payeeName: 'Coffee shop',
|
|
incomeSourceName: null,
|
|
description: null,
|
|
parentOperationId: null,
|
|
convertedFromExpense: false,
|
|
version: 1,
|
|
createdAt: '2026-09-05T10:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('OperationsPage filter bar, row actions and create menu', () => {
|
|
beforeEach(() => {
|
|
listAccountsMock.mockResolvedValue([account()])
|
|
listOperationsMock.mockResolvedValue({
|
|
items: [operation({ id: 'op-1' })],
|
|
nextCursor: null,
|
|
})
|
|
budgetCategoriesMock.mockResolvedValue([] as BudgetCategory[])
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('renders a single filter bar with search and equal compact date filters', () => {
|
|
const { container } = renderWithProviders(<OperationsPage />)
|
|
const form = container.querySelector('form.filters')
|
|
expect(form).not.toBeNull()
|
|
|
|
const search = form?.querySelector('input[type="search"]')
|
|
expect(search).toHaveAttribute('aria-label', 'Search')
|
|
|
|
const selects = Array.from(form?.querySelectorAll('select') ?? [])
|
|
expect(selects.map((select) => select.getAttribute('aria-label'))).toEqual([
|
|
'Account',
|
|
'Type',
|
|
'Status',
|
|
])
|
|
|
|
const dates = Array.from(form?.querySelectorAll('input[type="date"]') ?? [])
|
|
expect(dates).toHaveLength(2)
|
|
const [fromDate, toDate] = dates
|
|
if (!fromDate || !toDate) throw new Error('date filters missing')
|
|
expect(fromDate).toHaveAttribute('aria-label', 'From date')
|
|
expect(toDate).toHaveAttribute('aria-label', 'To date')
|
|
expect(fromDate.className).toBe(toDate.className)
|
|
expect(fromDate.className).toContain('filter-date')
|
|
expect(fromDate.closest('.filter-date-field')).toHaveTextContent('From date')
|
|
expect(toDate.closest('.filter-date-field')).toHaveTextContent('To date')
|
|
})
|
|
|
|
it('keeps Void and To savings adjacent in one action line', async () => {
|
|
renderWithProviders(<OperationsPage />)
|
|
const row = (await screen.findByText('Coffee shop')).closest('tr') as HTMLTableRowElement
|
|
const actions = row.querySelector('.operation-actions .inline-actions')
|
|
expect(actions).not.toBeNull()
|
|
const buttons = Array.from(actions?.querySelectorAll('button') ?? [])
|
|
expect(buttons.map((button) => button.textContent)).toEqual(['To savings', 'Void'])
|
|
for (const button of buttons) {
|
|
expect(button.className).toContain('btn')
|
|
}
|
|
})
|
|
|
|
it('opens the create menu as a right-hand drawer with white type options', async () => {
|
|
renderWithProviders(<OperationsPage />)
|
|
await userEvent.click(screen.getByRole('button', { name: '+ New operation' }))
|
|
const dialog = await screen.findByRole('dialog', { name: 'New operation' })
|
|
expect(dialog).toHaveClass('modal')
|
|
|
|
const group = within(dialog).getByRole('group', { name: 'Type' })
|
|
const options = Array.from(group.querySelectorAll('button'))
|
|
expect(options.map((option) => option.textContent)).toEqual([
|
|
'Expense',
|
|
'Income',
|
|
'Transfer',
|
|
'Refund',
|
|
])
|
|
const [activeOption] = options
|
|
if (!activeOption) throw new Error('type options missing')
|
|
expect(activeOption.getAttribute('aria-pressed')).toBe('true')
|
|
})
|
|
})
|