9ac67933d4
Applying a plan by itself stopped being something the interface does once every edit began recomputing and saving in one call. The hook that posted straight to the apply endpoint had no caller left, nor did its request type or its fixture. The response decoder stays, since the sync response embeds the same object, and the two tests being deleted were the only ones checking how its collision and rejection fields are renamed — that coverage moved onto the sync test rather than leaving with the code it happened to live next to.
1939 lines
57 KiB
TypeScript
1939 lines
57 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import * as api from './api'
|
|
import type { RawParentalLeaveFetch, RawWorkspaceOverview } from './api'
|
|
import { ApiRequestError } from './errors'
|
|
|
|
describe('API response mappers (snake_case → camelCase)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('getWorkspaceOverview', () => {
|
|
it('maps snake_case workspace overview to camelCase', async () => {
|
|
const rawResponse: RawWorkspaceOverview = {
|
|
workspace_id: 'ws1',
|
|
child_id: 'child1',
|
|
parent_projections: [
|
|
{
|
|
personal_data_uuid: 'pd1',
|
|
child_id: 'child1',
|
|
birth_date: '2024-01-01',
|
|
fourth_birthday: '2028-01-01',
|
|
total_days: 480,
|
|
days_used: 100,
|
|
planned_days_before_b4: 150,
|
|
projected_left_at_b4: 230,
|
|
savable_after_b4: 96,
|
|
expiring_at_b4: 134,
|
|
savable_cap_at_b4: 96,
|
|
},
|
|
],
|
|
combined_days_used: 100,
|
|
combined_days_left: 380,
|
|
combined_expiring_at_b4: 134,
|
|
}
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => rawResponse,
|
|
})
|
|
|
|
const result = await api.getWorkspaceOverview('ws1', 'child1')
|
|
|
|
expect(result.workspaceId).toBe('ws1')
|
|
expect(result.childId).toBe('child1')
|
|
expect(result.combinedDaysUsed).toBe(100)
|
|
expect(result.combinedDaysLeft).toBe(380)
|
|
expect(result.combinedExpiringAtB4).toBe(134)
|
|
expect(result.parentProjections.length).toBe(1)
|
|
expect(result.parentProjections[0]!.personalDataUuid).toBe('pd1')
|
|
expect(result.parentProjections[0]!.fourthBirthday).toBe('2028-01-01')
|
|
expect(result.parentProjections[0]!.plannedDaysBeforeB4).toBe(150)
|
|
expect(result.parentProjections[0]!.projectedLeftAtB4).toBe(230)
|
|
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
|
|
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
|
|
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
|
|
})
|
|
})
|
|
|
|
describe('getParentalLeave', () => {
|
|
it('maps snake_case parental leave fetch to camelCase with nested tierData', async () => {
|
|
const rawResponse: RawParentalLeaveFetch = {
|
|
id: 'pf1',
|
|
personal_data_uuid: 'pd1',
|
|
workspace_id: 'ws1',
|
|
child_id: 'child1',
|
|
source: 'manual',
|
|
fetched_at: '2026-06-14T17:09:47',
|
|
total_days: 480,
|
|
days_used: 100,
|
|
days_left: 380,
|
|
reserved_days_total: 90,
|
|
reserved_days_left: 80,
|
|
transferable_days_left: 100,
|
|
dubbeldagar_left: 60,
|
|
tier_data: {
|
|
sjukpenning_total: 390,
|
|
sjukpenning_used: 50,
|
|
sjukpenning_left: 340,
|
|
lagsta_niva_total: 90,
|
|
lagsta_niva_used: 10,
|
|
lagsta_niva_left: 80,
|
|
},
|
|
created_at: '2026-06-14T17:09:47',
|
|
updated_at: '2026-06-14T17:09:47',
|
|
}
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => rawResponse,
|
|
})
|
|
|
|
const result = await api.getParentalLeave('pd1', 'child1')
|
|
|
|
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,
|
|
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('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',
|
|
planning_mode: 'couple' 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.planningMode).toBe('couple')
|
|
expect(result.createdAt).toBe('2026-06-14T17:09:47')
|
|
})
|
|
})
|
|
|
|
describe('listDayOverridesByWorkspace', () => {
|
|
it('maps snake_case day overrides to camelCase', async () => {
|
|
// Regression test: toDayOverride was the only mapper in this file with
|
|
// no snake_case→camelCase test — this is the exact shape of bug that
|
|
// shipped for getTips (see the getTips test below): a missing/wrong
|
|
// mapping surfaces only at runtime, never in a suite that mocks the
|
|
// module and hand-rolls already-camelCase fixtures.
|
|
const rawResponse = [
|
|
{
|
|
id: 'do-1',
|
|
workspace_id: 'ws1',
|
|
date: '2026-12-24',
|
|
is_working_day: true,
|
|
note: 'Jobbade på juldagen',
|
|
created_at: '2026-01-01T00:00:00',
|
|
updated_at: '2026-01-02T00:00:00',
|
|
},
|
|
]
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => rawResponse,
|
|
})
|
|
|
|
const result = await api.listDayOverridesByWorkspace('ws1')
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
id: 'do-1',
|
|
workspaceId: 'ws1',
|
|
date: '2026-12-24',
|
|
isWorkingDay: true,
|
|
note: 'Jobbade på juldagen',
|
|
createdAt: '2026-01-01T00:00:00',
|
|
updatedAt: '2026-01-02T00:00:00',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('maps a null note/created_at/updated_at through unchanged', async () => {
|
|
const rawResponse = [
|
|
{
|
|
id: 'do-2',
|
|
workspace_id: 'ws1',
|
|
date: '2026-06-20',
|
|
is_working_day: false,
|
|
note: null,
|
|
created_at: null,
|
|
updated_at: null,
|
|
},
|
|
]
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => rawResponse,
|
|
})
|
|
|
|
const result = await api.listDayOverridesByWorkspace('ws1')
|
|
|
|
expect(result[0]!.note).toBeNull()
|
|
expect(result[0]!.createdAt).toBeNull()
|
|
expect(result[0]!.updatedAt).toBeNull()
|
|
expect(result[0]!.isWorkingDay).toBe(false)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('apiFetch (shared request/response handling)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
api.setAccessToken(null)
|
|
})
|
|
|
|
it('omits the Authorization header when no access token is set', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [] })
|
|
|
|
await api.getTips('ws1')
|
|
|
|
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
const headers = options.headers as Record<string, string>
|
|
expect(headers['Authorization']).toBeUndefined()
|
|
})
|
|
|
|
it('attaches the Authorization header when an access token is set', async () => {
|
|
api.setAccessToken('my-token')
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [] })
|
|
|
|
await api.getTips('ws1')
|
|
|
|
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/api/tips/ws1')
|
|
const headers = options.headers as Record<string, string>
|
|
expect(headers['Authorization']).toBe('Bearer my-token')
|
|
})
|
|
|
|
it('returns undefined for a 204 No Content response', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
|
|
const result = await api.deletePlannedDay('pd1')
|
|
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it('throws an ApiRequestError using a string `detail` field from the error body', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
json: async () => ({ detail: 'Invalid payload' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 400,
|
|
error: { code: '400', message: 'Invalid payload' },
|
|
})
|
|
})
|
|
|
|
it('joins a FastAPI-style array `detail` field into a single message', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({
|
|
detail: [{ msg: 'field required' }, { msg: 'must be positive' }],
|
|
}),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 422,
|
|
error: { code: '422', message: 'field required; must be positive' },
|
|
})
|
|
})
|
|
|
|
it('stringifies an array `detail` entry that has no `msg` field', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity',
|
|
json: async () => ({ detail: [{ loc: ['body', 'email'] }] }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 422,
|
|
error: { message: JSON.stringify({ loc: ['body', 'email'] }) },
|
|
})
|
|
})
|
|
|
|
it('falls back to the HTTP statusText when the error body has neither `detail` nor `message`', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
statusText: 'Conflict',
|
|
json: async () => ({ code: 'duplicate' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 409,
|
|
error: { code: 'duplicate', message: 'Conflict' },
|
|
})
|
|
})
|
|
|
|
it('falls back to `code`/`message` fields when there is no `detail`', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
statusText: 'Conflict',
|
|
json: async () => ({ code: 'duplicate', message: 'Already exists' }),
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 409,
|
|
error: { code: 'duplicate', message: 'Already exists' },
|
|
})
|
|
})
|
|
|
|
it('falls back to the HTTP status and statusText when the error body is not JSON', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected end of input')
|
|
},
|
|
})
|
|
|
|
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
|
status: 500,
|
|
error: { code: '500', message: 'Internal Server Error' },
|
|
})
|
|
})
|
|
|
|
it('rejects with an instance of ApiRequestError', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
json: async () => ({ detail: 'nope' }),
|
|
})
|
|
|
|
try {
|
|
await api.getTips('ws1')
|
|
expect.unreachable('expected getTips to throw')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ApiRequestError)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('endpoint functions (representative coverage)', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn()
|
|
globalThis.fetch = fetchMock as typeof fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
api.setAccessToken(null)
|
|
})
|
|
|
|
function lastRequestBody(): Record<string, unknown> {
|
|
const calls = vi.mocked(fetchMock).mock.calls
|
|
const options = calls[calls.length - 1]![1] as RequestInit | undefined
|
|
return JSON.parse((options?.body ?? '') as string) as Record<string, unknown>
|
|
}
|
|
|
|
function lastRequest(): { url: string; method?: string | undefined } {
|
|
const calls = vi.mocked(fetchMock).mock.calls
|
|
const call = calls[calls.length - 1]!
|
|
const url = (call[0] ?? '') as string
|
|
const options = call[1] as RequestInit | undefined
|
|
return { url, method: options?.method }
|
|
}
|
|
|
|
it('register sends snake_case payload and returns the token response', async () => {
|
|
const tokenResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tokenResponse })
|
|
|
|
const result = await api.register({
|
|
email: 'a@b.com',
|
|
password: 'secret',
|
|
displayName: 'Anna',
|
|
})
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/register', method: 'POST' })
|
|
expect(lastRequestBody()).toEqual({
|
|
email: 'a@b.com',
|
|
password: 'secret',
|
|
display_name: 'Anna',
|
|
})
|
|
expect(result).toEqual(tokenResponse)
|
|
})
|
|
|
|
it('login posts credentials and returns the token response', async () => {
|
|
const tokenResponse = { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tokenResponse })
|
|
|
|
await api.login({ email: 'a@b.com', password: 'secret' })
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/login', method: 'POST' })
|
|
expect(lastRequestBody()).toEqual({ email: 'a@b.com', password: 'secret' })
|
|
})
|
|
|
|
it('logout and refreshToken hit their auth endpoints', async () => {
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
|
|
await api.logout()
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/logout', method: 'POST' })
|
|
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ access_token: 'tok', token_type: 'bearer', expires_in: 3600 }),
|
|
})
|
|
await api.refreshToken()
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/refresh', method: 'POST' })
|
|
})
|
|
|
|
it('getUserContext fetches the auth context', async () => {
|
|
const context = {
|
|
personal_data_uuid: 'pd1',
|
|
workspace_id: 'ws1',
|
|
parent_name: 'Anna',
|
|
children: [],
|
|
}
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => context })
|
|
|
|
const result = await api.getUserContext()
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/auth/context', method: undefined })
|
|
expect(result).toEqual(context)
|
|
})
|
|
|
|
it('createChild posts the child payload', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ id: 'c1', name: 'Child' }),
|
|
})
|
|
|
|
const result = await api.createChild({
|
|
workspace_id: 'ws1',
|
|
name: 'Child',
|
|
birth_date: '2026-01-01',
|
|
})
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/children', method: 'POST' })
|
|
expect(result).toEqual({ id: 'c1', name: 'Child' })
|
|
})
|
|
|
|
it('getChild maps a raw child, defaulting a null created_at to an empty string', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
id: 'c1',
|
|
workspace_id: 'ws1',
|
|
name: 'Child',
|
|
birth_date: '2026-01-01',
|
|
preschool_start_date: null,
|
|
preschool_planning_days_per_year: 0,
|
|
created_at: null,
|
|
}),
|
|
})
|
|
|
|
const result = await api.getChild('c1')
|
|
|
|
expect(result).toEqual({
|
|
id: 'c1',
|
|
workspaceId: 'ws1',
|
|
name: 'Child',
|
|
birthDate: '2026-01-01',
|
|
preschoolStartDate: null,
|
|
preschoolPlanningDaysPerYear: 0,
|
|
createdAt: '',
|
|
})
|
|
})
|
|
|
|
it('createWorkspace posts the workspace name', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
id: 'ws1',
|
|
name: 'Home',
|
|
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_mode: 'couple',
|
|
created_at: '2026-01-01',
|
|
}),
|
|
})
|
|
|
|
const result = await api.updateWorkspace('ws1', {
|
|
name: 'New name',
|
|
planningMode: 'couple',
|
|
})
|
|
|
|
expect(lastRequest()).toEqual({ url: '/api/workspace/ws1', method: 'PUT' })
|
|
expect(lastRequestBody()).toEqual({
|
|
name: 'New name',
|
|
planning_mode: 'couple',
|
|
})
|
|
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,
|
|
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,
|
|
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 — 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'] },
|
|
},
|
|
},
|
|
})
|
|
})
|
|
})
|