Files
F-r-ldraledigplannerare/frontend/src/services/api.test.ts
T
jacobh2 6d92b87bf3 Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:53:25 +02:00

2175 lines
64 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 typeof fetch
})
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,
savable_cap_at_b4: 96,
},
],
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)
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
})
})
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')
if (!result) throw new Error('expected a mapped ParentalLeaveFetch, got null')
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)
})
it('returns null when nothing has been saved yet (the backend returns 200 with a null body)', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => null,
})
const result = await api.getParentalLeave('pd1', 'child1')
expect(result).toBeNull()
})
})
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,
first_year: 2026,
series_id: 'series-1',
last_year: 2030,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: 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]!.seriesId).toBe('series-1')
expect(result[0]!.lastYear).toBe(2030)
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('getTips', () => {
it('maps snake_case tips (incl. anchor fields) to camelCase', async () => {
const rawResponse = [
{
id: 'preserve_for_age_4:child-1',
severity: 'warning' as const,
message_key: 'tips.preserve_for_age_4',
data: { parent_name: 'Alice', expiring_days: 12, child_id: 'child-1' },
anchor_date: '2030-01-01',
anchor_range: null,
},
{
id: 'wrap_weekend_with_vacation:child-1',
severity: 'info' as const,
message_key: 'tips.wrap_weekend_with_vacation',
data: { parent_name: 'Bob' },
anchor_date: null,
anchor_range: ['2026-07-03', '2026-07-06'] as [string, string],
},
]
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.getTips('ws1')
expect(result).toHaveLength(2)
expect(result[0]!.id).toBe('preserve_for_age_4:child-1')
expect(result[0]!.messageKey).toBe('tips.preserve_for_age_4')
expect(result[0]!.anchorDate).toBe('2030-01-01')
expect(result[0]!.anchorRange).toBeNull()
expect(result[0]!.data.expiring_days).toBe(12)
expect(result[1]!.anchorDate).toBeNull()
expect(result[1]!.anchorRange).toEqual(['2026-07-03', '2026-07-06'])
})
})
describe('dismissTip', () => {
it('posts tip_id to the dismissals endpoint', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 204,
json: async () => null,
})
await api.dismissTip('ws1', 'preserve_for_age_4:child-1')
const calls = vi.mocked(fetchMock).mock.calls
if (calls.length === 0) throw new Error('No fetch calls made')
const [url, options] = calls[0]!
const body = JSON.parse(options.body)
expect(String(url)).toContain('/tips/ws1/dismissals')
expect(options.method).toBe('POST')
expect(body.tip_id).toBe('preserve_for_age_4:child-1')
})
})
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,
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 = vi.mocked(fetchMock).mock.calls
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,
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 = vi.mocked(fetchMock).mock.calls
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')
})
it('sends target_days for a calendar-drawn period and maps it back', async () => {
const rawResponse = {
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Ny period',
window_start_month: 6,
window_start_day: 1,
window_end_month: 6,
window_end_day: 5,
target_weeks: 1,
target_days: 5,
recurrence: 'once',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'specific',
parent_personal_data_uuid: 'pd1',
window_mode: 'fixed',
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: 'Ny period',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
windowEndDay: 5,
targetWeeks: 1,
targetDays: 5,
recurrence: 'once',
firstYear: 2026,
parentMode: 'specific',
parentPersonalDataUuid: 'pd1',
windowMode: 'fixed',
})
const calls = vi.mocked(fetchMock).mock.calls
if (calls.length === 0) throw new Error('No fetch calls made')
const [, options] = calls[0]!
const body = JSON.parse(options.body)
expect(body.target_days).toBe(5)
expect(result.targetDays).toBe(5)
})
})
describe('getWorkspace', () => {
it('maps snake_case workspace to camelCase', async () => {
const rawResponse = {
id: 'ws1',
name: 'My Workspace',
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.createdAt).toBe('2026-06-14T17:09:47')
})
})
describe('listDayOverridesByWorkspace', () => {
it('maps snake_case day overrides to camelCase', async () => {
// Regression test: toDayOverride was the only mapper in this file with
// no snake_case→camelCase test — this is the exact shape of bug that
// shipped for getTips (see the getTips test below): a missing/wrong
// mapping surfaces only at runtime, never in a suite that mocks the
// module and hand-rolls already-camelCase fixtures.
const rawResponse = [
{
id: 'do-1',
workspace_id: 'ws1',
date: '2026-12-24',
is_working_day: true,
note: 'Jobbade på juldagen',
created_at: '2026-01-01T00:00:00',
updated_at: '2026-01-02T00:00:00',
},
]
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.listDayOverridesByWorkspace('ws1')
expect(result).toEqual([
{
id: 'do-1',
workspaceId: 'ws1',
date: '2026-12-24',
isWorkingDay: true,
note: 'Jobbade på juldagen',
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-02T00:00:00',
},
])
})
it('maps a null note/created_at/updated_at through unchanged', async () => {
const rawResponse = [
{
id: 'do-2',
workspace_id: 'ws1',
date: '2026-06-20',
is_working_day: false,
note: null,
created_at: null,
updated_at: null,
},
]
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.listDayOverridesByWorkspace('ws1')
expect(result[0]!.note).toBeNull()
expect(result[0]!.createdAt).toBeNull()
expect(result[0]!.updatedAt).toBeNull()
expect(result[0]!.isWorkingDay).toBe(false)
})
})
})
describe('apiFetch (shared request/response handling)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as typeof fetch
})
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 typeof fetch
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
})
function lastRequestBody(): Record<string, unknown> {
const calls = vi.mocked(fetchMock).mock.calls
const options = calls[calls.length - 1]![1] as RequestInit | undefined
return JSON.parse((options?.body ?? '') as string) as Record<string, unknown>
}
function lastRequest(): { url: string; method?: string | undefined } {
const calls = vi.mocked(fetchMock).mock.calls
const call = calls[calls.length - 1]!
const url = (call[0] ?? '') as string
const options = call[1] as RequestInit | undefined
return { url, method: options?.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',
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',
created_at: '2026-01-01',
}),
})
const result = await api.updateWorkspace('ws1', {
name: 'New name',
})
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1', method: 'PUT' })
expect(lastRequestBody()).toEqual({
name: 'New name',
})
expect(result.name).toBe('New name')
})
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 })
})
function makeManualFetchResponse() {
return {
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',
}
}
it('saveManualFetch omits optional fields entirely when not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => makeManualFetchResponse(),
})
await api.saveManualFetch({
personalDataUuid: 'pd1',
workspaceId: 'ws1',
childId: 'child1',
sjukpenningLeft: 195,
lagstaNivaLeft: 45,
dubbeldagarLeft: 60,
})
const body = lastRequestBody()
expect(body).toMatchObject({
personal_data_uuid: 'pd1',
workspace_id: 'ws1',
child_id: 'child1',
sjukpenning_left: 195,
lagsta_niva_left: 45,
dubbeldagar_left: 60,
})
expect(body).not.toHaveProperty('reserved_days_left')
expect(body).not.toHaveProperty('total_days')
expect(body).not.toHaveProperty('source')
})
it('saveManualFetch sends optional fields, including a null reservedDaysLeft, when provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => makeManualFetchResponse(),
})
await api.saveManualFetch({
personalDataUuid: 'pd1',
workspaceId: 'ws1',
childId: 'child1',
sjukpenningLeft: 195,
lagstaNivaLeft: 45,
dubbeldagarLeft: 60,
reservedDaysLeft: null,
totalDays: 240,
source: 'deduced',
})
expect(lastRequestBody()).toMatchObject({
reserved_days_left: null,
total_days: 240,
source: 'deduced',
})
})
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?scope=occurrence',
method: 'DELETE',
})
expect(result).toBeUndefined()
})
it('deleteLeaveWish widens to the whole series when asked', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
await api.deleteLeaveWish('lw1', 'series')
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1?scope=series', method: 'DELETE' })
})
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,
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?scope=occurrence',
method: 'PUT',
})
expect(lastRequestBody()).toEqual({ target_weeks: 8 })
expect(result.targetWeeks).toBe(8)
})
it('updateLeaveWish carries an explicit series scope in the query string', 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,
first_year: 2026,
series_id: 'series-1',
last_year: 2030,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
await api.updateLeaveWish('lw1', { targetWeeks: 8 }, 'series')
expect(lastRequest()).toEqual({ url: '/api/leave-wishes/lw1?scope=series', method: 'PUT' })
})
it('createLeaveWish sends series_id so a removed year can rejoin its series', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw9',
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,
first_year: 2028,
series_id: 'series-1',
last_year: 2030,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
window_mode: 'fixed',
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Sommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
windowEndDay: 26,
targetWeeks: 6,
recurrence: 'once',
firstYear: 2028,
seriesId: 'series-1',
})
expect(lastRequestBody()).toMatchObject({ series_id: 'series-1', recurrence: 'once' })
expect(result.seriesId).toBe('series-1')
})
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,
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',
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',
})
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',
})
})
// 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,
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,
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,
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',
wish_name_message_key: 'wishEditor.presetSommar',
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',
wish_name_message_key: 'wishEditor.presetSommar',
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]!.wishNameMessageKey).toBe('wishEditor.presetSommar')
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',
wish_name_message_key: 'wishEditor.presetSommar',
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('syncPlan posts the workspace id and maps the nested proposal/applyResult/changes', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
proposal: {
days: [],
outcomes: [],
warnings: [],
pool_balances: [],
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
},
apply_result: {
accepted: true,
created_count: 2,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 1,
collisions: [
{
date: '2026-06-15',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
existing_day_type: 'vacation',
proposed_day_type: 'sjukpenning',
},
],
rejections: [
{
message_key: 'planApply.rejection.insufficientBalance',
data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
},
],
created_days: [],
},
changes: [
{
kind: 'retyped',
date: '2028-06-20',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: 'vacation',
new_day_type: 'sjukpenning',
old_is_dubbeldag: false,
new_is_dubbeldag: false,
old_wish_id: 'lw1',
new_wish_id: 'lw1',
},
{
kind: 'added',
date: '2028-06-21',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: null,
new_day_type: 'vacation',
old_is_dubbeldag: null,
new_is_dubbeldag: false,
old_wish_id: null,
new_wish_id: 'lw1',
},
],
}),
})
const result = await api.syncPlan('ws1')
expect(lastRequest()).toEqual({ url: '/api/plan/sync', method: 'POST' })
expect(lastRequestBody()).toEqual({ workspace_id: 'ws1' })
expect(result.applyResult.createdCount).toBe(2)
expect(result.applyResult.replacedCount).toBe(1)
expect(result.applyResult.collisions).toEqual([
{
date: '2026-06-15',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
existingDayType: 'vacation',
proposedDayType: 'sjukpenning',
},
])
expect(result.applyResult.rejections).toEqual([
{
messageKey: 'planApply.rejection.insufficientBalance',
data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
},
])
expect(result.changes).toHaveLength(2)
expect(result.changes[0]).toEqual({
kind: 'retyped',
date: '2028-06-20',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: 'vacation',
newDayType: 'sjukpenning',
oldIsDubbeldag: false,
newIsDubbeldag: false,
oldWishId: 'lw1',
newWishId: 'lw1',
})
expect(result.changes[1]).toEqual({
kind: 'added',
date: '2028-06-21',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: null,
newDayType: 'vacation',
oldIsDubbeldag: null,
newIsDubbeldag: false,
oldWishId: null,
newWishId: 'lw1',
})
})
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 and maps snake_case to camelCase', async () => {
// Regression test: the backend's real wire shape is snake_case
// (`message_key`, not `messageKey`) — see tips_service.py's `Tip`
// dataclass. A bare pass-through here left `tip.messageKey` undefined
// at runtime, which rendered every tip card blank.
const rawTips = [
{ id: 't1', severity: 'info', message_key: 'tips.use_vacation_before_expiry', data: { foo: 'bar' } },
]
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => rawTips })
const result = await api.getTips('ws1')
expect(lastRequest()).toEqual({ url: '/api/tips/ws1', method: undefined })
expect(result).toEqual([
{ id: 't1', severity: 'info', messageKey: 'tips.use_vacation_before_expiry', data: { foo: 'bar' } },
])
})
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 — 401 refresh-and-retry (S1)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as typeof fetch
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
api.setUnauthorizedHandler(null)
})
function unauthorizedResponse(detail = 'Unauthorized') {
return { ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({ detail }) }
}
function tokenResponse(access_token: string) {
return {
ok: true,
status: 200,
json: async () => ({ access_token, token_type: 'bearer', expires_in: 3600 }),
}
}
it('refreshes the token once and retries the original request after a 401', async () => {
api.setAccessToken('old-token')
fetchMock
.mockResolvedValueOnce(unauthorizedResponse())
.mockResolvedValueOnce(tokenResponse('new-token'))
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => [] })
const result = await api.getTips('ws1')
expect(result).toEqual([])
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock.mock.calls[1]![0]).toBe('/api/auth/refresh')
const [retryUrl, retryOptions] = fetchMock.mock.calls[2] as [string, RequestInit]
expect(retryUrl).toBe('/api/tips/ws1')
expect((retryOptions.headers as Record<string, string>)['Authorization']).toBe('Bearer new-token')
})
it('dedupes concurrent 401s from parallel requests into a single refresh call', async () => {
api.setAccessToken('old-token')
fetchMock.mockImplementation(async (url: string, options?: RequestInit) => {
if (url === '/api/auth/refresh') {
return tokenResponse('new-token')
}
const headers = options?.headers as Record<string, string> | undefined
if (headers?.['Authorization'] === 'Bearer new-token') {
return { ok: true, status: 200, json: async () => [] }
}
return unauthorizedResponse()
})
const [r1, r2] = await Promise.all([api.getTips('ws1'), api.getTips('ws1')])
expect(r1).toEqual([])
expect(r2).toEqual([])
const refreshCalls = fetchMock.mock.calls.filter(([url]) => url === '/api/auth/refresh')
expect(refreshCalls).toHaveLength(1)
})
it('clears the token, notifies the unauthorized handler, and surfaces the original 401 when refresh fails', async () => {
api.setAccessToken('old-token')
const onUnauthorized = vi.fn()
api.setUnauthorizedHandler(onUnauthorized)
fetchMock
.mockResolvedValueOnce(unauthorizedResponse('session expired'))
.mockResolvedValueOnce(unauthorizedResponse('refresh token expired'))
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 401,
error: { message: 'session expired' },
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(onUnauthorized).toHaveBeenCalledTimes(1)
})
it('does not loop when the retried request also 401s', async () => {
api.setAccessToken('old-token')
fetchMock
.mockResolvedValueOnce(unauthorizedResponse())
.mockResolvedValueOnce(tokenResponse('new-token'))
.mockResolvedValueOnce(unauthorizedResponse('still unauthorized'))
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 401,
error: { message: 'still unauthorized' },
})
// Original + refresh + one retry — no second refresh attempt.
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('does not attempt a refresh-and-retry for the auth endpoints themselves', async () => {
fetchMock.mockResolvedValue(unauthorizedResponse())
await expect(api.login({ email: 'a@b.com', password: 'x' })).rejects.toMatchObject({ status: 401 })
await expect(api.refreshToken()).rejects.toMatchObject({ status: 401 })
await expect(api.logout()).rejects.toMatchObject({ status: 401 })
expect(fetchMock).toHaveBeenCalledTimes(3)
})
})
describe('apiFetch — structured {message_key, data} errors', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as typeof fetch
})
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 typeof fetch
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
})
function lastRequestBody(): Record<string, unknown> {
const calls = vi.mocked(fetchMock).mock.calls
const options = calls[calls.length - 1]![1] as RequestInit | undefined
return JSON.parse((options?.body ?? '') as string) as Record<string, unknown>
}
function lastRequest(): { url: string; method?: string | undefined } {
const calls = vi.mocked(fetchMock).mock.calls
const call = calls[calls.length - 1]!
const url = (call[0] ?? '') as string
const options = call[1] as RequestInit | undefined
return { url, method: options?.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'] },
},
},
})
})
})