Files
a7dcaac6ad feat(kpi): monthly revenue, expenses and result table under Nyckeltal, toggle in Anpassa (#2433)
The KPI payload has carried income, expenses and net per month since the
aggregates RPC, but after the Recharts trend chart was dropped only the net
column was rendered (the bars pane). A fiscal year's month-by-month sums
were therefore fetched and never shown (#2196).

- New components/kpi/KPIMonthsTable.tsx: full-width dry table (Manad,
  Intakter, Kostnader, Resultat) with the period totals as the last row,
  rendered between the panes and the cost story. Rows and totals come from
  the pure helper components/kpi/months-table.ts.
- New preference showMonthlyTable (default true) on KPIPreferences: filled
  by mergeWithDefaults on read, accepted by the preferences route, sent
  whole by the dialog, required by readPreferencesBody. A boolean, not a
  KPI_DEFINITIONS id: stored kpiOrder arrays would hide a new id for every
  existing company.
- One Switch row in the Anpassa dialog after the KPI list.
- Reuses the orphaned kpi.trend_* keys; adds months_col_month, months_total
  and the two settings keys in sv and en.
- Tests: helper rows/totals/inactive flags, defaults + merge, route accepts
  false and rejects a string; fixtures updated for the new field.

Closes #2196


Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 21:19:19 +02:00

202 lines
6.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
loadKPIPreferences,
LOAD_KPI_PREFERENCES_TIMEOUT_MS,
} from '@/components/kpi/load-preferences'
import type { KPIPreferences } from '@/types'
const originalFetch = globalThis.fetch
const STORED: KPIPreferences = {
visibleKpis: ['netResult', 'cashPosition'],
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
accountOverrides: { cashPosition: ['1930', '1940'] },
showMonthlyTable: true,
}
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('loadKPIPreferences', () => {
it('GETs the preferences endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: STORED }))
globalThis.fetch = fetchMock
await loadKPIPreferences()
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/kpi/preferences')
expect(init.method).toBe('GET')
expect(init.body).toBeUndefined()
})
it('returns the stored row the route answered with', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: STORED }))
const result = await loadKPIPreferences()
expect(result).toEqual({ ok: true, preferences: STORED })
})
it('reports a 401 as a server failure carrying the status-map sentence', async () => {
// The page reads status 401/403 off this arm to drop the retry: a retry
// cannot outlive an expired session, signing in again can.
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 401 }))
const result = await loadKPIPreferences()
expect(result).toEqual({
ok: false,
reason: 'server',
status: 401,
message: 'Din session har gått ut. Logga in igen.',
})
})
it('reports a 403 with its status so the page can drop the retry', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 403 }))
const result = await loadKPIPreferences()
expect(result).toMatchObject({ ok: false, reason: 'server', status: 403 })
})
it('reports a 500 as a failure instead of fabricating defaults', async () => {
// The finding: this arm used to fall into a silent catch, the page seeded
// the settings dialog from getDefaultPreferences(), and the next save PUT
// that defaults-based object over the user's stored layout. The terse
// envelope message is not Swedish user-prose, so getErrorMessage resolves
// it through the status map.
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: { code: 'INTERNAL_ERROR', message: 'Serverfel' } }, 500))
const result = await loadKPIPreferences()
expect(result).toEqual({
ok: false,
reason: 'server',
status: 500,
message: 'Ett oväntat serverfel uppstod. Försök igen senare.',
})
})
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 loadKPIPreferences()
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(new Response('', { status: 401 }))
const result = await loadKPIPreferences({ locale: 'en' })
expect(result).toMatchObject({
reason: 'server',
message: 'Your session has expired. Please sign in again.',
})
})
it('reports a thrown request as a network failure, not as defaults', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
const result = await loadKPIPreferences()
expect(result).toEqual({
ok: false,
reason: 'network',
message: 'Något gick fel. Försök igen.',
})
})
it('reports a hung request as a timeout instead of hanging the page', async () => {
globalThis.fetch = hangingFetch()
const result = await loadKPIPreferences({ timeoutMs: 20 })
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: STORED }))
globalThis.fetch = fetchMock
await loadKPIPreferences()
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(LOAD_KPI_PREFERENCES_TIMEOUT_MS).toBe(15_000)
})
it('treats a 200 without a preferences object as a failed read, never as defaults', async () => {
// Unlike the save path (where a 2xx means the row was already written and
// the sent payload is the closest truth), a read has nothing truthful to
// substitute: an ok result here would flow into the dialog draft and be
// saved over the stored row.
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({}))
const result = await loadKPIPreferences()
expect(result).toMatchObject({ ok: false, reason: 'network' })
})
it('treats a 200 with an incomplete preferences object as a failed read', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: { visibleKpis: null } }))
const result = await loadKPIPreferences()
expect(result).toMatchObject({ ok: false, reason: 'network' })
})
it('treats a 200 whose body is not JSON as a failed read', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<!doctype html><title>proxy</title>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
}),
)
const result = await loadKPIPreferences()
expect(result).toMatchObject({ ok: false, reason: 'network' })
})
})