Files
accounted/lib/browser/__tests__/post-action.test.ts
T
Jakob Wennberg c3b7e79af8 fix(errors): show a route's Swedish message even without a keyword match (#2086) (#2253)
* fix(errors): show a route's Swedish message even without a keyword match (#2086)

getErrorMessage passed a route's free-text `error` / `message` through
only if it contained one of ~30 keywords ("kunde inte", "saknas", ...).
"Inget skattekonto är registrerat hos Skatteverket." has none, so the
skattekonto sync replaced the one sentence that would have helped with
"Ett oväntat serverfel uppstod. Försök igen senare.", which is wrong
advice for a company without a skattekonto. 155 of the 631 message_sv
strings in structured-errors.ts failed the same keyword test.

A second way in: looksLikeUserFacingSwedish accepts a string that reads
as Swedish (å/ä/ö, a strong Swedish word, or two weak function words)
and shows no sign of a technical leak (stack frames, file:line, JS/Node
error vocabulary, Postgres/PostgREST/SQL fragments, JSON, URLs). The
keyword list stays as the first way in. English framework text still
falls through to the status/context fallback, and a registry-wide test
pins that every message_sv now passes.

Closes #2086

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

* test(errors): pin the two call sites whose Swedish messages now pass through (#2086)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:55:33 +02:00

158 lines
5.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { postAction, POST_ACTION_TIMEOUT_MS } from '@/lib/browser/post-action'
const originalFetch = globalThis.fetch
const MARK_PAID_URL = '/api/skatteverket/tax-payments/2026-04/mark-paid'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** A fetch that never resolves and rejects the way the platform does on abort. */
function hangingFetch() {
return vi.fn((_url: unknown, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('The operation was aborted due to timeout')
err.name = 'TimeoutError'
reject(err)
})
})
})
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('postAction', () => {
it('reports success on 2xx without reading the body', async () => {
const res = jsonResponse({ data: { ok: true } })
const jsonSpy = vi.spyOn(res, 'json')
globalThis.fetch = vi.fn().mockResolvedValue(res)
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({ ok: true })
// The success shape is the route's business, not the button's.
expect(jsonSpy).not.toHaveBeenCalled()
})
it('sends POST', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: { ok: true } }))
globalThis.fetch = fetchMock
await postAction({ url: MARK_PAID_URL })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.method).toBe('POST')
})
it('reports the envelope message on 404', async () => {
// The regression guard for the missing catch: this used to be the only
// branch that spoke to the user at all.
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: 'Ingen AGI för perioden 2026-04.' }, 404))
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({
ok: false,
reason: 'server',
status: 404,
// The route's own Swedish sentence is shown as-is (issue #2086): it
// used to be dropped for lacking one of getErrorMessage's keywords and
// replaced by the status map's "Resursen kunde inte hittas.".
message: 'Ingen AGI för perioden 2026-04.',
})
})
it('keeps a Swedish server message verbatim', async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: 'Kunde inte markera som betald. Försök igen.' }, 500))
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toMatchObject({
reason: 'server',
status: 500,
message: 'Kunde inte markera som betald. Försök igen.',
})
})
it('falls back to the status message when the error body is not JSON', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<html>502 Bad Gateway</html>', {
status: 502,
headers: { 'Content-Type': 'text/html' },
}),
)
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({
ok: false,
reason: 'server',
status: 502,
message: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
})
})
it('reports a server error in English for an English UI', async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: { code: 'INTERNAL_ERROR', message: 'Serverfel' } }, 500))
const result = await postAction({ url: MARK_PAID_URL, locale: 'en' })
expect(result).toMatchObject({ reason: 'server', message: 'Internal server error.' })
})
it('reports a thrown request as a network failure, not as success', async () => {
// The finding: with try/finally and no catch this rejection escaped as an
// unhandled rejection and the user was told nothing at all.
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({
ok: false,
reason: 'network',
message: 'Något gick fel. Försök igen.',
})
})
it('reports a hung request as a timeout, not as a generic failure', async () => {
globalThis.fetch = hangingFetch()
const result = await postAction({ url: MARK_PAID_URL, timeoutMs: 20 })
// A timeout on a write is ambiguous (the update may have landed), so the
// call site needs to tell it apart to say "reload and check".
expect(result).toEqual({ ok: false, reason: 'timeout' })
})
it('bounds the request with an abort signal on the default deadline', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: { ok: true } }))
globalThis.fetch = fetchMock
await postAction({ url: MARK_PAID_URL })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.signal).toBeInstanceOf(AbortSignal)
// Never shorter than the download deadline: aborting a mutation early
// reports a failure for a write that may have landed.
expect(POST_ACTION_TIMEOUT_MS).toBe(15_000)
})
})