Files
TREK/server/tests/unit/nest/vacay.controller.test.ts
T
Elmer 7bd9e08016 feat(vacay): add school holiday overlays (#1633)
* feat: add vacay school holiday overlays

* fix(vacay): remove SOH control character corruption in locale files

13 of 22 vacay locale files contained a 0x01 (SOH) control character
that corrupted the closing }; token. This broke the shared package
build (tsdown/rolldown PARSE_ERROR: Invalid Character) and cascaded
to all client tests failing to resolve @trek/shared.

Affected locales: ar, br, ca, cs, es, fr, gr, hu, id, it, ja, ko,
nl, pl, ru, sv, tr, uk, vi, zh, zh-TW

* fix(vacay): school-holiday migration order, i18n dupes, settings layout

- Move the school-holiday columns migration to the end of the migrations
  array. It sat before the #1435/#1281 migrations, which every existing DB
  had already run, so the runner skipped it and school_holidays_enabled /
  vacay_holiday_calendars.type were never created — PUT /addons/vacay/plan
  then 500'd with "no such column".
- Remove duplicate i18n keys in 13 locale vacay files (TS1117) that failed
  the shared + client typechecks.
- Move the school-holidays block into the right column, directly under
  public holidays, instead of full-width below everything.
- Add the school-holidays section to the mobile Vacay settings sheet.

* feat(vacay): modern school-holiday day styling + combined hover tooltip

- Replace the stacked 2px underlines with a single rounded accent band and a
  soft background wash on plain school-break days (desktop + mobile — mobile
  did not render school holidays at all before).
- Fold school holidays into the custom day tooltip under a divider instead of a
  separate native title, and show the tooltip on every set day (whole vacation
  days too), not only half days / shared calendars.

---------

Co-authored-by: Otto <uif86164@automotive-wan.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-07-23 22:48:42 +02:00

