Files
accounted/components/transactions/__tests__/booking-feedback-parity.test.ts
T
9ebb2e518f feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871)
The old Bankavstämning report page was the only place a user could still tag
a bank row as ingående balans or move it to another bank account, so the new
/reconciliation page kept linking out to it and the reconciliation lived in
two places. Both row tools now live on the account overview (hover-revealed,
same endpoints), the ?autorun=1 deep link from the transactions inbox runs the
matcher on the new page, and the report slug redirects: old links, ⌘K, the
bokslut readiness wizard and the ignore toast all land on /reconciliation.

BankReconciliationView and its FocusedReport branches (own range preset,
help popover, autoRun plumbing) are removed; the source-grepping parity test
for its quick-book path goes with it.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:03:17 +02:00

137 lines
6.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
/**
* Booking-feedback parity between the two booking paths on the transactions
* page.
*
* The counterparty-template path (Bokför → "Tidigare motparter") used to end a
* successful booking with nothing but `setExitingIds().add(id)`: no "Bokförd"
* toast, no Ångra action, no unbooked-count decrement, and the id was never
* removed from exitingIds again, so an undo would have restored the row's data
* while leaving it filtered out of the inbox. Every other booking path ran
* runCategorize's success tail.
*
* Both now go through one `finishBooking`. This repo runs Vitest in the `node`
* environment and never renders components, so, like the sibling
* invoice-match-dialog tests, these are file-level assertions: the two paths
* must not drift apart again.
*/
const PAGE_SRC = fs.readFileSync(
path.resolve(__dirname, '../../../app/(dashboard)/transactions/page.tsx'),
'utf8',
)
const readMessages = (locale: 'sv' | 'en', namespace: string) =>
(
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../messages/${locale}.json`), 'utf8'),
) as Record<string, Record<string, string>>
)[namespace]
describe('transactions page booking feedback', () => {
it('defines exactly one success tail', () => {
expect(PAGE_SRC).toContain('function finishBooking(')
// One undo implementation, and one place that calls the storno endpoint.
expect(PAGE_SRC.match(/altText="Ångra kategorisering"/g) ?? []).toHaveLength(1)
expect(PAGE_SRC.match(/uncategorize`, \{ method: 'POST' \}/g) ?? []).toHaveLength(1)
})
it('routes every successful booking through it', () => {
// runCategorize (category / catalog template / library template), the
// counterparty-template booking, the counterparty activate-and-retry, and
// the counterparty duplicate-warning "Bokför ändå" retry.
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(4)
})
it('no longer ends the counterparty path on a bare exitingIds add', () => {
// The old tail: setExitingIds(...) immediately followed by `journalEntryId = cpJeId`.
expect(PAGE_SRC).not.toMatch(
/setExitingIds\(\(prev\) => new Set\(prev\)\.add\(id\)\)\s*\n\s*journalEntryId = cpJeId/,
)
})
it('clears the id from exitingIds so an undo puts the row back', () => {
// Without the delete, an undone booking restores is_business: null but the
// row stays filtered out of the inbox (see uncategorizedTransactions).
expect(PAGE_SRC).toMatch(/next\.delete\(id\)/)
})
it('lets a completed undo win over the delayed booked-state patch', () => {
// The 350ms animation timer must not re-apply journal_entry_id after an
// Ångra has already storno-reversed the verifikat server-side. The
// closure-local flag covers the per-row Ångra; the shared undoneIdsRef
// covers "Ångra alla", which runs outside finishBooking's closure.
expect(PAGE_SRC).toMatch(/let undone = false/)
expect(PAGE_SRC).toMatch(/undone = true/)
expect(PAGE_SRC).toMatch(/if \(!undone && !undoneIdsRef\.current\.has\(id\)\) \{/)
// Both undo paths record into the shared ref, and a fresh booking clears
// its row's entry again so a re-booked row still gets its delayed patch.
expect(PAGE_SRC).toMatch(/undoneIdsRef\.current\.add\(id\)/)
expect(PAGE_SRC).toMatch(/undoneIdsRef\.current\.add\(undoneId\)/)
expect(PAGE_SRC).toMatch(/undoneIdsRef\.current\.delete\(id\)/)
})
it('clears only the finished row\'s spinner', () => {
// The shared tail runs for rows that never set processingId; an
// unconditional clear would wipe an unrelated in-flight row.
expect(PAGE_SRC).toMatch(/setProcessingId\(\(prev\) => \(prev === id \? null : prev\)\)/)
})
it('decrements the unbooked count on every path that removes a row', () => {
// finishBooking, handleTransactionBooked (manual booking dialog / voucher
// match), the three other single-row exits already on the page, the
// duplicate-dialog "Ignorera transaktionen" tail, and
// handleDeleteTransaction (deleting a pending row must not leave the
// inbox badge stale: the realtime echo is not guaranteed for DELETE).
expect(
PAGE_SRC.match(/setTotalUncategorizedCount\(\(prev\) => Math\.max\(0, \(prev \?\? 1\) - 1\)\)/g) ?? [],
).toHaveLength(7)
})
it('ships the undo strings it renders in both locales', () => {
for (const locale of ['sv', 'en'] as const) {
const messages = readMessages(locale, 'transactions')
for (const key of [
'undone_title',
'undone_description',
'undo_failed_title',
'undo_failed_description',
'partially_booked_title',
'partially_booked_description',
]) {
expect(messages[key], `${locale}.transactions.${key}`).toBeTruthy()
}
}
})
})
/**
* Duplicate-guard feedback parity: every client of POST /categorize must route
* a TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 into DuplicateBookingDialog (which
* offers match / ignore / book-anyway), never into a destructive toast that
* names no way forward. The counterparty-template branch of
* handleQuickReviewConfirm used to dead-end. (The old BankReconciliationView
* quick-book was retired with the view on 2026-08-25; /reconciliation books
* through the shared transactions inbox flow instead.)
*/
describe('duplicate-guard 409 routing parity', () => {
it('handles the duplicate code on both booking paths of the transactions page', () => {
// runCategorize AND the counterparty-template branch.
expect(
PAGE_SRC.match(/error\?\.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'/g) ?? [],
).toHaveLength(2)
})
it('opens the dialog from the counterparty branch with a force-bound retry', () => {
// The branch must set the shared duplicateWarning state (the dialog) and
// bind the retry to the reviewed candidate's voucher.
expect(PAGE_SRC).toMatch(
/TRANSACTION_BOOK_POSSIBLE_DUPLICATE'[\s\S]{0,600}cpCategorize\(\{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
})