mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(vacay): implement Nager.Holidays v4 integration and adapt holiday data structure
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user