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