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 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 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 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 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 beforeEach(() => { fetchMock = vi.fn() globalThis.fetch = fetchMock as any }) afterEach(() => { vi.clearAllMocks() api.setAccessToken(null) }) function lastRequestBody(): Record { 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 } 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 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 beforeEach(() => { fetchMock = vi.fn() globalThis.fetch = fetchMock as any }) afterEach(() => { vi.clearAllMocks() api.setAccessToken(null) }) function lastRequestBody(): Record { 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 } 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'] }, }, }, }) }) })