Files
TREK/client/src/store/vacayStore.test.ts
T

369 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-STORE-VCY-001 to FE-STORE-VCY-018
// Complements tests/unit/stores/vacayStore.test.ts: optimistic toggles and their
// rollbacks, the leave-year window plumbing and the holiday-marker merge rules.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores } from '../../tests/helpers/store';
import { useVacayStore } from './vacayStore';
import { useAuthStore } from './authStore';
import type { HolidayInfo, VacayEntry, VacayPlan, VacayYearSettings } from '../types';
const FISCAL_JULY: VacayYearSettings = {
year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null,
};
function buildPlan(over: Partial<VacayPlan> = {}): VacayPlan {
return {
id: 1,
holidays_enabled: true,
school_holidays_enabled: false,
holidays_region: null,
holiday_calendars: [],
block_weekends: true,
carry_over_enabled: false,
company_holidays_enabled: false,
...over,
};
}
function markersOn(date: string): HolidayInfo[] {
const value = useVacayStore.getState().holidays[date];
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
beforeEach(() => {
resetAllStores();
useAuthStore.setState({ user: { id: 1, username: 'alice', email: 'a@t.app', role: 'user' } as never });
});
describe('vacayStore optimistic updates', () => {
it('FE-STORE-VCY-001: toggling an empty day adds an entry with the person color', async () => {
let resolveToggle = (): void => {};
const gate = new Promise<void>(res => { resolveToggle = res; });
server.use(http.post('/api/addons/vacay/entries/toggle', async () => {
await gate;
return HttpResponse.json({ success: true });
}));
useVacayStore.setState({
selectedYear: 2025,
users: [{ id: 1, username: 'alice', color: '#3b82f6' }],
entries: [],
});
const pending = useVacayStore.getState().toggleEntry('2025-06-20');
// The cell reacts before the round trip finishes.
expect(useVacayStore.getState().entries).toEqual([
{ date: '2025-06-20', user_id: 1, fraction: 1, kind: 'vacation', person_color: '#3b82f6', person_name: 'alice' },
]);
resolveToggle();
await pending;
// Reconciled from the server afterwards.
expect(useVacayStore.getState().entries).toHaveLength(2);
});
it('FE-STORE-VCY-002: toggling the same fraction and kind again clears the day', async () => {
const entries: VacayEntry[] = [
{ date: '2025-06-20', user_id: 1, fraction: 1, kind: 'vacation' },
{ date: '2025-06-21', user_id: 1 },
];
useVacayStore.setState({ selectedYear: 2025, entries });
let seen: VacayEntry[] = [];
server.use(http.post('/api/addons/vacay/entries/toggle', () => {
seen = useVacayStore.getState().entries;
return HttpResponse.json({ success: true });
}));
await useVacayStore.getState().toggleEntry('2025-06-20', 1, 1, 'vacation');
expect(seen.map(e => e.date)).toEqual(['2025-06-21']);
});
it('FE-STORE-VCY-003: a legacy entry without fraction or kind counts as a full vacation day', async () => {
useVacayStore.setState({ selectedYear: 2025, entries: [{ date: '2025-06-20', user_id: 1 }] });
let seen: VacayEntry[] = [];
server.use(http.post('/api/addons/vacay/entries/toggle', () => {
seen = useVacayStore.getState().entries;
return HttpResponse.json({ success: true });
}));
await useVacayStore.getState().toggleEntry('2025-06-20', 1, 1, 'vacation');
expect(seen).toEqual([]);
});
it('FE-STORE-VCY-004: a different fraction or kind converts the day instead of clearing it', async () => {
useVacayStore.setState({
selectedYear: 2025,
entries: [{ date: '2025-06-20', user_id: 1, fraction: 1, kind: 'vacation' }],
});
let seen: VacayEntry[] = [];
server.use(http.post('/api/addons/vacay/entries/toggle', () => {
seen = useVacayStore.getState().entries;
return HttpResponse.json({ success: true });
}));
await useVacayStore.getState().toggleEntry('2025-06-20', 1, 0.5, 'comp');
expect(seen).toEqual([{ date: '2025-06-20', user_id: 1, fraction: 0.5, kind: 'comp' }]);
});
it('FE-STORE-VCY-005: a rejected toggle restores the previous entries and rethrows', async () => {
const entries: VacayEntry[] = [{ date: '2025-06-20', user_id: 1 }];
const stats = [{
user_id: 1, person_name: 'alice', person_color: '#3b82f6', year: 2025,
vacation_days: 30, carried_over: 0, total_available: 30, used: 1, remaining: 29,
}];
useVacayStore.setState({ selectedYear: 2025, entries, stats });
server.use(http.post('/api/addons/vacay/entries/toggle', () =>
HttpResponse.json({ error: 'locked' }, { status: 403 })));
await expect(useVacayStore.getState().toggleEntry('2025-06-21', 1)).rejects.toThrow();
expect(useVacayStore.getState().entries).toEqual(entries);
expect(useVacayStore.getState().stats).toEqual(stats);
});
it('FE-STORE-VCY-006: toggling for an unknown person still records the entry', async () => {
useVacayStore.setState({ selectedYear: 2025, users: [], entries: [] });
let seen: VacayEntry[] = [];
server.use(http.post('/api/addons/vacay/entries/toggle', () => {
seen = useVacayStore.getState().entries;
return HttpResponse.json({ success: true });
}));
await useVacayStore.getState().toggleEntry('2025-06-20', 4);
expect(seen).toEqual([
{ date: '2025-06-20', user_id: 4, fraction: 1, kind: 'vacation', person_color: undefined, person_name: undefined },
]);
});
it('FE-STORE-VCY-007: company-holiday toggles add and remove the day optimistically', async () => {
useVacayStore.setState({ selectedYear: 2025, companyHolidays: [{ date: '2025-12-24' }] });
const seen: { date: string; note?: string }[][] = [];
server.use(http.post('/api/addons/vacay/entries/company-holiday', () => {
seen.push(useVacayStore.getState().companyHolidays);
return HttpResponse.json({ success: true });
}));
await useVacayStore.getState().toggleCompanyHoliday('2025-12-24');
await useVacayStore.getState().toggleCompanyHoliday('2025-12-25');
expect(seen[0]).toEqual([]);
expect(seen[1]).toEqual([{ date: '2025-12-25' }]);
});
it('FE-STORE-VCY-008: a rejected company-holiday toggle rolls the day back', async () => {
const companyHolidays = [{ date: '2025-12-24' }];
useVacayStore.setState({ selectedYear: 2025, companyHolidays });
server.use(http.post('/api/addons/vacay/entries/company-holiday', () =>
HttpResponse.json({ error: 'nope' }, { status: 500 })));
await expect(useVacayStore.getState().toggleCompanyHoliday('2025-12-25')).rejects.toThrow();
expect(useVacayStore.getState().companyHolidays).toEqual(companyHolidays);
});
});
describe('vacayStore leave-year window', () => {
it('FE-STORE-VCY-009: an empty year list falls back to the current period', async () => {
useVacayStore.setState({ yearSettings: FISCAL_JULY });
server.use(http.get('/api/addons/vacay/years', () => HttpResponse.json({ years: [] })));
await useVacayStore.getState().loadYears();
const now = new Date();
const expected = now.getMonth() >= 6 ? now.getFullYear() : now.getFullYear() - 1;
expect(useVacayStore.getState().years).toEqual([]);
expect(useVacayStore.getState().selectedYear).toBe(expected);
});
it('FE-STORE-VCY-010: updating the year settings reloads everything the window feeds', async () => {
useVacayStore.setState({ selectedYear: 2026, plan: buildPlan({ holidays_enabled: false }) });
const hits: string[] = [];
server.use(
http.get('/api/addons/vacay/entries/:year', ({ params }) => {
hits.push(`entries:${params.year as string}`);
return HttpResponse.json({ entries: [], companyHolidays: [] });
}),
http.get('/api/addons/vacay/stats/:year', ({ params }) => {
hits.push(`stats:${params.year as string}`);
return HttpResponse.json({ stats: [] });
}),
http.get('/api/addons/vacay/shares/calendars/:year', ({ params }) => {
hits.push(`shared:${params.year as string}`);
return HttpResponse.json({ calendars: [] });
}),
);
await useVacayStore.getState().updateYearSettings({
year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null,
});
expect(useVacayStore.getState().yearSettings.year_type).toBe('fiscal');
expect(useVacayStore.getState().yearSettings.year_start_month).toBe(7);
expect(hits).toEqual(['entries:2026', 'stats:2026', 'shared:2026']);
});
it('FE-STORE-VCY-011: a settings response without a body falls back to the calendar year', async () => {
useVacayStore.setState({ yearSettings: FISCAL_JULY });
server.use(http.put('/api/addons/vacay/year-settings', () => HttpResponse.json({})));
await useVacayStore.getState().updateYearSettings({
year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null,
});
expect(useVacayStore.getState().yearSettings).toEqual({
year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null,
});
});
});
describe('vacayStore holiday markers', () => {
it('FE-STORE-VCY-012: two calendars on the same day keep both markers', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: buildPlan({
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'DE', label: 'DE', color: '#fecaca', sort_order: 0 },
{ id: 2, plan_id: 1, region: 'FR', label: 'FR', color: '#bbf7d0', sort_order: 1 },
],
}),
});
server.use(http.get('/api/addons/vacay/holidays/:year/:country', () => HttpResponse.json([
{ date: '2026-12-25', name: 'Christmas', localName: 'Weihnachten', global: true, counties: null },
])));
await useVacayStore.getState().loadHolidays(2026);
expect(markersOn('2026-12-25').map(m => m.color)).toEqual(['#fecaca', '#bbf7d0']);
});
it('FE-STORE-VCY-013: a shifted year loads both calendar years without duplicating a marker', async () => {
useVacayStore.setState({
selectedYear: 2026,
yearSettings: FISCAL_JULY,
plan: buildPlan({
holiday_calendars: [{ id: 1, plan_id: 1, region: 'DE', label: 'DE', color: '#fecaca', sort_order: 0 }],
}),
});
const requested: string[] = [];
server.use(http.get('/api/addons/vacay/holidays/:year/:country', ({ params }) => {
requested.push(params.year as string);
return HttpResponse.json([
{ date: '2026-12-25', name: 'Christmas', localName: 'Weihnachten', global: true, counties: null },
// Outside the Jul 2026 – Jun 2027 window, so it never reaches the map.
{ date: '2026-05-01', name: 'Labour Day', localName: 'Tag der Arbeit', global: true, counties: null },
]);
}));
await useVacayStore.getState().loadHolidays(2026);
expect(requested).toEqual(['2026', '2027']);
expect(markersOn('2026-12-25')).toHaveLength(1);
expect(markersOn('2026-05-01')).toHaveLength(0);
});
it('FE-STORE-VCY-014: a country-level calendar is skipped when the data is region-split', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: buildPlan({
holiday_calendars: [{ id: 1, plan_id: 1, region: 'DE', label: 'DE', color: '#fecaca', sort_order: 0 }],
}),
});
server.use(http.get('/api/addons/vacay/holidays/:year/:country', () => HttpResponse.json([
{ date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false, counties: ['DE-BY'] },
])));
await useVacayStore.getState().loadHolidays(2026);
expect(useVacayStore.getState().holidays).toEqual({});
});
it('FE-STORE-VCY-015: school-holiday ranges expand into days and accept a plain name', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: buildPlan({
holidays_enabled: false,
school_holidays_enabled: true,
holiday_calendars: [{ id: 1, plan_id: 1, region: 'NL', label: 'NL', color: '#bbf7d0', sort_order: 0, type: 'school_holiday' }],
}),
});
server.use(http.get('/api/addons/vacay/school-holidays/:year/:country', () => HttpResponse.json([
{ startDate: '2026-05-01', endDate: '2026-05-03', name: 'Meivakantie' },
// No start date at all, and a range the date parser cannot read.
{ endDate: '2026-06-10', name: 'Broken' },
{ startDate: 'not-a-date', endDate: 'nope', name: 'Unparseable' },
])));
await useVacayStore.getState().loadHolidays(2026);
const holidays = useVacayStore.getState().holidays;
expect(Object.keys(holidays).sort()).toEqual(['2026-05-01', '2026-05-02', '2026-05-03']);
expect(markersOn('2026-05-02')[0].name).toBe('Meivakantie');
});
it('FE-STORE-VCY-016: an unnamed school holiday gets the generic label', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: buildPlan({
holidays_enabled: false,
school_holidays_enabled: true,
holiday_calendars: [{ id: 1, plan_id: 1, region: 'NL', label: null, color: '#bbf7d0', sort_order: 0, type: 'school_holiday' }],
}),
});
server.use(http.get('/api/addons/vacay/school-holidays/:year/:country', () => HttpResponse.json([
{ startDate: '2026-05-01', name: [] },
])));
await useVacayStore.getState().loadHolidays(2026);
expect(markersOn('2026-05-01')[0].name).toBe('School holidays');
});
it('FE-STORE-VCY-017: a failing holiday request leaves the other calendars intact', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: buildPlan({
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'DE', label: 'DE', color: '#fecaca', sort_order: 0 },
{ id: 2, plan_id: 1, region: 'FR', label: 'FR', color: '#bbf7d0', sort_order: 1 },
],
}),
});
server.use(http.get('/api/addons/vacay/holidays/:year/:country', ({ params }) => {
if (params.country === 'DE') return HttpResponse.json({ error: 'down' }, { status: 500 });
return HttpResponse.json([
{ date: '2026-07-14', name: 'Bastille Day', localName: 'Fête nationale', global: true, counties: null },
]);
}));
await useVacayStore.getState().loadHolidays(2026);
expect(markersOn('2026-07-14').map(m => m.color)).toEqual(['#bbf7d0']);
});
it('FE-STORE-VCY-018: disabled calendar types are not fetched at all', async () => {
const fetched = vi.fn(() => undefined);
useVacayStore.setState({
selectedYear: 2026,
plan: buildPlan({
holidays_enabled: false,
school_holidays_enabled: false,
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'DE', label: 'DE', color: '#fecaca', sort_order: 0 },
{ id: 2, plan_id: 1, region: 'NL', label: 'NL', color: '#bbf7d0', sort_order: 1, type: 'school_holiday' },
],
}),
holidays: { '2026-01-01': [{ name: 'stale', localName: 'stale', color: '#000', label: null }] },
});
server.use(http.get('/api/addons/vacay/holidays/:year/:country', () => {
fetched();
return HttpResponse.json([]);
}));
await useVacayStore.getState().loadHolidays(2026);
expect(fetched).not.toHaveBeenCalled();
expect(useVacayStore.getState().holidays).toEqual({});
});
});