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();