mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
import { DomainError } from '../../../src/nest/common/domain-error';
|
|
import { VacayController } from '../../../src/nest/vacay/vacay.controller';
|
|
import type { VacayService } from '../../../src/nest/vacay/vacay.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
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', async () => {
|
|
const getPlanData = vi.fn().mockReturnValue({ plan: { id: 10 } });
|
|
expect(await 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, { block_weekends: true }, 'sock-1');
|
|
expect(updatePlan).toHaveBeenCalledWith(10, { block_weekends: true }, 'sock-1');
|
|
});
|
|
|
|
describe('holiday calendars', () => {
|
|
// A missing region now 400s in the global ZodValidationPipe (the schema
|
|
// requires it), so the bespoke 'region required' guard is gone.
|
|
it('creates a calendar', async () => {
|
|
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
|
|
const res = await 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', async () => {
|
|
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
const setUserColor = vi.fn();
|
|
expect(
|
|
await makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock'),
|
|
).toEqual({ success: true });
|
|
expect(setUserColor).toHaveBeenCalledWith(1, 10, '#fff', 'sock');
|
|
});
|
|
});
|
|
|
|
describe('invites', () => {
|
|
// The schema requires user_id but still admits falsy values (0, '') — the
|
|
// bespoke guard stays for those, byte-identical to the legacy route.
|
|
it('400 when user_id falsy', () => {
|
|
return thrown(() => makeController({ ...planBase }).invite(user, { user_id: 0 })).then((r) =>
|
|
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }),
|
|
);
|
|
});
|
|
|
|
it('maps a sendInvite error to its status', () => {
|
|
const sendInvite = vi.fn().mockRejectedValue(new DomainError(409, 'Already in a plan'));
|
|
return thrown(() => makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).then((r) =>
|
|
expect(r).toEqual({ status: 409, body: { error: 'Already in a plan' } }),
|
|
);
|
|
});
|
|
|
|
it('sends an invite', async () => {
|
|
const sendInvite = vi.fn().mockReturnValue({});
|
|
expect(await makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true });
|
|
expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2);
|
|
});
|
|
|
|
it('maps an acceptInvite error', () => {
|
|
const acceptInvite = vi.fn().mockRejectedValue(new DomainError(404, 'Invite not found'));
|
|
return thrown(() => makeController({ acceptInvite }).acceptInvite(user, { plan_id: 5 })).then((r) =>
|
|
expect(r).toEqual({ status: 404, body: { error: 'Invite not found' } }),
|
|
);
|
|
});
|
|
|
|
it('decline / cancel / dissolve return success', async () => {
|
|
const declineInvite = vi.fn();
|
|
const cancelInvite = vi.fn();
|
|
const dissolvePlan = vi.fn();
|
|
expect(await makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true });
|
|
expect(await makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({
|
|
success: true,
|
|
});
|
|
expect(await makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true });
|
|
});
|
|
});
|
|
|
|
describe('years', () => {
|
|
// Same falsy-but-present rule as user_id: schema admits 0/'', the guard
|
|
// keeps the legacy 'Year required' body for them.
|
|
it('400 when year falsy on add', () => {
|
|
return thrown(() => makeController({ ...planBase }).addYear(user, { year: 0 })).then((r) =>
|
|
expect(r).toEqual({ status: 400, body: { error: 'Year required' } }),
|
|
);
|
|
});
|
|
|
|
it('adds and deletes years', async () => {
|
|
const addYear = vi.fn().mockReturnValue([2026]);
|
|
const deleteYear = vi.fn().mockReturnValue([]);
|
|
expect(await makeController({ ...planBase, addYear }).addYear(user, { year: 2026 }, 'sock')).toEqual({
|
|
years: [2026],
|
|
});
|
|
expect(await makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] });
|
|
});
|
|
});
|
|
|
|
describe('entries', () => {
|
|
// A missing date now 400s in the global ZodValidationPipe (schema-required),
|
|
// so the bespoke 'date required' guard is gone.
|
|
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', async () => {
|
|
const toggleEntry = vi.fn().mockReturnValue({ action: 'added' });
|
|
expect(
|
|
await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock'),
|
|
).toEqual({ action: 'added' });
|
|
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock');
|
|
});
|
|
|
|
it('forwards the half-day fraction (#552)', async () => {
|
|
const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 });
|
|
await makeController({ ...planBase, toggleEntry }).toggleEntry(
|
|
user,
|
|
{ date: '2026-07-01', fraction: 0.5 },
|
|
'sock',
|
|
);
|
|
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, undefined, 'sock');
|
|
});
|
|
|
|
it('400 with the bespoke message when the service blocks a weekend day (I-02)', () => {
|
|
const toggleEntry = vi.fn().mockReturnValue({ error: 'weekend_blocked' });
|
|
return thrown(() => makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-04' })).then(
|
|
(r) => expect(r).toEqual({ status: 400, body: { error: 'Weekend days are blocked on this plan' } }),
|
|
);
|
|
});
|
|
|
|
it('forwards the comp/flex leave type (#1074)', async () => {
|
|
const toggleEntry = vi.fn().mockReturnValue({ action: 'added', kind: 'comp' });
|
|
await makeController({ ...planBase, toggleEntry }).toggleEntry(
|
|
user,
|
|
{ date: '2026-07-01', kind: 'comp' },
|
|
'sock',
|
|
);
|
|
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'comp', 'sock');
|
|
});
|
|
|
|
it('GET reads entries over the caller window (#737)', async () => {
|
|
const getEntries = vi.fn().mockReturnValue({ entries: [], companyHolidays: [] });
|
|
await makeController({ ...planBase, getEntries }).entries(user, '2026');
|
|
expect(getEntries).toHaveBeenCalledWith(10, '2026', 1);
|
|
});
|
|
});
|
|
|
|
describe('year settings (#737)', () => {
|
|
it('GET wraps the caller settings', async () => {
|
|
const getYearSettings = vi.fn().mockReturnValue({ year_type: 'calendar' });
|
|
expect(await makeController({ getYearSettings }).yearSettings(user)).toEqual({
|
|
settings: { year_type: 'calendar' },
|
|
});
|
|
expect(getYearSettings).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('PUT saves for the caller and returns the stored settings', async () => {
|
|
const updateYearSettings = vi
|
|
.fn()
|
|
.mockReturnValue({ year_type: 'fiscal', year_start_month: 7, year_start_day: 1 });
|
|
const body = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1 };
|
|
expect(await makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({
|
|
settings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 },
|
|
});
|
|
expect(updateYearSettings).toHaveBeenCalledWith(1, body);
|
|
});
|
|
});
|
|
|
|
describe('stats', () => {
|
|
it('GET wraps stats', async () => {
|
|
const getStats = vi.fn().mockReturnValue({ used: 5 });
|
|
expect(await 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().mockRejectedValue(new DomainError(502, '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().mockRejectedValue(new DomainError(502, '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', async () => {
|
|
const listShares = vi.fn().mockReturnValue({ outgoing: [{ id: 1 }], incoming: [] });
|
|
expect(await makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] });
|
|
expect(listShares).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('400 when user_id falsy on share', () => {
|
|
return thrown(() => makeController({}).share(user, { user_id: 0 })).then((r) =>
|
|
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }),
|
|
);
|
|
});
|
|
|
|
it('maps a shareCalendar error to its status', () => {
|
|
const shareCalendar = vi.fn().mockRejectedValue(new DomainError(400, 'Already shared'));
|
|
return thrown(() => makeController({ shareCalendar }).share(user, { user_id: 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)', async () => {
|
|
const shareCalendar = vi.fn().mockReturnValue({});
|
|
expect(await makeController({ shareCalendar }).share(user, { user_id: '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', async () => {
|
|
const getShareAvailableUsers = vi.fn().mockReturnValue([{ id: 3 }]);
|
|
expect(await makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({
|
|
users: [{ id: 3 }],
|
|
});
|
|
expect(getShareAvailableUsers).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('GET /shares/calendars/:year wraps the calendars', async () => {
|
|
const getSharedCalendars = vi.fn().mockReturnValue([{ share_id: 4 }]);
|
|
expect(await 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', async () => {
|
|
const setShareHidden = vi.fn().mockReturnValue(true);
|
|
expect(await 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', async () => {
|
|
const removeShare = vi.fn().mockReturnValue(true);
|
|
expect(await 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' } }),
|
|
);
|
|
});
|
|
});
|
|
});
|