feat(vacay): implement Nager.Holidays v4 integration and adapt holiday data structure

This commit is contained in:
Luffy
2026-11-11 17:39:14 +08:00
committed by Maurice
parent be305c3daf
commit a3622e91b6
8 changed files with 405 additions and 222 deletions
@@ -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(<><ToastContainer /><MAdminAddonManager /></>);
await screen.findByText('Todo List');
+1 -1
View File
@@ -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
}
+42
View File
@@ -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;
}
+2 -39
View File
@@ -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;
@@ -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,
},
]);
});
});
+10 -26
View File
@@ -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);
@@ -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<string, unknown> }).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();
}
});
});
@@ -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<VacayService> {
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();