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:
Maurice
2026-11-11 21:04:07 +00:00
parent a3622e91b6
commit 5345b50d17
7 changed files with 234 additions and 352 deletions
@@ -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,
},
]);
});
});
+38
View File
@@ -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();