mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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.
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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(<><ToastContainer /><MAdminAddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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,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<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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user