mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
f61bab3ffd
The year list comes back sorted, so picking its last element meant Vacay always landed on the highest year — add 2030 to plan ahead and every visit from then on opened 2030. It now opens the leave-year period today falls into, falls back to the closest year the plan has when that period has none, and keeps the year the viewer is on when a live update reloads the list.
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
// FE-STORE-VCY-001 to FE-STORE-VCY-023
|
||
// 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({});
|
||
});
|
||
});
|
||
|
||
describe('vacayStore year selection', () => {
|
||
it('FE-STORE-VCY-019: the first load opens on the current period, not on the highest year', async () => {
|
||
const current = new Date().getFullYear();
|
||
server.use(http.get('/api/addons/vacay/years', () =>
|
||
HttpResponse.json({ years: [current - 1, current, current + 4] })));
|
||
|
||
await useVacayStore.getState().loadYears();
|
||
|
||
expect(useVacayStore.getState().selectedYear).toBe(current);
|
||
});
|
||
|
||
it('FE-STORE-VCY-020: with no year for the current period the closest one is opened', async () => {
|
||
const current = new Date().getFullYear();
|
||
server.use(http.get('/api/addons/vacay/years', () =>
|
||
HttpResponse.json({ years: [current - 3, current + 1] })));
|
||
|
||
await useVacayStore.getState().loadYears();
|
||
|
||
expect(useVacayStore.getState().selectedYear).toBe(current + 1);
|
||
});
|
||
|
||
it('FE-STORE-VCY-021: a reload keeps the year the viewer is looking at', async () => {
|
||
const current = new Date().getFullYear();
|
||
useVacayStore.setState({ years: [current, current + 1], selectedYear: current + 1 });
|
||
server.use(http.get('/api/addons/vacay/years', () =>
|
||
HttpResponse.json({ years: [current, current + 1] })));
|
||
|
||
await useVacayStore.getState().loadYears();
|
||
|
||
expect(useVacayStore.getState().selectedYear).toBe(current + 1);
|
||
});
|
||
|
||
it('FE-STORE-VCY-022: a reload that lost the selected year falls back to the current period', async () => {
|
||
const current = new Date().getFullYear();
|
||
useVacayStore.setState({ years: [current, current + 1], selectedYear: current + 1 });
|
||
server.use(http.get('/api/addons/vacay/years', () =>
|
||
HttpResponse.json({ years: [current] })));
|
||
|
||
await useVacayStore.getState().loadYears();
|
||
|
||
expect(useVacayStore.getState().selectedYear).toBe(current);
|
||
});
|
||
|
||
it('FE-STORE-VCY-023: removing the open year drops back to the current period', async () => {
|
||
const current = new Date().getFullYear();
|
||
useVacayStore.setState({ years: [current, current + 1], selectedYear: current + 1 });
|
||
server.use(
|
||
http.delete('/api/addons/vacay/years/:year', () => HttpResponse.json({ years: [current] })),
|
||
http.get('/api/addons/vacay/stats/:year', () => HttpResponse.json({ stats: [] })),
|
||
);
|
||
|
||
await useVacayStore.getState().removeYear(current + 1);
|
||
|
||
expect(useVacayStore.getState().selectedYear).toBe(current);
|
||
});
|
||
});
|