fe120ee222
ESLint turned `no-explicit-any` off for test files, and 219 casts had accumulated across 12 of them. Two shapes, very different risks: most were `(useWorkspace as any).mockReturnValue(...)`, mere laziness that `vi.mocked()` does properly; the rest cast partial fixtures onto API types, which erases the contract exactly where a test should enforce it. That second kind matters here specifically. This project's recurring bug is a field silently dropped between backend and frontend -- the hand-written mapping in models/plan.py has done it twice, and apiFetch mishandling structured errors was the same shape. A test that casts an incomplete fixture to `any` keeps passing through precisely that change. A typed fixture layer already existed at tests/fixtures/mockData.ts and was imported by exactly zero tests: the right tool was there and invisible, so everyone reached for a cast. It is now a set of `make*(overrides: Partial<T>) => T` factories returning complete, valid objects, split by domain with a barrel. When a type gains a field, one factory fails to compile instead of thirty tests silently passing. With the count at zero the ESLint exemption is deleted, so this cannot regrow. Removing it immediately caught two `}: any` prop stubs that a grep for "as any" had missed; those mocks now use the real components' exported prop types. `as unknown as T` survives in five places, each a test deliberately feeding invalid input (an undefined breakdown, missing tierData, a bogus leave type). CLAUDE.md now documents that as the only accepted escape, and points at the factories. interface PollResponse in useScraperFetch.ts is exported so its shape can be derived rather than duplicated -- pollScraperSession declares Promise<unknown>, so there was no other honest way to type it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
1679 lines
49 KiB
TypeScript
1679 lines
49 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import * as api from './api'
|
|
import type { RawParentalLeaveFetch, RawWorkspaceOverview } from './api'
|
|
import { ApiRequestError } from './errors'
|
|
|
|
describe('API response mappers (snake_case → camelCase)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as 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,
|
|
},
|
|
],
|
|
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 = 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,
|
|
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 = 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')
|
|
})
|
|
})
|
|
|
|
describe('getWorkspace', () => {
|
|
it('maps snake_case workspace to camelCase', async () => {
|
|
const rawResponse = {
|
|
id: 'ws1',
|
|
name: 'My Workspace',
|
|
planning_goal: 'max_time' as const,
|
|
created_at: '2026-06-14T17:09:47',
|
|
}
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => rawResponse,
|
|
})
|
|
|
|
const result = await api.getWorkspace('ws1')
|
|
|
|
expect(result.id).toBe('ws1')
|
|
expect(result.name).toBe('My Workspace')
|
|
expect(result.planningGoal).toBe('max_time')
|
|
expect(result.createdAt).toBe('2026-06-14T17:09:47')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('apiFetch (shared request/response handling)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
api.setAccessToken(null)
|
|
})
|
|
|
|
it('omits the Authorization header when no access token is set', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) })
|
|
|
|
await api.getTips('ws1')
|
|
|
|
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
const headers = options.headers as Record<string, string>
|
|
expect(headers['Authorization']).toBeUndefined()
|
|
})
|
|
|
|
it('attaches the Authorization header when an access token is set', async () => {
|
|
api.setAccessToken('my-token')
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) })
|
|
|
|
await api.getTips('ws1')
|
|
|
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/tips/ws1')
|
|
const headers = options.headers as Record<string, string>
|
|
expect(headers['Authorization']).toBe('Bearer my-token')
|
|
})
|
|
|
|
it('returns undefined for a 204 No Content response', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
|
|
const result = await api.deletePlannedDay('pd1')
|
|
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it('throws an ApiRequestError using a string `detail` field from the error body', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
json: async () => ({ detail: 'Invalid payload' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 400,
|
|
error: { code: '400', message: 'Invalid payload' },
|
|
})
|
|
})
|
|
|
|
it('joins a FastAPI-style array `detail` field into a single message', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({
|
|
detail: [{ msg: 'field required' }, { msg: 'must be positive' }],
|
|
}),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 422,
|
|
error: { code: '422', message: 'field required; must be positive' },
|
|
})
|
|
})
|
|
|
|
it('stringifies an array `detail` entry that has no `msg` field', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({ detail: [{ loc: ['body', 'email'] }] }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 422,
|
|
error: { message: JSON.stringify({ loc: ['body', 'email'] }) },
|
|
})
|
|
})
|
|
|
|
it('falls back to the HTTP statusText when the error body has neither `detail` nor `message`', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
statusText: 'Conflict',
|
|
json: async () => ({ code: 'duplicate' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 409,
|
|
error: { code: 'duplicate', message: 'Conflict' },
|
|
})
|
|
})
|
|
|
|
it('falls back to `code`/`message` fields when there is no `detail`', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
statusText: 'Conflict',
|
|
json: async () => ({ code: 'duplicate', message: 'Already exists' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 409,
|
|
error: { code: 'duplicate', message: 'Already exists' },
|
|
})
|
|
})
|
|
|
|
it('falls back to the HTTP status and statusText when the error body is not JSON', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected end of input')
|
|
},
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 500,
|
|
error: { code: '500', message: 'Internal Server Error' },
|
|
})
|
|
})
|
|
|
|
it('rejects with an instance of ApiRequestError', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
json: async () => ({ detail: 'nope' }),
|
|
})
|
|
|
|
try {
|
|
await api.getTips('ws1')
|
|
expect.unreachable('expected getTips to throw')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ApiRequestError)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('endpoint functions (representative coverage)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
api.setAccessToken(null)
|
|
})
|
|
|
|
function lastRequestBody(): Record<string, unknown> {
|
|
const calls = vi.mocked(fetchMock).mock.calls
|
|
const options = calls[calls.length - 1]![1] as RequestInit | undefined
|
|
return JSON.parse((options?.body ?? '') as string) as Record<string, unknown>
|
|
}
|
|
|
|
function lastRequest(): { url: string; method?: string | undefined } {
|
|
const calls = vi.mocked(fetchMock).mock.calls
|
|
const call = calls[calls.length - 1]!
|
|
const url = (call[0] ?? '') as string
|
|
const options = call[1] as RequestInit | undefined
|
|
return { url, method: options?.method }
|
|
}
|
|
|
|
it('register sends snake_case payload and returns the token response', async () => {
|
|
const tokenResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tokenResponse })
|
|
|
|
const result = await api.register({
|
|
email: 'a@b.com',
|
|
password: 'secret',
|
|
displayName: 'Anna',
|
|
})
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/register', method: 'POST' })
|
|
expect(lastRequestBody()).toEqual({
|
|
email: 'a@b.com',
|
|
password: 'secret',
|
|
display_name: 'Anna',
|
|
})
|
|
expect(result).toEqual(tokenResponse)
|
|
})
|
|
|
|
it('login posts credentials and returns the token response', async () => {
|
|
const tokenResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tokenResponse })
|
|
|
|
await api.login({ email: 'a@b.com', password: 'secret' })
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/login', method: 'POST' })
|
|
expect(lastRequestBody()).toEqual({ email: 'a@b.com', password: 'secret' })
|
|
})
|
|
|
|
it('logout and refreshToken hit their auth endpoints', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
await api.logout()
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/logout', method: 'POST' })
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ access_token: 'tok', token_type: 'bearer', expires_in: 3600 }),
|
|
})
|
|
await api.refreshToken()
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/refresh', method: 'POST' })
|
|
})
|
|
|
|
it('getUserContext fetches the auth context', async () => {
|
|
const context = {
|
|
personal_data_uuid: 'pd1',
|
|
workspace_id: 'ws1',
|
|
parent_name: 'Anna',
|
|
children: [],
|
|
}
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => context })
|
|
|
|
const result = await api.getUserContext()
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/context', method: undefined })
|
|
expect(result).toEqual(context)
|
|
})
|
|
|
|
it('createChild posts the child payload', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ id: 'c1', name: 'Child' }),
|
|
})
|
|
|
|
const result = await api.createChild({
|
|
workspace_id: 'ws1',
|
|
name: 'Child',
|
|
birth_date: '2026-01-01',
|
|
})
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/children', method: 'POST' })
|
|
expect(result).toEqual({ id: 'c1', name: 'Child' })
|
|
})
|
|
|
|
it('getChild maps a raw child, defaulting a null created_at to an empty string', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
id: 'c1',
|
|
workspace_id: 'ws1',
|
|
name: 'Child',
|
|
birth_date: '2026-01-01',
|
|
preschool_start_date: null,
|
|
preschool_planning_days_per_year: 0,
|
|
created_at: null,
|
|
}),
|
|
})
|
|
|
|
const result = await api.getChild('c1')
|
|
|
|
expect(result).toEqual({
|
|
id: 'c1',
|
|
workspaceId: 'ws1',
|
|
name: 'Child',
|
|
birthDate: '2026-01-01',
|
|
preschoolStartDate: null,
|
|
preschoolPlanningDaysPerYear: 0,
|
|
createdAt: '',
|
|
})
|
|
})
|
|
|
|
it('createWorkspace posts the workspace name', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
id: 'ws1',
|
|
name: 'Home',
|
|
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',
|
|
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('applyPlan maps rejections to messageKey/data (§3.5)', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
accepted: false,
|
|
created_count: 0,
|
|
skipped_identical_count: 0,
|
|
skipped_free_count: 0,
|
|
replaced_count: 0,
|
|
collisions: [],
|
|
rejections: [
|
|
{
|
|
message_key: 'planApply.rejection.insufficientBalance',
|
|
data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
|
|
},
|
|
],
|
|
created_days: [],
|
|
}),
|
|
})
|
|
|
|
const result = await api.applyPlan('ws1', [], false)
|
|
|
|
expect(result.accepted).toBe(false)
|
|
expect(result.rejections).toEqual([
|
|
{
|
|
messageKey: 'planApply.rejection.insufficientBalance',
|
|
data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
|
|
},
|
|
])
|
|
})
|
|
|
|
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => [
|
|
{
|
|
uuid: 'pd1',
|
|
workspace_id: 'ws1',
|
|
name: 'Alex',
|
|
salary_type: 'monthly',
|
|
employment_status: 'employed',
|
|
created_at: '2026-01-01',
|
|
},
|
|
],
|
|
})
|
|
|
|
const result = await api.listWorkspaceMembers('ws1')
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/members', method: undefined })
|
|
expect(result[0]!.name).toBe('Alex')
|
|
expect(result[0]!.salaryType).toBe('monthly')
|
|
})
|
|
|
|
it('listWorkspaceMembers defaults a null created_at to an empty string', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => [
|
|
{
|
|
uuid: 'pd1',
|
|
workspace_id: 'ws1',
|
|
name: 'Alex',
|
|
salary_type: 'monthly',
|
|
employment_status: 'employed',
|
|
created_at: null,
|
|
},
|
|
],
|
|
})
|
|
|
|
const result = await api.listWorkspaceMembers('ws1')
|
|
|
|
expect(result[0]!.createdAt).toBe('')
|
|
})
|
|
|
|
it('getTips fetches tips for a workspace', async () => {
|
|
const tips = [{ id: 't1', severity: 'info', messageKey: 'k', data: {} }]
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tips })
|
|
|
|
const result = await api.getTips('ws1')
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/tips/ws1', method: undefined })
|
|
expect(result).toEqual(tips)
|
|
})
|
|
|
|
it('getPlannedDays omits the query string when no childId is given', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [] })
|
|
|
|
await api.getPlannedDays()
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/planned-days', method: undefined })
|
|
})
|
|
|
|
it('createPlannedDay posts the payload and maps the response', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
id: 'pld1',
|
|
workspace_id: 'ws1',
|
|
child_id: 'child1',
|
|
parent_personal_data_uuid: 'pd1',
|
|
date: '2026-06-01',
|
|
day_type: 'vacation',
|
|
is_dubbeldag: false,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01',
|
|
}),
|
|
})
|
|
|
|
const result = await api.createPlannedDay({
|
|
workspace_id: 'ws1',
|
|
child_id: 'child1',
|
|
parent_personal_data_uuid: 'pd1',
|
|
date: '2026-06-01',
|
|
day_type: 'vacation',
|
|
})
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/planned-days', method: 'POST' })
|
|
expect(result.dayType).toBe('vacation')
|
|
})
|
|
|
|
it('updatePlannedDay PUTs a partial payload and maps the response', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
id: 'pld1',
|
|
workspace_id: 'ws1',
|
|
child_id: 'child1',
|
|
parent_personal_data_uuid: 'pd1',
|
|
date: '2026-06-01',
|
|
day_type: 'sjukpenning',
|
|
is_dubbeldag: true,
|
|
created_at: '2026-01-01',
|
|
updated_at: '2026-01-01',
|
|
}),
|
|
})
|
|
|
|
const result = await api.updatePlannedDay('pld1', { is_dubbeldag: true })
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'PUT' })
|
|
expect(result.isDubbeldag).toBe(true)
|
|
})
|
|
|
|
it('deletePlannedDay issues a DELETE', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
|
|
await api.deletePlannedDay('pld1')
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'DELETE' })
|
|
})
|
|
|
|
it('startScraperSession, pollScraperSession and cancelScraperSession hit the fetch-data endpoints', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
session_id: 'sess1',
|
|
status: 'pending',
|
|
workspace_id: 'ws1',
|
|
personal_data_uuid: 'pd1',
|
|
child_id: 'child1',
|
|
}),
|
|
})
|
|
await api.startScraperSession({ workspace_id: 'ws1' })
|
|
expect(lastRequest()).toEqual({ url: '/api/fetch-data/start', method: 'POST' })
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'done' }) })
|
|
await api.pollScraperSession('sess1')
|
|
expect(lastRequest()).toEqual({
|
|
url: '/api/fetch-data/poll?session_id=sess1',
|
|
method: undefined,
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
await api.cancelScraperSession('sess1')
|
|
expect(lastRequest()).toEqual({ url: '/api/fetch-data/cancel', method: 'POST' })
|
|
expect(lastRequestBody()).toEqual({ session_id: 'sess1' })
|
|
})
|
|
|
|
it('getCalendarDays builds the query string and maps the response to camelCase', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
start_year: 2026,
|
|
end_year: 2029,
|
|
min_year: 2026,
|
|
max_year: 2035,
|
|
days: [
|
|
{
|
|
date: '2026-01-01',
|
|
name: 'Nyårsdagen',
|
|
day_type: 'red_day',
|
|
is_override: false,
|
|
override_is_working_day: null,
|
|
is_free: true,
|
|
},
|
|
{
|
|
date: '2026-04-05',
|
|
name: null,
|
|
day_type: 'klamdag',
|
|
is_override: true,
|
|
override_is_working_day: false,
|
|
is_free: true,
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
const result = await api.getCalendarDays('ws1', 2026, 4)
|
|
|
|
expect(lastRequest()).toEqual({
|
|
url: '/api/calendar/days?workspace_id=ws1&start_year=2026&years=4',
|
|
method: undefined,
|
|
})
|
|
expect(result.startYear).toBe(2026)
|
|
expect(result.endYear).toBe(2029)
|
|
expect(result.minYear).toBe(2026)
|
|
expect(result.maxYear).toBe(2035)
|
|
expect(result.days).toHaveLength(2)
|
|
expect(result.days[0]).toEqual({
|
|
date: '2026-01-01',
|
|
name: 'Nyårsdagen',
|
|
dayType: 'red_day',
|
|
isOverride: false,
|
|
overrideIsWorkingDay: null,
|
|
isFree: true,
|
|
})
|
|
expect(result.days[1]!.dayType).toBe('klamdag')
|
|
expect(result.days[1]!.isOverride).toBe(true)
|
|
expect(result.days[1]!.overrideIsWorkingDay).toBe(false)
|
|
})
|
|
|
|
it('getCalendarDays defaults years to 4 when omitted', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ start_year: 2026, end_year: 2029, min_year: 2026, max_year: 2035, days: [] }),
|
|
})
|
|
|
|
await api.getCalendarDays('ws1', 2026)
|
|
|
|
expect(lastRequest()).toEqual({
|
|
url: '/api/calendar/days?workspace_id=ws1&start_year=2026&years=4',
|
|
method: undefined,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('apiFetch — structured {message_key, data} errors', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
api.setAccessToken(null)
|
|
})
|
|
|
|
it('captures message_key/data in error.details instead of leaking the raw key into message', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({
|
|
detail: { message_key: 'workspace.parents.remove.hasDependentData', data: { blockers: ['leave_wishes'] } },
|
|
}),
|
|
})
|
|
|
|
try {
|
|
await api.getTips('ws1')
|
|
expect.unreachable('expected getTips to throw')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ApiRequestError)
|
|
const apiErr = err as ApiRequestError
|
|
expect(apiErr.status).toBe(422)
|
|
expect(apiErr.error.details).toEqual({
|
|
messageKey: 'workspace.parents.remove.hasDependentData',
|
|
data: { blockers: ['leave_wishes'] },
|
|
})
|
|
}
|
|
})
|
|
|
|
it('defaults data to an empty object when the structured error omits it', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({ detail: { message_key: 'workspace.notFound' } }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 404,
|
|
error: { details: { messageKey: 'workspace.notFound', data: {} } },
|
|
})
|
|
})
|
|
|
|
it('still handles a plain string `detail` the old way (not misread as structured)', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
json: async () => ({ detail: 'Invalid payload' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 400,
|
|
error: { code: '400', message: 'Invalid payload' },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('workspace parent management (rename/remove)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
api.setAccessToken(null)
|
|
})
|
|
|
|
function lastRequestBody(): Record<string, unknown> {
|
|
const calls = vi.mocked(fetchMock).mock.calls
|
|
const options = calls[calls.length - 1]![1] as RequestInit | undefined
|
|
return JSON.parse((options?.body ?? '') as string) as Record<string, unknown>
|
|
}
|
|
|
|
function lastRequest(): { url: string; method?: string | undefined } {
|
|
const calls = vi.mocked(fetchMock).mock.calls
|
|
const call = calls[calls.length - 1]!
|
|
const url = (call[0] ?? '') as string
|
|
const options = call[1] as RequestInit | undefined
|
|
return { url, method: options?.method }
|
|
}
|
|
|
|
it('renameWorkspaceParent PATCHes the new display name and maps the response to camelCase', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
uuid: 'pd2',
|
|
workspace_id: 'ws1',
|
|
name: 'Samuel',
|
|
salary_type: 'monthly',
|
|
employment_status: 'employed',
|
|
created_at: '2026-01-01',
|
|
}),
|
|
})
|
|
|
|
const result = await api.renameWorkspaceParent('ws1', 'pd2', 'Samuel')
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'PATCH' })
|
|
expect(lastRequestBody()).toEqual({ display_name: 'Samuel' })
|
|
expect(result.name).toBe('Samuel')
|
|
expect(result.uuid).toBe('pd2')
|
|
})
|
|
|
|
it('renameWorkspaceParent surfaces an invalidName refusal as a structured error', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({ detail: { message_key: 'workspace.parents.invalidName', data: {} } }),
|
|
})
|
|
|
|
await expect(api.renameWorkspaceParent('ws1', 'pd2', '')).rejects.toMatchObject({
|
|
status: 422,
|
|
error: { details: { messageKey: 'workspace.parents.invalidName' } },
|
|
})
|
|
})
|
|
|
|
it('removeWorkspaceParent DELETEs and resolves to undefined on 204', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
|
|
const result = await api.removeWorkspaceParent('ws1', 'pd2')
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'DELETE' })
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it('removeWorkspaceParent surfaces a hasDependentData refusal with its blockers', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({
|
|
detail: {
|
|
message_key: 'workspace.parents.remove.hasDependentData',
|
|
data: { blockers: ['leave_wishes', 'vacation_days'] },
|
|
},
|
|
}),
|
|
})
|
|
|
|
await expect(api.removeWorkspaceParent('ws1', 'pd2')).rejects.toMatchObject({
|
|
status: 422,
|
|
error: {
|
|
details: {
|
|
messageKey: 'workspace.parents.remove.hasDependentData',
|
|
data: { blockers: ['leave_wishes', 'vacation_days'] },
|
|
},
|
|
},
|
|
})
|
|
})
|
|
})
|