From a3622e91b627483d9f9d38d5fe076459ef734bb3 Mon Sep 17 00:00:00 2001
From: Luffy <52o@qq52o.cn>
Date: Wed, 11 Nov 2026 17:39:14 +0800
Subject: [PATCH] feat(vacay): implement Nager.Holidays v4 integration and
adapt holiday data structure
---
.../mobile/admin/MAdminAddonManager.test.tsx | 2 +-
server/scripts/size-baseline.json | 2 +-
server/src/nest/vacay/vacay.holidays.ts | 42 +++
server/src/nest/vacay/vacay.service.ts | 41 +--
.../tests/integration/vacay.holidays.test.ts | 82 ++++++
server/tests/integration/vacay.test.ts | 36 +--
.../unit/nest/vacay.service.holidays.test.ts | 267 ++++++++++++++++++
server/tests/unit/nest/vacay.service.test.ts | 155 ----------
8 files changed, 405 insertions(+), 222 deletions(-)
create mode 100644 server/src/nest/vacay/vacay.holidays.ts
create mode 100644 server/tests/integration/vacay.holidays.test.ts
create mode 100644 server/tests/unit/nest/vacay.service.holidays.test.ts
diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
index e7e630de9..e6f9b8f73 100644
--- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
+++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
@@ -169,7 +169,7 @@ describe('MAdminAddonManager', () => {
const user = userEvent.setup();
server.use(
addonsRoute([buildAddon({ id: 'todo', enabled: true })]),
- http.put('/api/admin/addons/todo', () => HttpResponse.error()),
+ http.put('/api/admin/addons/todo', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
render(<>>);
await screen.findByText('Todo List');
diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json
index 81e2eb780..8ce89647a 100644
--- a/server/scripts/size-baseline.json
+++ b/server/scripts/size-baseline.json
@@ -16,5 +16,5 @@
"src/nest/plugins/plugin-runtime.service.ts": 1460,
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1228,
- "src/nest/vacay/vacay.service.ts": 1409
+ "src/nest/vacay/vacay.service.ts": 1393
}
diff --git a/server/src/nest/vacay/vacay.holidays.ts b/server/src/nest/vacay/vacay.holidays.ts
new file mode 100644
index 000000000..11e871e3a
--- /dev/null
+++ b/server/src/nest/vacay/vacay.holidays.ts
@@ -0,0 +1,42 @@
+export interface Holiday {
+ date: string;
+ localName?: string;
+ name?: string;
+ global?: boolean;
+ counties?: string[] | null;
+}
+
+interface NagerHolidayV4 {
+ date: string;
+ name: string;
+ nationalHoliday: boolean;
+ subdivisionCodes: string[] | null;
+}
+
+/** Adapt Nager.Holidays v4 records to Vacay's long-standing holiday shape. */
+export function normalizeNagerHolidays(data: unknown): Holiday[] | undefined {
+ if (!Array.isArray(data)) return undefined;
+ const holidays: Holiday[] = [];
+ for (const row of data) {
+ if (
+ !row ||
+ typeof row !== 'object' ||
+ typeof row.date !== 'string' ||
+ typeof row.name !== 'string' ||
+ typeof row.nationalHoliday !== 'boolean' ||
+ (row.subdivisionCodes !== null &&
+ (!Array.isArray(row.subdivisionCodes) ||
+ row.subdivisionCodes.some((code: unknown) => typeof code !== 'string')))
+ )
+ return undefined;
+ const holiday = row as NagerHolidayV4;
+ holidays.push({
+ date: holiday.date,
+ name: holiday.name,
+ localName: holiday.name,
+ global: holiday.nationalHoliday,
+ counties: holiday.subdivisionCodes,
+ });
+ }
+ return holidays;
+}
diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts
index cd478e1d0..5d5396e12 100644
--- a/server/src/nest/vacay/vacay.service.ts
+++ b/server/src/nest/vacay/vacay.service.ts
@@ -28,10 +28,12 @@ import { VacayUserSettings as VacayUserSettingsEntity } from '../../db/entities/
import type { VacayUserSettingsRepository } from '../../db/repositories/VacayUserSettings.repository';
import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.entity';
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
+import { normalizeNagerHolidays, type Holiday } from './vacay.holidays';
import {
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
} from './vacay.helpers';
+export type { Holiday } from './vacay.holidays';
// ---------------------------------------------------------------------------
// Types
@@ -72,45 +74,6 @@ export interface VacayPlanMember {
created_at?: string;
}
-export interface Holiday {
- date: string;
- localName?: string;
- name?: string;
- global?: boolean;
- counties?: string[] | null;
-}
-
-interface NagerHolidayV4 {
- date: string;
- name: string;
- nationalHoliday: boolean;
- subdivisionCodes: string[] | null;
-}
-
-/** Adapt Nager.Holidays v4 records to Vacay's long-standing holiday shape. */
-function normalizeNagerHolidays(data: unknown): Holiday[] | undefined {
- if (!Array.isArray(data)) return undefined;
- const holidays: Holiday[] = [];
- for (const row of data) {
- if (
- !row || typeof row !== 'object' ||
- typeof row.date !== 'string' || typeof row.name !== 'string' ||
- typeof row.nationalHoliday !== 'boolean' ||
- (row.subdivisionCodes !== null &&
- (!Array.isArray(row.subdivisionCodes) || row.subdivisionCodes.some((code: unknown) => typeof code !== 'string')))
- ) return undefined;
- const holiday = row as NagerHolidayV4;
- holidays.push({
- date: holiday.date,
- name: holiday.name,
- localName: holiday.name,
- global: holiday.nationalHoliday,
- counties: holiday.subdivisionCodes,
- });
- }
- return holidays;
-}
-
export interface SchoolHoliday {
startDate?: string;
endDate?: string;
diff --git a/server/tests/integration/vacay.holidays.test.ts b/server/tests/integration/vacay.holidays.test.ts
new file mode 100644
index 000000000..c1e286390
--- /dev/null
+++ b/server/tests/integration/vacay.holidays.test.ts
@@ -0,0 +1,82 @@
+/** Vacay public holiday provider integration tests. */
+import { buildApp } from '../../src/bootstrap';
+import { db as testDb } from '../../src/db/database';
+import { VacayService } from '../../src/nest/vacay/vacay.service';
+import { authCookie } from '../helpers/auth';
+import { createUser } from '../helpers/factories';
+import { resetTestDb, resetRateLimits } from '../helpers/test-db';
+import type { INestApplication } from '@nestjs/common';
+
+import type { Application } from 'express';
+import request from 'supertest';
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+
+vi.mock('../../src/db/database', async () => {
+ const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
+ return buildDbMock(createSnapshotTestDb());
+});
+
+vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve([]),
+ }),
+);
+vi.spyOn(VacayService.prototype, 'getCountries').mockResolvedValue({ data: [] });
+
+let nestApp: INestApplication;
+let app: Application;
+beforeAll(async () => {
+ nestApp = await buildApp();
+ app = nestApp.getHttpAdapter().getInstance();
+});
+
+beforeEach(async () => {
+ resetTestDb(testDb);
+ await resetRateLimits(nestApp);
+});
+
+afterAll(async () => {
+ await nestApp.close();
+ testDb.close();
+ vi.unstubAllGlobals();
+});
+
+describe('Vacay public holidays', () => {
+ it('VACAY-035 — returns 502 when the external API fetch fails', async () => {
+ const { user } = createUser(testDb);
+ vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
+
+ const res = await request(app).get('/api/addons/vacay/holidays/2099/ZZ').set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(502);
+ });
+
+ it('VACAY-037 — adapts a Nager v4 record when the external API succeeds', async () => {
+ const { user } = createUser(testDb);
+ vi.mocked(global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve([
+ {
+ date: '2025-05-01',
+ name: 'Labour Day',
+ nationalHoliday: true,
+ subdivisionCodes: null,
+ },
+ ]),
+ });
+
+ const res = await request(app).get('/api/addons/vacay/holidays/2025/AT').set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual([
+ {
+ date: '2025-05-01',
+ name: 'Labour Day',
+ localName: 'Labour Day',
+ global: true,
+ counties: null,
+ },
+ ]);
+ });
+});
diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts
index e77df5b79..6594636d1 100644
--- a/server/tests/integration/vacay.test.ts
+++ b/server/tests/integration/vacay.test.ts
@@ -28,7 +28,16 @@ vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
- json: () => Promise.resolve([{ date: '2025-01-01', name: "New Year's Day", countryCode: 'DE' }]),
+ json: () =>
+ Promise.resolve([
+ {
+ date: '2025-01-01',
+ name: "New Year's Day",
+ countryCode: 'DE',
+ nationalHoliday: true,
+ subdivisionCodes: null,
+ },
+ ]),
}),
);
@@ -448,17 +457,6 @@ describe('Vacay stats restrictions', () => {
});
});
-describe('Vacay holidays error path', () => {
- it('VACAY-035 — GET /holidays/:year/:country returns 502 when external API fetch fails', async () => {
- const { user } = createUser(testDb);
- // Use an unusual country/year to avoid cache hits from other tests
- vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
-
- const res = await request(app).get('/api/addons/vacay/holidays/2099/ZZ').set('Cookie', authCookie(user.id));
- expect(res.status).toBe(502);
- });
-});
-
describe('Vacay color restriction', () => {
it('VACAY-036 — PUT /color with target_user_id not in plan returns 403', async () => {
const { user: owner } = createUser(testDb);
@@ -473,20 +471,6 @@ describe('Vacay color restriction', () => {
});
});
-describe('Vacay holidays success path', () => {
- it('VACAY-037 — GET /holidays/:year/:country returns data when fetch succeeds', async () => {
- const { user } = createUser(testDb);
- // Use unique year/country to avoid cache from other tests
- vi.mocked(global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([{ date: '2025-05-01', name: 'Labour Day', countryCode: 'AT' }]),
- });
-
- const res = await request(app).get('/api/addons/vacay/holidays/2025/AT').set('Cookie', authCookie(user.id));
- expect(res.status).toBe(200);
- });
-});
-
describe('Vacay toggle entry for plan member', () => {
it('VACAY-038 — POST /entries/toggle with target_user_id in plan toggles their entry', async () => {
const { user: owner } = createUser(testDb);
diff --git a/server/tests/unit/nest/vacay.service.holidays.test.ts b/server/tests/unit/nest/vacay.service.holidays.test.ts
new file mode 100644
index 000000000..3d21aab1c
--- /dev/null
+++ b/server/tests/unit/nest/vacay.service.holidays.test.ts
@@ -0,0 +1,267 @@
+import { db as testDb } from '../../../src/db/database';
+import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity';
+import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity';
+import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
+import { VacayService } from '../../../src/nest/vacay/vacay.service';
+import { asLegacyResult } from '../../helpers/domain-error';
+import { createUser } from '../../helpers/factories';
+import { insertRow, updateRows } from '../../helpers/factories/rows';
+import { notificationsStub } from '../../helpers/notifications';
+import {
+ createTestVacayHolidayCalendarsRepo,
+ createTestSchoolHolidayRegionsRepo,
+} from '../../helpers/school-holidays-repos';
+import { resetTestDb } from '../../helpers/test-db';
+import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
+import {
+ createTestVacayPlansRepo,
+ createTestVacayPlanMembersRepo,
+ createTestVacayYearsRepo,
+ createTestVacayUserYearsRepo,
+ createTestVacayUserColorsRepo,
+ createTestVacayEntriesRepo,
+ createTestVacayCompanyHolidaysRepo,
+ createTestVacaySharesRepo,
+ createTestVacayUserSettingsRepo,
+} from '../../helpers/vacay-repos';
+
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+
+vi.mock('../../../src/db/database', async () => {
+ const { createSnapshotTestDb } = await import('../../helpers/db-mock');
+ const db = createSnapshotTestDb();
+ return { db, closeDb: () => {}, reinitialize: () => {} };
+});
+
+const orm = () => sharedTestOrm(testDb);
+let svc: VacayService;
+
+async function buildVacayService() {
+ const plans = await createTestVacayPlansRepo(testDb);
+ return new VacayService(
+ plans,
+ await createTestVacayPlanMembersRepo(testDb),
+ await createTestVacayYearsRepo(testDb),
+ await createTestVacayUserYearsRepo(testDb),
+ await createTestVacayUserColorsRepo(testDb),
+ await createTestVacayEntriesRepo(testDb),
+ await createTestVacayCompanyHolidaysRepo(testDb),
+ await createTestVacayHolidayCalendarsRepo(testDb),
+ await createTestVacaySharesRepo(testDb),
+ await createTestVacayUserSettingsRepo(testDb),
+ await createTestSchoolHolidayRegionsRepo(testDb),
+ new RealtimeService(),
+ notificationsStub(),
+ await createTestUnitOfWork(testDb),
+ );
+}
+
+async function setupUserWithPlan() {
+ const { user } = createUser(testDb);
+ return { user, plan: await svc.getOwnPlan(user.id) };
+}
+
+beforeAll(async () => {
+ svc = await buildVacayService();
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+ (svc as unknown as { holidayCache: Map }).holidayCache.clear();
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }));
+});
+
+afterAll(() => {
+ vi.unstubAllGlobals();
+ testDb.close();
+});
+
+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 = await buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 buildVacayService();
+
+ 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 35d76d9ba..f02268697 100644
--- a/server/tests/unit/nest/vacay.service.test.ts
+++ b/server/tests/unit/nest/vacay.service.test.ts
@@ -1685,11 +1685,6 @@ describe('getSharedCalendars', () => {
// ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ──────
describe('quirk fixes', () => {
- /** A fresh, fully-functioning VacayService over the same testDb — repository-backed now (Plan 3f Task 5), so a fresh instance no longer needs a `DatabaseService` wrapper, only its own repo set (the holiday-provider cache is instance state, so `applyHolidayCalendars`/`getCountries`/etc.'s 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
* themselves too, so a caller can `vi.spyOn` one of them before
@@ -1769,156 +1764,6 @@ describe('quirk fixes', () => {
expect(await storedFields(VacayEntries, { plan: plan.id }, ['id'])).toBeDefined();
});
- it('VACAY-SVC-070: getCountries surfaces an upstream non-2xx as the fetch error and caches nothing', 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 });
- // Nothing cached: a retry hits the network again.
- 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: getHolidays maps Nager.Holidays v4 fields to the Vacay response 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: getCountries 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: getHolidays treats an invalid v4 payload as an upstream failure and does not cache 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: getHolidays refuses a year or country that is not a plain code', 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: getSchoolHolidayRegions refuses a country that is not an alpha-2 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: a provider body over the size cap reads as the usual fetch error', 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: a chunked provider body past the cap reads as the usual fetch error', async () => {
- // Nager.Holidays answers chunked, so there is no content-length for the declared
- // check to look at — only the streaming read stops this being buffered whole.
- 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: applyHolidayCalendars honors the cache TTL', 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);
- // Within the TTL the cached year list is reused — no new requests.
- 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();