243 lines
12 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { VacayController } from '../../../src/nest/vacay/vacay.controller';
import type { VacayService } from '../../../src/nest/vacay/vacay.service';
import type { User } from '../../../src/types';
const user = { id: 1, username: 'u', email: 'u@example.test', role: 'user' } as User;
function makeController(svc: Partial<VacayService>) {
return new VacayController(svc as VacayService);
}
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected the handler to throw');
}
// Default plan helpers shared by most handlers.
const planBase = { getActivePlanId: vi.fn().mockReturnValue(10), getActivePlan: vi.fn().mockReturnValue({ id: 10 }) };
describe('VacayController (parity with the legacy /api/addons/vacay route)', () => {
it('GET /plan delegates getPlanData', () => {
const getPlanData = vi.fn().mockReturnValue({ plan: { id: 10 } });
expect(makeController({ getPlanData }).getPlan(user)).toEqual({ plan: { id: 10 } });
});
it('PUT /plan forwards the socket id', async () => {
const updatePlan = vi.fn().mockResolvedValue({ ok: true });
await makeController({ ...planBase, updatePlan }).updatePlan(user, { foo: 1 }, 'sock-1');
expect(updatePlan).toHaveBeenCalledWith(10, { foo: 1 }, 'sock-1');
});
describe('holiday calendars', () => {
it('400 when region missing', () => {
return thrown(() => makeController({ ...planBase }).addHolidayCalendar(user, {})).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'region required' } }));
});
it('creates a calendar', () => {
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
expect(res).toEqual({ calendar: { id: 1, region: 'DE-BY' } });
expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock', undefined);
});
it('404 on update of a missing calendar', () => {
const updateHolidayCalendar = vi.fn().mockReturnValue(null);
return thrown(() => makeController({ ...planBase, updateHolidayCalendar }).updateHolidayCalendar(user, '9', {})).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Calendar not found' } }));
});
it('404 on delete of a missing calendar', () => {
const deleteHolidayCalendar = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ ...planBase, deleteHolidayCalendar }).deleteHolidayCalendar(user, '9')).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Calendar not found' } }));
});
});
describe('color', () => {
it('403 when the target user is not in the plan', () => {
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
return thrown(() => makeController({ ...planBase, getPlanUsers }).setColor(user, { color: '#fff', target_user_id: 99 })).then((r) =>
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
});
it('sets the colour for an in-plan user', () => {
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
const setUserColor = vi.fn();
expect(makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock')).toEqual({ success: true });
expect(setUserColor).toHaveBeenCalledWith(1, 10, '#fff', 'sock');
});
});
describe('invites', () => {
it('400 when user_id missing', () => {
return thrown(() => makeController({ ...planBase }).invite(user, undefined)).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }));
});
it('maps a sendInvite error to its status', () => {
const sendInvite = vi.fn().mockReturnValue({ error: 'Already in a plan', status: 409 });
return thrown(() => makeController({ ...planBase, sendInvite }).invite(user, 2)).then((r) =>
expect(r).toEqual({ status: 409, body: { error: 'Already in a plan' } }));
});
it('sends an invite', () => {
const sendInvite = vi.fn().mockReturnValue({});
expect(makeController({ ...planBase, sendInvite }).invite(user, 2)).toEqual({ success: true });
expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2);
});
it('maps an acceptInvite error', () => {
const acceptInvite = vi.fn().mockReturnValue({ error: 'Invite not found', status: 404 });
return thrown(() => makeController({ acceptInvite }).acceptInvite(user, 5)).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Invite not found' } }));
});
it('decline / cancel / dissolve return success', () => {
const declineInvite = vi.fn(); const cancelInvite = vi.fn(); const dissolvePlan = vi.fn();
expect(makeController({ declineInvite }).declineInvite(user, 5)).toEqual({ success: true });
expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, 2)).toEqual({ success: true });
expect(makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true });
});
});
describe('years', () => {
it('400 when year missing on add', () => {
return thrown(() => makeController({ ...planBase }).addYear(user, undefined)).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Year required' } }));
});
it('adds and deletes years', () => {
const addYear = vi.fn().mockReturnValue([2026]); const deleteYear = vi.fn().mockReturnValue([]);
expect(makeController({ ...planBase, addYear }).addYear(user, 2026, 'sock')).toEqual({ years: [2026] });
expect(makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] });
});
});
describe('entries', () => {
it('400 when date missing on toggle', () => {
return thrown(() => makeController({ ...planBase }).toggleEntry(user, {})).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'date required' } }));
});
it('403 when toggling for a user not in the plan', () => {
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
return thrown(() => makeController({ ...planBase, getPlanUsers }).toggleEntry(user, { date: '2026-07-01', target_user_id: 99 })).then((r) =>
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
});
it('toggles for the caller', () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added' });
expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' });
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock');
});
it('forwards the half-day fraction (#552)', () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 });
makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock');
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, 'sock');
});
});
describe('stats', () => {
it('GET wraps stats', () => {
const getStats = vi.fn().mockReturnValue({ used: 5 });
expect(makeController({ ...planBase, getStats }).stats(user, '2026')).toEqual({ stats: { used: 5 } });
});
it('403 on updateStats for a user not in the plan', () => {
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
return thrown(() => makeController({ ...planBase, getPlanUsers }).updateStats(user, '2026', { vacation_days: 30, target_user_id: 99 })).then((r) =>
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
});
});
describe('public holidays', () => {
it('502 when the upstream country lookup fails', () => {
const getCountries = vi.fn().mockResolvedValue({ error: 'upstream down' });
return thrown(() => makeController({ getCountries }).holidayCountries()).then((r) =>
expect(r).toEqual({ status: 502, body: { error: 'upstream down' } }));
});
it('returns the country data on success', async () => {
const getCountries = vi.fn().mockResolvedValue({ data: [{ code: 'DE' }] });
expect(await makeController({ getCountries }).holidayCountries()).toEqual([{ code: 'DE' }]);
});
it('502 when the holidays lookup fails', () => {
const getHolidays = vi.fn().mockResolvedValue({ error: 'upstream down' });
return thrown(() => makeController({ getHolidays }).holidays('2026', 'DE')).then((r) =>
expect(r).toEqual({ status: 502, body: { error: 'upstream down' } }));
});
});
describe('read-only shares', () => {
it('GET /shares delegates listShares', () => {
const listShares = vi.fn().mockReturnValue({ outgoing: [{ id: 1 }], incoming: [] });
expect(makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] });
expect(listShares).toHaveBeenCalledWith(1);
});
it('400 when user_id missing on share', () => {
return thrown(() => makeController({}).share(user, undefined)).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }));
});
it('maps a shareCalendar error to its status', () => {
const shareCalendar = vi.fn().mockReturnValue({ error: 'Already shared', status: 400 });
return thrown(() => makeController({ shareCalendar }).share(user, 2)).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Already shared' } }));
});
it('shares a calendar (user_id coerced to a number, socket id forwarded)', () => {
const shareCalendar = vi.fn().mockReturnValue({});
expect(makeController({ shareCalendar }).share(user, '2', 'sock-1')).toEqual({ success: true });
expect(shareCalendar).toHaveBeenCalledWith(1, 'u@example.test', 2, 'sock-1');
});
it('GET /shares/available-users wraps the user list', () => {
const getShareAvailableUsers = vi.fn().mockReturnValue([{ id: 3 }]);
expect(makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({ users: [{ id: 3 }] });
expect(getShareAvailableUsers).toHaveBeenCalledWith(1);
});
it('GET /shares/calendars/:year wraps the calendars', () => {
const getSharedCalendars = vi.fn().mockReturnValue([{ share_id: 4 }]);
expect(makeController({ getSharedCalendars }).sharedCalendars(user, '2026')).toEqual({ calendars: [{ share_id: 4 }] });
expect(getSharedCalendars).toHaveBeenCalledWith(1, '2026');
});
it('PUT /shares/:id forwards the socket id and the hidden flag', () => {
const setShareHidden = vi.fn().mockReturnValue(true);
expect(makeController({ setShareHidden }).updateShare(user, '7', { hidden: true }, 'sock-1')).toEqual({ success: true });
expect(setShareHidden).toHaveBeenCalledWith(7, 1, true, 'sock-1');
});
it('404 on PUT when the share is not toggleable by the caller', () => {
const setShareHidden = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ setShareHidden }).updateShare(user, '9', { hidden: true })).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Share not found' } }));
});
it('DELETE /shares/:id forwards the socket id', () => {
const removeShare = vi.fn().mockReturnValue(true);
expect(makeController({ removeShare }).deleteShare(user, '7', 'sock-1')).toEqual({ success: true });
expect(removeShare).toHaveBeenCalledWith(7, 1, 'sock-1');
});
it('404 on DELETE of a foreign share', () => {
const removeShare = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ removeShare }).deleteShare(user, '9')).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Share not found' } }));
});
});
});