From 5345b50d179fb7d044d545d4a2a75b0950702c39 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 11 Nov 2026 21:04:07 +0000 Subject: [PATCH] test(vacay): the holiday provider cases live in the existing suites again The two new test files mocked the db module and built VacayService by hand, which the test-mocks and new-service ratchets refuse for files without a baseline entry. The cases move back into vacay.service.test.ts and vacay.test.ts unchanged; VACAY-037 asks for a year no other case caches. The unrelated admin addon test change is reverted and two comments name the new provider. --- .../screens/vacay/MVacaySettingsSheet.tsx | 2 +- .../mobile/admin/MAdminAddonManager.test.tsx | 2 +- .../nest/assistant/cards/vacay/vacay-reads.ts | 2 +- .../tests/integration/vacay.holidays.test.ts | 82 ------ server/tests/integration/vacay.test.ts | 38 +++ .../unit/nest/vacay.service.holidays.test.ts | 267 ------------------ server/tests/unit/nest/vacay.service.test.ts | 193 +++++++++++++ 7 files changed, 234 insertions(+), 352 deletions(-) delete mode 100644 server/tests/integration/vacay.holidays.test.ts delete mode 100644 server/tests/unit/nest/vacay.service.holidays.test.ts diff --git a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx index e063a3b1f..2eb0a3e80 100644 --- a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx +++ b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx @@ -26,7 +26,7 @@ interface MVacaySettingsSheetProps { /** * Vacay settings sheet: weekend blocking (+ selectable weekend days), week - * start, carry-over, company holidays, public-holiday calendars (nager.at — + * start, carry-over, company holidays, public-holiday calendars (Nager.Holidays — * label/color/country/region, several) and dissolving the Fusion. */ /** Each setting is a card on the inner surface, as the trip settings' sections. */ diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index e6f9b8f73..e7e630de9 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.json({ error: 'boom' }, { status: 500 })), + http.put('/api/admin/addons/todo', () => HttpResponse.error()), ); render(<>); await screen.findByText('Todo List'); diff --git a/server/src/nest/assistant/cards/vacay/vacay-reads.ts b/server/src/nest/assistant/cards/vacay/vacay-reads.ts index 3cd87068c..f69513a03 100644 --- a/server/src/nest/assistant/cards/vacay/vacay-reads.ts +++ b/server/src/nest/assistant/cards/vacay/vacay-reads.ts @@ -181,7 +181,7 @@ export function statsOf(value: unknown): VacayStatRow[] | null { } /** - * A country's public holidays as `list_holidays` answers them (nager.at + * A country's public holidays as `list_holidays` answers them (Nager.Holidays * rows), narrowed to `region` the way the planner clears them: a holiday of * the whole country, or one of the region's own. A country that splits its * holidays by region gives nothing for a calendar without one. diff --git a/server/tests/integration/vacay.holidays.test.ts b/server/tests/integration/vacay.holidays.test.ts deleted file mode 100644 index c1e286390..000000000 --- a/server/tests/integration/vacay.holidays.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** 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 6594636d1..ef38a52b5 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -631,3 +631,41 @@ describe('Vacay read-only calendar shares', () => { expect(res.body.error).toBe('Already shared'); }); }); + +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/2087/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/unit/nest/vacay.service.holidays.test.ts b/server/tests/unit/nest/vacay.service.holidays.test.ts deleted file mode 100644 index 3d21aab1c..000000000 --- a/server/tests/unit/nest/vacay.service.holidays.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -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 f02268697..5810b0ba4 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -1685,6 +1685,11 @@ describe('getSharedCalendars', () => { // ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ────── describe('quirk fixes', () => { + /** A fresh, fully-functioning VacayService over the same testDb: the holiday-provider cache is instance state, so the provider and TTL tests need a service the earlier tests in this file never touched. */ + async function freshVacayService(): Promise { + 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 @@ -1764,6 +1769,194 @@ describe('quirk fixes', () => { expect(await storedFields(VacayEntries, { plan: plan.id }, ['id'])).toBeDefined(); }); + it('VACAY-SVC-070: reports an upstream non-2xx and does not cache the error', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 }); + expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledWith( + 'https://nagerholidays.com/api/v4/Countries/Available', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('VACAY-SVC-070e: maps Nager v4 holidays and caches by year and country', async () => { + const holidays = [ + { + date: '2026-01-01', + name: "New Year's Day", + countryCode: 'US', + nationalHoliday: true, + subdivisionCodes: null, + holidayTypes: ['Public'], + }, + { + date: '2026-02-16', + name: 'Washington’s Birthday', + countryCode: 'US', + nationalHoliday: false, + subdivisionCodes: ['US-CA'], + holidayTypes: ['Public'], + }, + ]; + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => holidays }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + const expected = [ + { date: '2026-01-01', name: "New Year's Day", localName: "New Year's Day", global: true, counties: null }, + { + date: '2026-02-16', + name: 'Washington’s Birthday', + localName: 'Washington’s Birthday', + global: false, + counties: ['US-CA'], + }, + ]; + expect((await fresh.getHolidays('2026', 'US')).data).toEqual(expected); + expect((await fresh.getHolidays('2026', 'US')).data).toEqual(expected); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://nagerholidays.com/api/v4/Holidays/US/2026', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('VACAY-SVC-070f: passes through v4 country records and caches them', async () => { + const countries = [ + { countryCode: 'US', name: 'United States' }, + { countryCode: 'CN', name: 'China' }, + ]; + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => countries }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + expect((await fresh.getCountries()).data).toEqual(countries); + expect((await fresh.getCountries()).data).toEqual(countries); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://nagerholidays.com/api/v4/Countries/Available', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('VACAY-SVC-070g: rejects an invalid v4 payload without caching it', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => [{ date: '2026-01-01', name: 'New Year' }] }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + expect(await asLegacyResult(fresh.getHolidays('2026', 'CN'))).toEqual({ + error: 'Failed to fetch holidays', + status: 502, + }); + expect((await fresh.getHolidays('2026', 'CN')).data).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('VACAY-SVC-070a: refuses malformed holiday URL segments', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + for (const [year, country] of [ + ['../../..', 'DE'], + ['2026', 'DE/../../x'], + ['20xx', 'DE'], + ['2026', 'DEU'], + ]) { + expect(await asLegacyResult(fresh.getHolidays(year, country))).toEqual({ + error: 'Failed to fetch holidays', + status: 502, + }); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('VACAY-SVC-070b: refuses a malformed school holiday country code', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + expect(await asLegacyResult(fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR'))).toEqual({ + error: 'Failed to fetch school holiday regions', + status: 502, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('VACAY-SVC-070c: rejects a provider body over the declared size cap', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: (h: string) => (h === 'content-length' ? String(50 * 1024 * 1024) : null) }, + json: async () => [{ date: '2026-01-01' }], + }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 }); + expect(await asLegacyResult(fresh.getHolidays('2026', 'DE'))).toEqual({ + error: 'Failed to fetch holidays', + status: 502, + }); + }); + + it('VACAY-SVC-070d: rejects a chunked provider body when its stream exceeds the size cap', async () => { + const payload = `[${'{"date":"2026-01-01"},'.repeat(200_000)}{"date":"2026-12-24"}]`; + const fetchMock = vi.fn().mockImplementation(async () => { + let sent = false; + return { + ok: true, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => + sent ? { done: true } : ((sent = true), { done: false, value: new TextEncoder().encode(payload) }), + cancel: async () => undefined, + }), + cancel: async () => undefined, + }, + json: async () => JSON.parse(payload), + }; + }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + expect(await asLegacyResult(fresh.getHolidays('2026', 'DE'))).toEqual({ + error: 'Failed to fetch holidays', + status: 502, + }); + expect(await asLegacyResult(fresh.getCountries())).toEqual({ error: 'Failed to fetch countries', status: 502 }); + }); + + it('VACAY-SVC-071: uses the holiday cache until its TTL expires', async () => { + const { plan } = await setupUserWithPlan(); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { holidays_enabled: 1 }); + await insertRow(await orm(), VacayHolidayCalendars, { plan: plan.id, region: 'DE' }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + const fresh = await freshVacayService(); + + await fresh.applyHolidayCalendars(plan.id); + const afterFirst = fetchMock.mock.calls.length; + await fresh.applyHolidayCalendars(plan.id); + expect(fetchMock.mock.calls.length).toBe(afterFirst); + + vi.useFakeTimers(); + try { + vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000 + 1); + await fresh.applyHolidayCalendars(plan.id); + expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirst); + } finally { + vi.useRealTimers(); + } + }); + it('VACAY-SVC-072: addYear still no-ops on a duplicate year but propagates real errors', async () => { const { plan } = await setupUserWithPlan(); const year = new Date().getFullYear();