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:
Maurice
2026-11-11 22:38:59 +00:00
parent 742edc4a79
commit 855bf8610a
6 changed files with 311 additions and 231 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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();
}
});
});
+1 -194
View File
@@ -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();