Files
F-r-ldraledigplannerare/frontend/src/services/api.test.ts
T
jacobh2 dc4c0a5edd Show preset wish names in the current UI language
Preset wishes were created with `name: t(preset.labelKey)`, so the
translated string was persisted as the wish's name. A wish created while
the UI was Swedish stayed "Jul" in English forever -- the Swedish text
was the data, so no display-layer change could fix it.

Wish names are user-editable free text, so `name` cannot simply be
translated at render time. Migration 007 adds a nullable
`leave_wish.name_message_key` recording where a name came from: preset
wishes store the i18n key alongside the translated literal, and the UI
renders the key when present.

The key is cleared server-side the moment `name` actually changes,
compared against the stored value rather than trusting the caller, so no
client can forget to clear it and silently revert a user's own name to a
preset label.

An unknown key (a stale row, a renamed key) falls back to the stored
literal via an isTranslationKey guard, so a bad key can never crash the
page or leak a raw key to the user. Rows predating this column keep
displaying their stored name.

_FULL_SCHEMA in db/connection.py is updated in step with the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 22:32:54 +02:00

1665 lines
49 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import * as api from './api'
import type { RawParentalLeaveFetch, RawWorkspaceOverview } from './api'
import { ApiRequestError } from './errors'
describe('API response mappers (snake_case → camelCase)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as any
})
afterEach(() => {
vi.clearAllMocks()
})
describe('getWorkspaceOverview', () => {
it('maps snake_case workspace overview to camelCase', async () => {
const rawResponse: RawWorkspaceOverview = {
workspace_id: 'ws1',
child_id: 'child1',
parent_projections: [
{
personal_data_uuid: 'pd1',
child_id: 'child1',
birth_date: '2024-01-01',
fourth_birthday: '2028-01-01',
total_days: 480,
days_used: 100,
planned_days_before_b4: 150,
projected_left_at_b4: 230,
savable_after_b4: 96,
expiring_at_b4: 134,
},
],
combined_days_used: 100,
combined_days_left: 380,
combined_expiring_at_b4: 134,
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getWorkspaceOverview('ws1', 'child1')
expect(result.workspaceId).toBe('ws1')
expect(result.childId).toBe('child1')
expect(result.combinedDaysUsed).toBe(100)
expect(result.combinedDaysLeft).toBe(380)
expect(result.combinedExpiringAtB4).toBe(134)
expect(result.parentProjections.length).toBe(1)
expect(result.parentProjections[0]!.personalDataUuid).toBe('pd1')
expect(result.parentProjections[0]!.fourthBirthday).toBe('2028-01-01')
expect(result.parentProjections[0]!.plannedDaysBeforeB4).toBe(150)
expect(result.parentProjections[0]!.projectedLeftAtB4).toBe(230)
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
})
})
describe('getParentalLeave', () => {
it('maps snake_case parental leave fetch to camelCase with nested tierData', async () => {
const rawResponse: RawParentalLeaveFetch = {
id: 'pf1',
personal_data_uuid: 'pd1',
workspace_id: 'ws1',
child_id: 'child1',
source: 'manual',
fetched_at: '2026-06-14T17:09:47',
total_days: 480,
days_used: 100,
days_left: 380,
reserved_days_total: 90,
reserved_days_left: 80,
transferable_days_left: 100,
dubbeldagar_left: 60,
tier_data: {
sjukpenning_total: 390,
sjukpenning_used: 50,
sjukpenning_left: 340,
lagsta_niva_total: 90,
lagsta_niva_used: 10,
lagsta_niva_left: 80,
},
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getParentalLeave('pd1', 'child1')
expect(result.personalDataUuid).toBe('pd1')
expect(result.workspaceId).toBe('ws1')
expect(result.childId).toBe('child1')
expect(result.fetchedAt).toBe('2026-06-14T17:09:47')
expect(result.totalDays).toBe(480)
expect(result.daysUsed).toBe(100)
expect(result.daysLeft).toBe(380)
expect(result.reservedDaysTotal).toBe(90)
expect(result.reservedDaysLeft).toBe(80)
expect(result.transferableDaysLeft).toBe(100)
expect(result.dubbeldagarLeft).toBe(60)
expect(result.createdAt).toBe('2026-06-14T17:09:47')
expect(result.updatedAt).toBe('2026-06-14T17:09:47')
expect(result.tierData!.sjukpenningTotal).toBe(390)
expect(result.tierData!.sjukpenningUsed).toBe(50)
expect(result.tierData!.sjukpenningLeft).toBe(340)
expect(result.tierData!.lagstaNivaTotal).toBe(90)
expect(result.tierData!.lagstaNivaUsed).toBe(10)
expect(result.tierData!.lagstaNivaLeft).toBe(80)
})
})
describe('getPlannedDays', () => {
it('maps snake_case planned days to camelCase', async () => {
const rawResponse = [
{
id: 'pd1',
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pp1',
date: '2026-06-15',
day_type: 'sjukpenning' as const,
is_dubbeldag: false,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
},
{
id: 'pd2',
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pp1',
date: '2026-06-16',
day_type: 'vacation' as const,
is_dubbeldag: true,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
},
]
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getPlannedDays('child1')
expect(result).toHaveLength(2)
expect(result[0]!.workspaceId).toBe('ws1')
expect(result[0]!.childId).toBe('child1')
expect(result[0]!.parentPersonalDataUuid).toBe('pp1')
expect(result[0]!.dayType).toBe('sjukpenning')
expect(result[0]!.isDubbeldag).toBe(false)
expect(result[1]!.dayType).toBe('vacation')
expect(result[1]!.isDubbeldag).toBe(true)
})
})
describe('getLeaveWishes', () => {
it('maps snake_case leave wishes to camelCase', async () => {
const rawResponse = [
{
id: 'lw1',
workspace_id: 'ws1',
child_id: 'child1',
name: 'Sommar',
name_message_key: 'wishEditor.presetSommar',
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 6,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
},
]
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getLeaveWishes('ws1')
expect(result).toHaveLength(1)
expect(result[0]!.workspaceId).toBe('ws1')
expect(result[0]!.childId).toBe('child1')
expect(result[0]!.name).toBe('Sommar')
expect(result[0]!.nameMessageKey).toBe('wishEditor.presetSommar')
expect(result[0]!.windowStartMonth).toBe(6)
expect(result[0]!.windowEndDay).toBe(26)
expect(result[0]!.targetWeeks).toBe(6)
expect(result[0]!.recurrence).toBe('yearly')
expect(result[0]!.parentMode).toBe('either')
})
})
describe('getVacationDaysByWorkspace', () => {
it('maps snake_case vacation days to camelCase', async () => {
const rawResponse = [
{
id: 'vd1',
personal_data_uuid: 'pd1',
workspace_id: 'ws1',
year: 2026,
total_days: 25,
saved_days_in: 5,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
},
]
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getVacationDaysByWorkspace('ws1')
expect(result).toHaveLength(1)
expect(result[0]!.personalDataUuid).toBe('pd1')
expect(result[0]!.workspaceId).toBe('ws1')
expect(result[0]!.year).toBe(2026)
expect(result[0]!.totalDays).toBe(25)
expect(result[0]!.savedDaysIn).toBe(5)
})
})
describe('createLeaveWish', () => {
it('sends camelCase request as snake_case and maps the response back', async () => {
const rawResponse = {
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Sommar',
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 6,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Sommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
windowEndDay: 26,
targetWeeks: 6,
recurrence: 'yearly',
firstYear: 2026,
})
const calls = (fetchMock.mock?.calls ?? []) as any[][]
if (calls.length === 0) throw new Error('No fetch calls made')
const [, options] = calls[0]!
const bodyStr = options.body
const body = JSON.parse(bodyStr)
expect(body.window_start_month).toBe(6)
expect(body.window_end_day).toBe(26)
expect(body.target_weeks).toBe(6)
expect(body.parent_mode).toBe('either')
expect(body.priority).toBe(1)
expect(result.windowStartMonth).toBe(6)
expect(result.targetWeeks).toBe(6)
})
it('sends name_message_key for a preset-created wish and maps it back', async () => {
const rawResponse = {
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Jul',
name_message_key: 'wishEditor.seasonJul',
window_start_month: 12,
window_start_day: 1,
window_end_month: 1,
window_end_day: 31,
target_weeks: 4,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Jul',
nameMessageKey: 'wishEditor.seasonJul',
windowStartMonth: 12,
windowStartDay: 1,
windowEndMonth: 1,
windowEndDay: 31,
targetWeeks: 4,
recurrence: 'yearly',
firstYear: 2026,
})
const calls = (fetchMock.mock?.calls ?? []) as any[][]
if (calls.length === 0) throw new Error('No fetch calls made')
const [, options] = calls[0]!
const body = JSON.parse(options.body)
expect(body.name_message_key).toBe('wishEditor.seasonJul')
expect(result.nameMessageKey).toBe('wishEditor.seasonJul')
})
})
describe('getWorkspace', () => {
it('maps snake_case workspace to camelCase', async () => {
const rawResponse = {
id: 'ws1',
name: 'My Workspace',
planning_goal: 'max_time' as const,
created_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getWorkspace('ws1')
expect(result.id).toBe('ws1')
expect(result.name).toBe('My Workspace')
expect(result.planningGoal).toBe('max_time')
expect(result.createdAt).toBe('2026-06-14T17:09:47')
})
})
})
describe('apiFetch (shared request/response handling)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as any
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
})
it('omits the Authorization header when no access token is set', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) })
await api.getTips('ws1')
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers['Authorization']).toBeUndefined()
})
it('attaches the Authorization header when an access token is set', async () => {
api.setAccessToken('my-token')
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) })
await api.getTips('ws1')
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/tips/ws1')
const headers = options.headers as Record<string, string>
expect(headers['Authorization']).toBe('Bearer my-token')
})
it('returns undefined for a 204 No Content response', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
const result = await api.deletePlannedDay('pd1')
expect(result).toBeUndefined()
})
it('throws an ApiRequestError using a string `detail` field from the error body', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({ detail: 'Invalid payload' }),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 400,
error: { code: '400', message: 'Invalid payload' },
})
})
it('joins a FastAPI-style array `detail` field into a single message', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({
detail: [{ msg: 'field required' }, { msg: 'must be positive' }],
}),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 422,
error: { code: '422', message: 'field required; must be positive' },
})
})
it('stringifies an array `detail` entry that has no `msg` field', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({ detail: [{ loc: ['body', 'email'] }] }),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 422,
error: { message: JSON.stringify({ loc: ['body', 'email'] }) },
})
})
it('falls back to the HTTP statusText when the error body has neither `detail` nor `message`', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 409,
statusText: 'Conflict',
json: async () => ({ code: 'duplicate' }),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 409,
error: { code: 'duplicate', message: 'Conflict' },
})
})
it('falls back to `code`/`message` fields when there is no `detail`', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 409,
statusText: 'Conflict',
json: async () => ({ code: 'duplicate', message: 'Already exists' }),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 409,
error: { code: 'duplicate', message: 'Already exists' },
})
})
it('falls back to the HTTP status and statusText when the error body is not JSON', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => {
throw new SyntaxError('Unexpected end of input')
},
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 500,
error: { code: '500', message: 'Internal Server Error' },
})
})
it('rejects with an instance of ApiRequestError', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({ detail: 'nope' }),
})
try {
await api.getTips('ws1')
expect.unreachable('expected getTips to throw')
} catch (err) {
expect(err).toBeInstanceOf(ApiRequestError)
}
})
})
describe('endpoint functions (representative coverage)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as any
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
})
function lastRequestBody(): Record<string, unknown> {
const calls = fetchMock.mock.calls as any[][]
const options = calls[calls.length - 1]![1] as RequestInit
return JSON.parse(options.body as string) as Record<string, unknown>
}
function lastRequest(): { url: string; method?: string | undefined } {
const calls = fetchMock.mock.calls as any[][]
const [url, options] = calls[calls.length - 1]!
return { url, method: (options as RequestInit | undefined)?.method }
}
it('register sends snake_case payload and returns the token response', async () => {
const tokenResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tokenResponse })
const result = await api.register({
email: 'a@b.com',
password: 'secret',
displayName: 'Anna',
})
expect(lastRequest()).toEqual({ url: '/api/auth/register', method: 'POST' })
expect(lastRequestBody()).toEqual({
email: 'a@b.com',
password: 'secret',
display_name: 'Anna',
})
expect(result).toEqual(tokenResponse)
})
it('login posts credentials and returns the token response', async () => {
const tokenResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tokenResponse })
await api.login({ email: 'a@b.com', password: 'secret' })
expect(lastRequest()).toEqual({ url: '/api/auth/login', method: 'POST' })
expect(lastRequestBody()).toEqual({ email: 'a@b.com', password: 'secret' })
})
it('logout and refreshToken hit their auth endpoints', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
await api.logout()
expect(lastRequest()).toEqual({ url: '/api/auth/logout', method: 'POST' })
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ access_token: 'tok', token_type: 'bearer', expires_in: 3600 }),
})
await api.refreshToken()
expect(lastRequest()).toEqual({ url: '/api/auth/refresh', method: 'POST' })
})
it('getUserContext fetches the auth context', async () => {
const context = {
personal_data_uuid: 'pd1',
workspace_id: 'ws1',
parent_name: 'Anna',
children: [],
}
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => context })
const result = await api.getUserContext()
expect(lastRequest()).toEqual({ url: '/api/auth/context', method: undefined })
expect(result).toEqual(context)
})
it('createChild posts the child payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'c1', name: 'Child' }),
})
const result = await api.createChild({
workspace_id: 'ws1',
name: 'Child',
birth_date: '2026-01-01',
})
expect(lastRequest()).toEqual({ url: '/api/children', method: 'POST' })
expect(result).toEqual({ id: 'c1', name: 'Child' })
})
it('getChild maps a raw child, defaulting a null created_at to an empty string', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'c1',
workspace_id: 'ws1',
name: 'Child',
birth_date: '2026-01-01',
preschool_start_date: null,
preschool_planning_days_per_year: 0,
created_at: null,
}),
})
const result = await api.getChild('c1')
expect(result).toEqual({
id: 'c1',
workspaceId: 'ws1',
name: 'Child',
birthDate: '2026-01-01',
preschoolStartDate: null,
preschoolPlanningDaysPerYear: 0,
createdAt: '',
})
})
it('createWorkspace posts the workspace name', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'ws1',
name: 'Home',
planning_goal: 'balanced',
planning_mode: 'solo',
created_at: '2026-01-01',
}),
})
await api.createWorkspace({ name: 'Home' })
expect(lastRequest()).toEqual({ url: '/api/workspace', method: 'POST' })
expect(lastRequestBody()).toEqual({ name: 'Home' })
})
it('updateWorkspace sends the mapped snake_case fields and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'ws1',
name: 'New name',
planning_goal: 'max_money',
planning_mode: 'couple',
created_at: '2026-01-01',
}),
})
const result = await api.updateWorkspace('ws1', {
name: 'New name',
planningGoal: 'max_money',
planningMode: 'couple',
})
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1', method: 'PUT' })
expect(lastRequestBody()).toEqual({
name: 'New name',
planning_goal: 'max_money',
planning_mode: 'couple',
})
expect(result.planningGoal).toBe('max_money')
expect(result.planningMode).toBe('couple')
})
it('addPartner posts the display name', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
uuid: 'pd2',
name: 'Partner',
salary_type: 'monthly',
employment_status: 'employed',
}),
})
await api.addPartner('ws1', 'Partner')
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents', method: 'POST' })
expect(lastRequestBody()).toEqual({ display_name: 'Partner' })
})
it('upsertVacationDays defaults savedDaysIn to 0 when omitted', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'vd1',
personal_data_uuid: 'pd1',
workspace_id: 'ws1',
year: 2026,
total_days: 25,
saved_days_in: 0,
created_at: null,
updated_at: null,
}),
})
await api.upsertVacationDays({
personalDataUuid: 'pd1',
workspaceId: 'ws1',
year: 2026,
totalDays: 25,
})
expect(lastRequestBody()).toMatchObject({ saved_days_in: 0 })
})
it('saveManualFetch applies its documented defaults for omitted optional fields', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'pf1',
personal_data_uuid: 'pd1',
workspace_id: 'ws1',
child_id: 'child1',
source: 'manual',
fetched_at: '2026-01-01',
total_days: 480,
days_used: 0,
days_left: 480,
reserved_days_total: 90,
reserved_days_left: 90,
transferable_days_left: 100,
dubbeldagar_left: 60,
tier_data: {
sjukpenning_total: 390,
sjukpenning_used: 0,
sjukpenning_left: 390,
lagsta_niva_total: 90,
lagsta_niva_used: 0,
lagsta_niva_left: 90,
},
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
await api.saveManualFetch({
personalDataUuid: 'pd1',
workspaceId: 'ws1',
childId: 'child1',
totalDays: 480,
daysUsed: 0,
reservedDaysLeft: 90,
transferableDaysLeft: 100,
sjukpenningUsed: 0,
lagstaNivaUsed: 0,
})
expect(lastRequestBody()).toMatchObject({
reserved_days_total: 90,
dubbeldagar_left: 60,
sjukpenning_total: 390,
lagsta_niva_total: 90,
})
})
it('updateParentSettings PUTs the settings payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
uuid: 'pd1',
name: 'Anna',
salary_type: 'hourly',
employment_status: 'employed',
}),
})
await api.updateParentSettings('pd1', { salary_type: 'hourly' })
expect(lastRequest()).toEqual({ url: '/api/settings/parent/pd1', method: 'PUT' })
expect(lastRequestBody()).toEqual({ salary_type: 'hourly' })
})
it('updateChildSettings PUTs and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'c1',
workspace_id: 'ws1',
name: 'Child',
birth_date: '2026-02-01',
preschool_start_date: null,
preschool_planning_days_per_year: 15,
created_at: '2026-01-01',
}),
})
const result = await api.updateChildSettings('c1', { birth_date: '2026-02-01' })
expect(lastRequest()).toEqual({ url: '/api/settings/child/c1', method: 'PUT' })
expect(result.birthDate).toBe('2026-02-01')
expect(result.preschoolPlanningDaysPerYear).toBe(15)
})
it('deleteLeaveWish issues a DELETE and returns undefined on 204', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
const result = await api.deleteLeaveWish('lw1')
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1', method: 'DELETE' })
expect(result).toBeUndefined()
})
it('updateLeaveWish only sends fields present in the payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Sommar',
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 8,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.updateLeaveWish('lw1', { targetWeeks: 8 })
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1', method: 'PUT' })
expect(lastRequestBody()).toEqual({ target_weeks: 8 })
expect(result.targetWeeks).toBe(8)
})
it('updateLeaveWish sends name_message_key: null to explicitly clear it on rename', async () => {
// Mirrors what WishEditor sends the moment a user edits a preset wish's
// name — the request must carry an explicit `null`, not omit the field
// (omitting it would mean "leave unchanged" server-side).
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Vinterresa',
name_message_key: null,
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 6,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.updateLeaveWish('lw1', {
name: 'Vinterresa',
nameMessageKey: null,
})
expect(lastRequestBody()).toEqual({ name: 'Vinterresa', name_message_key: null })
expect(result.name).toBe('Vinterresa')
expect(result.nameMessageKey).toBeNull()
})
it('updateLeaveWish sends every provided field, snake_cased', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw1',
workspace_id: 'ws1',
child_id: 'c1',
name: 'Höst',
window_start_month: 9,
window_start_day: 1,
window_end_month: 10,
window_end_day: 1,
target_weeks: 4,
recurrence: 'once',
first_year: 2027,
last_year: 2027,
priority: 2,
parent_mode: 'specific',
parent_personal_data_uuid: 'pd1',
goal_override: 'max_time',
window_mode: 'fixed',
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
await api.updateLeaveWish('lw1', {
childId: 'c1',
name: 'Höst',
windowStartMonth: 9,
windowStartDay: 1,
windowEndMonth: 10,
windowEndDay: 1,
targetWeeks: 4,
recurrence: 'once',
firstYear: 2027,
lastYear: 2027,
priority: 2,
parentMode: 'specific',
parentPersonalDataUuid: 'pd1',
goalOverride: 'max_time',
})
expect(lastRequestBody()).toEqual({
child_id: 'c1',
name: 'Höst',
window_start_month: 9,
window_start_day: 1,
window_end_month: 10,
window_end_day: 1,
target_weeks: 4,
recurrence: 'once',
first_year: 2027,
last_year: 2027,
priority: 2,
parent_mode: 'specific',
parent_personal_data_uuid: 'pd1',
goal_override: 'max_time',
})
})
// PLANNING_ENGINE_SPEC.md §10.1/§10.5 — window_mode round-trips through the
// API client like every other leave-wish field: camelCase on the wire out,
// snake_case in the request body, and back to camelCase on the response.
it('createLeaveWish sends window_mode: flexible and maps it back on the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw2',
workspace_id: 'ws1',
child_id: null,
name: 'Vår',
window_start_month: 3,
window_start_day: 1,
window_end_month: 5,
window_end_day: 31,
target_weeks: 1,
recurrence: 'yearly',
first_year: 2027,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
window_mode: 'flexible',
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Vår',
windowStartMonth: 3,
windowStartDay: 1,
windowEndMonth: 5,
windowEndDay: 31,
targetWeeks: 1,
recurrence: 'yearly',
firstYear: 2027,
windowMode: 'flexible',
})
expect(lastRequestBody()).toMatchObject({ window_mode: 'flexible' })
expect(result.windowMode).toBe('flexible')
})
it('createLeaveWish defaults window_mode to fixed when omitted', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Sommar',
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 6,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
window_mode: 'fixed',
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Sommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
windowEndDay: 26,
targetWeeks: 6,
recurrence: 'yearly',
firstYear: 2026,
})
expect(lastRequestBody()).toMatchObject({ window_mode: 'fixed' })
})
it('updateLeaveWish only sends window_mode when explicitly provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Sommar',
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 6,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
window_mode: 'flexible',
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.updateLeaveWish('lw1', { windowMode: 'flexible' })
expect(lastRequestBody()).toEqual({ window_mode: 'flexible' })
expect(result.windowMode).toBe('flexible')
})
it('proposePlan posts the workspace id and maps the nested response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
days: [],
outcomes: [
{
wish_id: 'lw1',
wish_name: 'Sommar',
year: 2026,
start_date: '2026-06-15',
end_date: '2026-07-26',
weeks_wished: 6,
weeks_funded: 6,
shortfall_days: 0,
strategy: 'parent_swap',
message_key: 'planOutcomes.windowExplanation',
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
cost_breakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
remedies: [],
},
],
warnings: [
{
kind: 'days_lost_at_age_4',
message_key: 'planWarnings.days_lost_at_age_4',
data: { expiring_days: 10 },
},
],
pool_balances: [
{
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
sjukpenning_left: 100,
lagsta_left: 20,
dubbeldagar_left: 60,
},
],
vacation_balances: [
{
parent_personal_data_uuid: 'pd1',
year: 2026,
total_days: 25,
saved_days_in: 0,
already_used: 19,
is_assumed: true,
},
],
surplus_advice: [
{
kind: 'wish_more_leave',
message_key: 'planSurplusAdvice.wish_more_leave',
days_involved: 10,
data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 },
},
],
age4_projections: [
{
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
fourth_birthday: '2030-01-01',
projected_left_at_b4: 106,
savable_after_b4: 96,
expiring_at_b4: 10,
},
],
}),
})
const result = await api.proposePlan('ws1')
expect(lastRequest()).toEqual({ url: '/api/plan/propose', method: 'POST' })
expect(lastRequestBody()).toEqual({ workspace_id: 'ws1' })
expect(result.outcomes[0]!.wishName).toBe('Sommar')
expect(result.outcomes[0]!.costBreakdown).toEqual({
free: 12,
vacation: 19,
sjukpenning: 11,
lagsta: 0,
})
expect(result.outcomes[0]!.messageKey).toBe('planOutcomes.windowExplanation')
expect(result.outcomes[0]!.data).toEqual({
wish_name: 'Sommar',
year: 2026,
weeks_wished: 6,
weeks_funded: 6,
shortfall_days: 0,
})
expect(result.warnings[0]!.kind).toBe('days_lost_at_age_4')
expect(result.warnings[0]!.messageKey).toBe('planWarnings.days_lost_at_age_4')
expect(result.poolBalances[0]!.sjukpenningLeft).toBe(100)
expect(result.vacationBalances[0]!.alreadyUsed).toBe(19)
expect(result.vacationBalances[0]!.isAssumed).toBe(true)
expect(result.surplusAdvice[0]!.kind).toBe('wish_more_leave')
expect(result.surplusAdvice[0]!.messageKey).toBe('planSurplusAdvice.wish_more_leave')
expect(result.surplusAdvice[0]!.daysInvolved).toBe(10)
expect(result.age4Projections[0]!.expiringAtB4).toBe(10)
expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01')
})
it('proposePlan defaults a missing cost breakdown field to 0', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
days: [],
outcomes: [
{
wish_id: 'lw1',
wish_name: 'Sommar',
year: 2026,
start_date: '2026-06-15',
end_date: '2026-07-26',
weeks_wished: 6,
weeks_funded: 6,
shortfall_days: 0,
strategy: 'parent_swap',
message_key: 'planOutcomes.windowExplanation',
data: {},
// The backend can omit zero-valued keys entirely — the mapper
// must default each one rather than surfacing `undefined`.
cost_breakdown: {},
remedies: [],
},
],
warnings: [],
pool_balances: [],
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
}),
})
const result = await api.proposePlan('ws1')
expect(result.outcomes[0]!.costBreakdown).toEqual({
free: 0,
vacation: 0,
sjukpenning: 0,
lagsta: 0,
})
})
it('applyPlan maps rejections to messageKey/data (§3.5)', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
accepted: false,
created_count: 0,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 0,
collisions: [],
rejections: [
{
message_key: 'planApply.rejection.insufficientBalance',
data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
},
],
created_days: [],
}),
})
const result = await api.applyPlan('ws1', [], false)
expect(result.accepted).toBe(false)
expect(result.rejections).toEqual([
{
messageKey: 'planApply.rejection.insufficientBalance',
data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
},
])
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => [
{
uuid: 'pd1',
workspace_id: 'ws1',
name: 'Alex',
salary_type: 'monthly',
employment_status: 'employed',
created_at: '2026-01-01',
},
],
})
const result = await api.listWorkspaceMembers('ws1')
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/members', method: undefined })
expect(result[0]!.name).toBe('Alex')
expect(result[0]!.salaryType).toBe('monthly')
})
it('listWorkspaceMembers defaults a null created_at to an empty string', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => [
{
uuid: 'pd1',
workspace_id: 'ws1',
name: 'Alex',
salary_type: 'monthly',
employment_status: 'employed',
created_at: null,
},
],
})
const result = await api.listWorkspaceMembers('ws1')
expect(result[0]!.createdAt).toBe('')
})
it('getTips fetches tips for a workspace', async () => {
const tips = [{ id: 't1', severity: 'info', messageKey: 'k', data: {} }]
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tips })
const result = await api.getTips('ws1')
expect(lastRequest()).toEqual({ url: '/api/tips/ws1', method: undefined })
expect(result).toEqual(tips)
})
it('getPlannedDays omits the query string when no childId is given', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [] })
await api.getPlannedDays()
expect(lastRequest()).toEqual({ url: '/api/planned-days', method: undefined })
})
it('createPlannedDay posts the payload and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'pld1',
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pd1',
date: '2026-06-01',
day_type: 'vacation',
is_dubbeldag: false,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.createPlannedDay({
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pd1',
date: '2026-06-01',
day_type: 'vacation',
})
expect(lastRequest()).toEqual({ url: '/api/planned-days', method: 'POST' })
expect(result.dayType).toBe('vacation')
})
it('updatePlannedDay PUTs a partial payload and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'pld1',
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pd1',
date: '2026-06-01',
day_type: 'sjukpenning',
is_dubbeldag: true,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.updatePlannedDay('pld1', { is_dubbeldag: true })
expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'PUT' })
expect(result.isDubbeldag).toBe(true)
})
it('deletePlannedDay issues a DELETE', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
await api.deletePlannedDay('pld1')
expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'DELETE' })
})
it('startScraperSession, pollScraperSession and cancelScraperSession hit the fetch-data endpoints', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
session_id: 'sess1',
status: 'pending',
workspace_id: 'ws1',
personal_data_uuid: 'pd1',
child_id: 'child1',
}),
})
await api.startScraperSession({ workspace_id: 'ws1' })
expect(lastRequest()).toEqual({ url: '/api/fetch-data/start', method: 'POST' })
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'done' }) })
await api.pollScraperSession('sess1')
expect(lastRequest()).toEqual({
url: '/api/fetch-data/poll?session_id=sess1',
method: undefined,
})
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
await api.cancelScraperSession('sess1')
expect(lastRequest()).toEqual({ url: '/api/fetch-data/cancel', method: 'POST' })
expect(lastRequestBody()).toEqual({ session_id: 'sess1' })
})
it('getCalendarDays builds the query string and maps the response to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
start_year: 2026,
end_year: 2029,
min_year: 2026,
max_year: 2035,
days: [
{
date: '2026-01-01',
name: 'Nyårsdagen',
day_type: 'red_day',
is_override: false,
override_is_working_day: null,
is_free: true,
},
{
date: '2026-04-05',
name: null,
day_type: 'klamdag',
is_override: true,
override_is_working_day: false,
is_free: true,
},
],
}),
})
const result = await api.getCalendarDays('ws1', 2026, 4)
expect(lastRequest()).toEqual({
url: '/api/calendar/days?workspace_id=ws1&start_year=2026&years=4',
method: undefined,
})
expect(result.startYear).toBe(2026)
expect(result.endYear).toBe(2029)
expect(result.minYear).toBe(2026)
expect(result.maxYear).toBe(2035)
expect(result.days).toHaveLength(2)
expect(result.days[0]).toEqual({
date: '2026-01-01',
name: 'Nyårsdagen',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
})
expect(result.days[1]!.dayType).toBe('klamdag')
expect(result.days[1]!.isOverride).toBe(true)
expect(result.days[1]!.overrideIsWorkingDay).toBe(false)
})
it('getCalendarDays defaults years to 4 when omitted', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ start_year: 2026, end_year: 2029, min_year: 2026, max_year: 2035, days: [] }),
})
await api.getCalendarDays('ws1', 2026)
expect(lastRequest()).toEqual({
url: '/api/calendar/days?workspace_id=ws1&start_year=2026&years=4',
method: undefined,
})
})
})
describe('apiFetch — structured {message_key, data} errors', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as any
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
})
it('captures message_key/data in error.details instead of leaking the raw key into message', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({
detail: { message_key: 'workspace.parents.remove.hasDependentData', data: { blockers: ['leave_wishes'] } },
}),
})
try {
await api.getTips('ws1')
expect.unreachable('expected getTips to throw')
} catch (err) {
expect(err).toBeInstanceOf(ApiRequestError)
const apiErr = err as ApiRequestError
expect(apiErr.status).toBe(422)
expect(apiErr.error.details).toEqual({
messageKey: 'workspace.parents.remove.hasDependentData',
data: { blockers: ['leave_wishes'] },
})
}
})
it('defaults data to an empty object when the structured error omits it', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ detail: { message_key: 'workspace.notFound' } }),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 404,
error: { details: { messageKey: 'workspace.notFound', data: {} } },
})
})
it('still handles a plain string `detail` the old way (not misread as structured)', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({ detail: 'Invalid payload' }),
})
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 400,
error: { code: '400', message: 'Invalid payload' },
})
})
})
describe('workspace parent management (rename/remove)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as any
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
})
function lastRequestBody(): Record<string, unknown> {
const calls = fetchMock.mock.calls as any[][]
const options = calls[calls.length - 1]![1] as RequestInit
return JSON.parse(options.body as string) as Record<string, unknown>
}
function lastRequest(): { url: string; method?: string | undefined } {
const calls = fetchMock.mock.calls as any[][]
const [url, options] = calls[calls.length - 1]!
return { url, method: (options as RequestInit | undefined)?.method }
}
it('renameWorkspaceParent PATCHes the new display name and maps the response to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
uuid: 'pd2',
workspace_id: 'ws1',
name: 'Samuel',
salary_type: 'monthly',
employment_status: 'employed',
created_at: '2026-01-01',
}),
})
const result = await api.renameWorkspaceParent('ws1', 'pd2', 'Samuel')
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'PATCH' })
expect(lastRequestBody()).toEqual({ display_name: 'Samuel' })
expect(result.name).toBe('Samuel')
expect(result.uuid).toBe('pd2')
})
it('renameWorkspaceParent surfaces an invalidName refusal as a structured error', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({ detail: { message_key: 'workspace.parents.invalidName', data: {} } }),
})
await expect(api.renameWorkspaceParent('ws1', 'pd2', '')).rejects.toMatchObject({
status: 422,
error: { details: { messageKey: 'workspace.parents.invalidName' } },
})
})
it('removeWorkspaceParent DELETEs and resolves to undefined on 204', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
const result = await api.removeWorkspaceParent('ws1', 'pd2')
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'DELETE' })
expect(result).toBeUndefined()
})
it('removeWorkspaceParent surfaces a hasDependentData refusal with its blockers', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({
detail: {
message_key: 'workspace.parents.remove.hasDependentData',
data: { blockers: ['leave_wishes', 'vacation_days'] },
},
}),
})
await expect(api.removeWorkspaceParent('ws1', 'pd2')).rejects.toMatchObject({
status: 422,
error: {
details: {
messageKey: 'workspace.parents.remove.hasDependentData',
data: { blockers: ['leave_wishes', 'vacation_days'] },
},
},
})
})
})