198d3092c7
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.
Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).
Five more page-crashes of the same shape, adversarially verified:
- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
a toast description. The Toaster is a sibling of {children} in the ROOT
layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
workspace was in the error boundary for every company that had opened it.
Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.
Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.
No migrations.
65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { createElement, isValidElement } from 'react'
|
|
import { coerceToastNode } from '../toaster'
|
|
|
|
/**
|
|
* The Toaster is a sibling of {children} in the ROOT layout, so a throw here
|
|
* escapes both app/error.tsx and app/(dashboard)/error.tsx and lands on
|
|
* global-error, blanking the whole app (and, because global-error reloads
|
|
* once, pinning the user on the fallback for that path).
|
|
*
|
|
* Call sites that forward an unchecked `await res.json()` field pass the
|
|
* canonical `{ code, message, … }` envelope OBJECT instead of a string, which
|
|
* React refuses to render. This coercion is the choke point.
|
|
*/
|
|
describe('coerceToastNode', () => {
|
|
it('passes strings and numbers through untouched', () => {
|
|
expect(coerceToastNode('Kunde inte spara')).toBe('Kunde inte spara')
|
|
expect(coerceToastNode(42)).toBe(42)
|
|
})
|
|
|
|
it('passes null/undefined through so the description simply does not render', () => {
|
|
expect(coerceToastNode(null)).toBeNull()
|
|
expect(coerceToastNode(undefined)).toBeUndefined()
|
|
})
|
|
|
|
it('keeps React elements renderable', () => {
|
|
const el = createElement('span', null, 'hej')
|
|
expect(coerceToastNode(el)).toBe(el)
|
|
expect(isValidElement(coerceToastNode(el))).toBe(true)
|
|
})
|
|
|
|
it('turns a canonical error envelope into a readable string instead of throwing', () => {
|
|
const result = coerceToastNode({
|
|
code: 'NOT_FOUND',
|
|
message: 'Leverantören hittades inte',
|
|
} as unknown as React.ReactNode)
|
|
expect(typeof result).toBe('string')
|
|
expect(result).toBe('Leverantören hittades inte')
|
|
})
|
|
|
|
it('turns any other object into a string rather than a React child throw', () => {
|
|
const result = coerceToastNode({ requestId: 'abc' } as unknown as React.ReactNode)
|
|
expect(typeof result).toBe('string')
|
|
expect((result as string).length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('coerces objects nested inside an array child', () => {
|
|
const el = createElement('span', { key: 'a' }, 'hej')
|
|
const result = coerceToastNode([
|
|
'text',
|
|
{ code: 'NOT_FOUND', message: 'Hittades inte' },
|
|
el,
|
|
] as unknown as React.ReactNode) as unknown[]
|
|
expect(result[0]).toBe('text')
|
|
expect(result[1]).toBe('Hittades inte')
|
|
// Elements pass through by identity so their keys survive.
|
|
expect(result[2]).toBe(el)
|
|
})
|
|
|
|
it('drops booleans, which React would render as nothing anyway', () => {
|
|
expect(coerceToastNode(true)).toBeNull()
|
|
expect(coerceToastNode(false)).toBeNull()
|
|
})
|
|
})
|