Files
accounted/components/transactions/__tests__/booking-feedback-parity.test.ts
T
Jakob Wennberg 954ce873a8 fix(transactions): confirm and allow undo on a counterparty booking (#1292)
Booking via "Tidigare motparter" ended on a bare setExitingIds().add(id): the
row animated out of the inbox and that was the entire feedback. No "Bokförd"
toast, no Ångra, 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 list.

Extracted runCategorize's success tail into one finishBooking() rather than
copying the toast into the counterparty branch: the counterparty path was
already a second, thinner implementation of it, which is why it drifted. Both
paths and the counterparty activate-and-retry go through it now, pinned by a
parity test.

Also fixed on the way:

- handleTransactionBooked (manual booking dialog / voucher match) never
  decremented the unbooked count, so the header read one too high until the
  next refetch. It deliberately gets no Ångra: its `matched` branch links the
  transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses
  whatever journal_entry_id the transaction points at.
- The 350ms animation timer re-applied the booked shape unconditionally, so an
  Ångra resolving inside that window left the client claiming a journal_entry_id
  the server had already storno-reversed. A completed undo now wins.
- finishBooking cleared processingId unconditionally; scoped to the finished id
  so it cannot wipe an unrelated row's spinner.

No migrations.
2026-07-29 19:58:47 +02:00

99 lines
4.0 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, and the counterparty activate-and-retry.
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(3)
})
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.
expect(PAGE_SRC).toMatch(/let undone = false/)
expect(PAGE_SRC).toMatch(/undone = true/)
expect(PAGE_SRC).toMatch(/if \(!undone\) \{/)
})
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), and the three other single-row exits already on the page.
expect(
PAGE_SRC.match(/setTotalUncategorizedCount\(\(prev\) => Math\.max\(0, \(prev \?\? 1\) - 1\)\)/g) ?? [],
).toHaveLength(5)
})
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()
}
}
})
})