dc4c0a5edd
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
1665 lines
49 KiB
TypeScript
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'] },
|
|
},
|
|
},
|
|
})
|
|
})
|
|
})
|