mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
refactor(planner): the day plan's drag state lives in a hook of its own; the holiday provider cases get a suite of their own
DayPlanSidebar grew past its size baseline with the Firefox drag fix. Its drag state (what is dragged, the drop line, the day under the drag, whether a drag happened) and the effect that ends a lost drag move into useDayPlanDragState, typed, which also lowers the file's strict-mode count; size and strict baselines are lowered with it. vacay.service.test.ts went past the 2000-line limit once the Nager v4 cases were back in it. They move into vacay.holiday-provider.test.ts, which takes VacayService from a Nest testing module over a snapshot db, so neither the db mock nor a hand-built service is needed.
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
"src/components/Map/MapViewGL.tsx": 2126,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2910,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1287,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 40,
|
||||
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
|
||||
"src/components/Planner/DayPlanSidebar.constants.ts": 1,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 61,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 52,
|
||||
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 3,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
||||
import type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { useDayPlanDragState } from './useDayPlanDragState'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { TourOverrunLine } from '../Tours/TourDayTimeParts'
|
||||
import { dayItemOverrun } from '../Tours/tourDayTime'
|
||||
@@ -246,17 +247,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// distances refresh instead of showing stale cached text (#1300).
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, _setDraggingId] = useState(null)
|
||||
// The day list fades in once, on mount. Switching its stagger back on when a
|
||||
// drag ended played that entrance again on every drop, so the first drag turns
|
||||
// it off for good (#3041).
|
||||
const [hasDragged, setHasDragged] = useState(false)
|
||||
const setDraggingId = (id) => { if (id != null) setHasDragged(true); _setDraggingId(id) }
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
@@ -273,6 +264,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
||||
} | null>(null)
|
||||
const dragDataRef = useRef(null)
|
||||
const {
|
||||
draggingId, setDraggingId, hasDragged, dropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId,
|
||||
} = useDayPlanDragState(dragDataRef)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const dayRefs = useRef<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
@@ -381,31 +375,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
return () => document.removeEventListener('dragend', cleanup)
|
||||
}, [])
|
||||
|
||||
// A drag whose source row re-rendered away never sends its dragend to the
|
||||
// document, and the drop line then stayed on a row until the next reload. The
|
||||
// browser sends no mousemove during a drag, so the first plain mouse move or
|
||||
// press after one means it is over. Plain means no button held: Firefox fires
|
||||
// dragstart while it handles the move that starts the drag and hands that move
|
||||
// to the page afterwards, button still down. Read as the end, it threw the drag
|
||||
// away at pick-up and left only the arrows to reorder a day (#3041).
|
||||
useEffect(() => {
|
||||
if (!draggingId && !dropTargetKey && !dragOverDayId) return
|
||||
const settle = (e: MouseEvent | PointerEvent) => {
|
||||
if (e.type === 'mousemove' && e.buttons !== 0) return
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
setDragOverDayId(null)
|
||||
dragDataRef.current = null
|
||||
window.__dragData = null
|
||||
}
|
||||
document.addEventListener('mousemove', settle)
|
||||
document.addEventListener('pointerdown', settle, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', settle)
|
||||
document.removeEventListener('pointerdown', settle, true)
|
||||
}
|
||||
}, [draggingId, dropTargetKey, dragOverDayId])
|
||||
|
||||
// Initialize missing transport positions outside of render to avoid setState-during-render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations])
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from 'react';
|
||||
|
||||
/** A stop's or a day's id, as the day plan's drag handlers pass them around. */
|
||||
type DragKey = string | number | null;
|
||||
|
||||
/**
|
||||
* The drag state of the day plan (DayPlanSidebar): what is being dragged, which
|
||||
* row the drop line stands on, which day the drag is over, and whether a drag
|
||||
* happened yet.
|
||||
*
|
||||
* The day list fades in once, on mount. Switching its stagger back on when a
|
||||
* drag ended played that entrance again on every drop, so the first drag turns
|
||||
* it off for good (#3041).
|
||||
*
|
||||
* A drag whose source row re-rendered away never sends its dragend to the
|
||||
* document, and the drop line then stayed on a row until the next reload. The
|
||||
* browser sends no mousemove during a drag, so the first plain mouse move or
|
||||
* press after one means it is over. Plain means no button held: Firefox fires
|
||||
* dragstart while it handles the move that starts the drag and hands that move
|
||||
* to the page afterwards, button still down. Read as the end, it threw the drag
|
||||
* away at pick-up and left only the arrows to reorder a day (#3041).
|
||||
*/
|
||||
export function useDayPlanDragState(dragDataRef: MutableRefObject<unknown>) {
|
||||
const [draggingId, setDraggingIdState] = useState<DragKey>(null);
|
||||
const [hasDragged, setHasDragged] = useState(false);
|
||||
const [dropTargetKey, setDropTargetKeyState] = useState<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(null);
|
||||
const [dragOverDayId, setDragOverDayId] = useState<DragKey>(null);
|
||||
|
||||
const setDraggingId = (id: DragKey) => {
|
||||
if (id != null) setHasDragged(true);
|
||||
setDraggingIdState(id);
|
||||
};
|
||||
const setDropTargetKey = (key: string | null) => {
|
||||
dropTargetRef.current = key;
|
||||
setDropTargetKeyState(key);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingId && !dropTargetKey && !dragOverDayId) return;
|
||||
const settle = (e: MouseEvent | PointerEvent) => {
|
||||
if (e.type === 'mousemove' && e.buttons !== 0) return;
|
||||
setDraggingId(null);
|
||||
setDropTargetKey(null);
|
||||
setDragOverDayId(null);
|
||||
dragDataRef.current = null;
|
||||
window.__dragData = null;
|
||||
};
|
||||
document.addEventListener('mousemove', settle);
|
||||
document.addEventListener('pointerdown', settle, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', settle);
|
||||
document.removeEventListener('pointerdown', settle, true);
|
||||
};
|
||||
// The setters close over nothing that changes; the effect follows the drag state only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draggingId, dropTargetKey, dragOverDayId]);
|
||||
|
||||
return {
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
hasDragged,
|
||||
dropTargetKey,
|
||||
dropTargetRef,
|
||||
setDropTargetKey,
|
||||
dragOverDayId,
|
||||
setDragOverDayId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* VacayService and its public holiday provider (Nager.Holidays v4): fetch
|
||||
* hygiene, the size cap, malformed codes, the mapping of v4 rows and the cache
|
||||
* with its TTL. A module of its own, built through Nest, with the provider
|
||||
* cache cleared before every case.
|
||||
*/
|
||||
import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity';
|
||||
import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity';
|
||||
import { VacayModule } from '../../../src/nest/vacay/vacay.module';
|
||||
import { VacayService } from '../../../src/nest/vacay/vacay.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { asLegacyResult } from '../../helpers/domain-error';
|
||||
import { insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let svc: VacayService;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [VacayModule] });
|
||||
svc = t.get(VacayService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(svc as unknown as { holidayCache: Map<string, unknown> }).holidayCache.clear();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
/** A user with their own plan. */
|
||||
async function setupUserWithPlan() {
|
||||
const { user } = await makeUser(orm());
|
||||
return { user, plan: await svc.getOwnPlan(user.id) };
|
||||
}
|
||||
|
||||
describe('Vacay holiday provider', () => {
|
||||
it('VACAY-SVC-070: reports an upstream non-2xx and does not cache the error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://nagerholidays.com/api/v4/Countries/Available',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070e: maps Nager v4 holidays and caches by year and country', async () => {
|
||||
const holidays = [
|
||||
{
|
||||
date: '2026-01-01',
|
||||
name: "New Year's Day",
|
||||
countryCode: 'US',
|
||||
nationalHoliday: true,
|
||||
subdivisionCodes: null,
|
||||
holidayTypes: ['Public'],
|
||||
},
|
||||
{
|
||||
date: '2026-02-16',
|
||||
name: 'Washington’s Birthday',
|
||||
countryCode: 'US',
|
||||
nationalHoliday: false,
|
||||
subdivisionCodes: ['US-CA'],
|
||||
holidayTypes: ['Public'],
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => holidays });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
const expected = [
|
||||
{ date: '2026-01-01', name: "New Year's Day", localName: "New Year's Day", global: true, counties: null },
|
||||
{
|
||||
date: '2026-02-16',
|
||||
name: 'Washington’s Birthday',
|
||||
localName: 'Washington’s Birthday',
|
||||
global: false,
|
||||
counties: ['US-CA'],
|
||||
},
|
||||
];
|
||||
expect((await fresh.getHolidays('2026', 'US')).data).toEqual(expected);
|
||||
expect((await fresh.getHolidays('2026', 'US')).data).toEqual(expected);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://nagerholidays.com/api/v4/Holidays/US/2026',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070f: passes through v4 country records and caches them', async () => {
|
||||
const countries = [
|
||||
{ countryCode: 'US', name: 'United States' },
|
||||
{ countryCode: 'CN', name: 'China' },
|
||||
];
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => countries });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
expect((await fresh.getCountries()).data).toEqual(countries);
|
||||
expect((await fresh.getCountries()).data).toEqual(countries);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://nagerholidays.com/api/v4/Countries/Available',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070g: rejects an invalid v4 payload without caching it', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [{ date: '2026-01-01', name: 'New Year' }] })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
expect(await asLegacyResult(fresh.getHolidays('2026', 'CN'))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
expect((await fresh.getHolidays('2026', 'CN')).data).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070a: refuses malformed holiday URL segments', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
for (const [year, country] of [
|
||||
['../../..', 'DE'],
|
||||
['2026', 'DE/../../x'],
|
||||
['20xx', 'DE'],
|
||||
['2026', 'DEU'],
|
||||
]) {
|
||||
expect(await asLegacyResult(fresh.getHolidays(year, country))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070b: refuses a malformed school holiday country code', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
expect(await asLegacyResult(fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR'))).toEqual({
|
||||
error: 'Failed to fetch school holiday regions',
|
||||
status: 502,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070c: rejects a provider body over the declared size cap', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: (h: string) => (h === 'content-length' ? String(50 * 1024 * 1024) : null) },
|
||||
json: async () => [{ date: '2026-01-01' }],
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
expect(await asLegacyResult(fresh.getHolidays('2026', 'DE'))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070d: rejects a chunked provider body when its stream exceeds the size cap', async () => {
|
||||
const payload = `[${'{"date":"2026-01-01"},'.repeat(200_000)}{"date":"2026-12-24"}]`;
|
||||
const fetchMock = vi.fn().mockImplementation(async () => {
|
||||
let sent = false;
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () =>
|
||||
sent ? { done: true } : ((sent = true), { done: false, value: new TextEncoder().encode(payload) }),
|
||||
cancel: async () => undefined,
|
||||
}),
|
||||
cancel: async () => undefined,
|
||||
},
|
||||
json: async () => JSON.parse(payload),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
expect(await asLegacyResult(fresh.getHolidays('2026', 'DE'))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
});
|
||||
|
||||
it('VACAY-SVC-071: uses the holiday cache until its TTL expires', async () => {
|
||||
const { plan } = await setupUserWithPlan();
|
||||
await updateRows(orm(), VacayPlans, { id: plan.id }, { holidays_enabled: 1 });
|
||||
await insertRow(orm(), VacayHolidayCalendars, { plan: plan.id, region: 'DE' });
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = svc;
|
||||
|
||||
await fresh.applyHolidayCalendars(plan.id);
|
||||
const afterFirst = fetchMock.mock.calls.length;
|
||||
await fresh.applyHolidayCalendars(plan.id);
|
||||
expect(fetchMock.mock.calls.length).toBe(afterFirst);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000 + 1);
|
||||
await fresh.applyHolidayCalendars(plan.id);
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirst);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1685,13 +1685,8 @@ describe('getSharedCalendars', () => {
|
||||
// ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ──────
|
||||
|
||||
describe('quirk fixes', () => {
|
||||
/** A fresh, fully-functioning VacayService over the same testDb: the holiday-provider cache is instance state, so the provider and TTL tests need a service the earlier tests in this file never touched. */
|
||||
async function freshVacayService(): Promise<VacayService> {
|
||||
return (await buildVacayServiceWithRepos()).service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same repo set `freshVacayService` builds, but returns the repos
|
||||
* A fresh VacayService over the same testDb, but returns the repos
|
||||
* themselves too, so a caller can `vi.spyOn` one of them before
|
||||
* constructing — the repository-backed replacement for the legacy
|
||||
* `failingService`'s `DatabaseService.run` SQL-text-match spy (this file's
|
||||
@@ -1769,194 +1764,6 @@ describe('quirk fixes', () => {
|
||||
expect(await storedFields(VacayEntries, { plan: plan.id }, ['id'])).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070: reports an upstream non-2xx and does not cache the error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://nagerholidays.com/api/v4/Countries/Available',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070e: maps Nager v4 holidays and caches by year and country', async () => {
|
||||
const holidays = [
|
||||
{
|
||||
date: '2026-01-01',
|
||||
name: "New Year's Day",
|
||||
countryCode: 'US',
|
||||
nationalHoliday: true,
|
||||
subdivisionCodes: null,
|
||||
holidayTypes: ['Public'],
|
||||
},
|
||||
{
|
||||
date: '2026-02-16',
|
||||
name: 'Washington’s Birthday',
|
||||
countryCode: 'US',
|
||||
nationalHoliday: false,
|
||||
subdivisionCodes: ['US-CA'],
|
||||
holidayTypes: ['Public'],
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => holidays });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
const expected = [
|
||||
{ date: '2026-01-01', name: "New Year's Day", localName: "New Year's Day", global: true, counties: null },
|
||||
{
|
||||
date: '2026-02-16',
|
||||
name: 'Washington’s Birthday',
|
||||
localName: 'Washington’s Birthday',
|
||||
global: false,
|
||||
counties: ['US-CA'],
|
||||
},
|
||||
];
|
||||
expect((await fresh.getHolidays('2026', 'US')).data).toEqual(expected);
|
||||
expect((await fresh.getHolidays('2026', 'US')).data).toEqual(expected);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://nagerholidays.com/api/v4/Holidays/US/2026',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070f: passes through v4 country records and caches them', async () => {
|
||||
const countries = [
|
||||
{ countryCode: 'US', name: 'United States' },
|
||||
{ countryCode: 'CN', name: 'China' },
|
||||
];
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => countries });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
expect((await fresh.getCountries()).data).toEqual(countries);
|
||||
expect((await fresh.getCountries()).data).toEqual(countries);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://nagerholidays.com/api/v4/Countries/Available',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070g: rejects an invalid v4 payload without caching it', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [{ date: '2026-01-01', name: 'New Year' }] })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
expect(await asLegacyResult(fresh.getHolidays('2026', 'CN'))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
expect((await fresh.getHolidays('2026', 'CN')).data).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070a: refuses malformed holiday URL segments', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
for (const [year, country] of [
|
||||
['../../..', 'DE'],
|
||||
['2026', 'DE/../../x'],
|
||||
['20xx', 'DE'],
|
||||
['2026', 'DEU'],
|
||||
]) {
|
||||
expect(await asLegacyResult(fresh.getHolidays(year, country))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070b: refuses a malformed school holiday country code', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
expect(await asLegacyResult(fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR'))).toEqual({
|
||||
error: 'Failed to fetch school holiday regions',
|
||||
status: 502,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070c: rejects a provider body over the declared size cap', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: (h: string) => (h === 'content-length' ? String(50 * 1024 * 1024) : null) },
|
||||
json: async () => [{ date: '2026-01-01' }],
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
expect(await asLegacyResult(fresh.getHolidays('2026', 'DE'))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it('VACAY-SVC-070d: rejects a chunked provider body when its stream exceeds the size cap', async () => {
|
||||
const payload = `[${'{"date":"2026-01-01"},'.repeat(200_000)}{"date":"2026-12-24"}]`;
|
||||
const fetchMock = vi.fn().mockImplementation(async () => {
|
||||
let sent = false;
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () =>
|
||||
sent ? { done: true } : ((sent = true), { done: false, value: new TextEncoder().encode(payload) }),
|
||||
cancel: async () => undefined,
|
||||
}),
|
||||
cancel: async () => undefined,
|
||||
},
|
||||
json: async () => JSON.parse(payload),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
expect(await asLegacyResult(fresh.getHolidays('2026', 'DE'))).toEqual({
|
||||
error: 'Failed to fetch holidays',
|
||||
status: 502,
|
||||
});
|
||||
expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 });
|
||||
});
|
||||
|
||||
it('VACAY-SVC-071: uses the holiday cache until its TTL expires', async () => {
|
||||
const { plan } = await setupUserWithPlan();
|
||||
await updateRows(await orm(), VacayPlans, { id: plan.id }, { holidays_enabled: 1 });
|
||||
await insertRow(await orm(), VacayHolidayCalendars, { plan: plan.id, region: 'DE' });
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const fresh = await freshVacayService();
|
||||
|
||||
await fresh.applyHolidayCalendars(plan.id);
|
||||
const afterFirst = fetchMock.mock.calls.length;
|
||||
await fresh.applyHolidayCalendars(plan.id);
|
||||
expect(fetchMock.mock.calls.length).toBe(afterFirst);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000 + 1);
|
||||
await fresh.applyHolidayCalendars(plan.id);
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirst);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('VACAY-SVC-072: addYear still no-ops on a duplicate year but propagates real errors', async () => {
|
||||
const { plan } = await setupUserWithPlan();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
Reference in New Issue
Block a user