diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 93805aa4d..fa3e5978c 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -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, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index a138fdcf7..1de46c9dc 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -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, diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 7a821ef21..10cf92589 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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(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(null) const dayRefs = useRef>(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]) diff --git a/client/src/components/Planner/useDayPlanDragState.ts b/client/src/components/Planner/useDayPlanDragState.ts new file mode 100644 index 000000000..a3f6900e1 --- /dev/null +++ b/client/src/components/Planner/useDayPlanDragState.ts @@ -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) { + const [draggingId, setDraggingIdState] = useState(null); + const [hasDragged, setHasDragged] = useState(false); + const [dropTargetKey, setDropTargetKeyState] = useState(null); + const dropTargetRef = useRef(null); + const [dragOverDayId, setDragOverDayId] = useState(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, + }; +} diff --git a/server/tests/unit/nest/vacay.holiday-provider.test.ts b/server/tests/unit/nest/vacay.holiday-provider.test.ts new file mode 100644 index 000000000..2fc575b68 --- /dev/null +++ b/server/tests/unit/nest/vacay.holiday-provider.test.ts @@ -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 }).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(); + } + }); +}); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 5810b0ba4..28f4daf26 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -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 { - 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();