mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(server): adopt zod DTOs for the vacay body contracts
This commit is contained in:
@@ -138,17 +138,4 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
|
||||
'UnifiedMemoriesController.addPhotos',
|
||||
'UnifiedMemoriesController.createAlbumLink',
|
||||
'UnifiedMemoriesController.setSharing',
|
||||
'VacayController.acceptInvite',
|
||||
'VacayController.addHolidayCalendar',
|
||||
'VacayController.addYear',
|
||||
'VacayController.cancelInvite',
|
||||
'VacayController.companyHoliday',
|
||||
'VacayController.declineInvite',
|
||||
'VacayController.invite',
|
||||
'VacayController.setColor',
|
||||
'VacayController.share',
|
||||
'VacayController.toggleEntry',
|
||||
'VacayController.updateHolidayCalendar',
|
||||
'VacayController.updatePlan',
|
||||
'VacayController.updateStats',
|
||||
];
|
||||
|
||||
@@ -14,7 +14,21 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { VacayService } from './vacay.service';
|
||||
import { VacayShareUpdateDto, VacayYearSettingsDto } from './vacay.dto';
|
||||
import {
|
||||
VacayAddHolidayCalendarDto,
|
||||
VacayAddYearDto,
|
||||
VacayCompanyHolidayDto,
|
||||
VacayInviteActionDto,
|
||||
VacayInviteDto,
|
||||
VacaySetColorDto,
|
||||
VacayShareDto,
|
||||
VacayShareUpdateDto,
|
||||
VacayToggleEntryDto,
|
||||
VacayUpdateHolidayCalendarDto,
|
||||
VacayUpdatePlanDto,
|
||||
VacayUpdateStatsDto,
|
||||
VacayYearSettingsDto,
|
||||
} from './vacay.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
@@ -24,8 +38,15 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
* Byte-identical to the legacy Express route (server/src/routes/vacay.ts): all
|
||||
* endpoints require auth; the X-Socket-Id header is forwarded to the services so
|
||||
* the originating client is excluded from the broadcast; POSTs answer 200 (the
|
||||
* legacy route uses res.json, not 201); and the bespoke 400/403/404/502 bodies
|
||||
* are reproduced exactly. No addon gate — the legacy mount has none.
|
||||
* legacy route uses res.json, not 201); and the bespoke 403/404/502 bodies are
|
||||
* reproduced exactly. No addon gate — the legacy mount has none.
|
||||
*
|
||||
* Bodies validate against the @trek/shared vacay schemas via the DTO classes in
|
||||
* vacay.dto.ts + the global ZodValidationPipe (400 with the standard `{ error }`
|
||||
* envelope on mismatch — this replaced the bespoke 'region required' and
|
||||
* 'date required' checks). The 'user_id required' / 'Year required' guards stay:
|
||||
* the schemas accept falsy-but-present values (0, ''), which the legacy route
|
||||
* rejected with those exact bodies.
|
||||
*/
|
||||
@Controller('api/addons/vacay')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -38,7 +59,7 @@ export class VacayController {
|
||||
}
|
||||
|
||||
@Put('plan')
|
||||
async updatePlan(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Headers('x-socket-id') socketId?: string) {
|
||||
async updatePlan(@CurrentUser() user: User, @Body() body: VacayUpdatePlanDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
return this.vacay.updatePlan(planId, body, socketId);
|
||||
}
|
||||
@@ -47,12 +68,9 @@ export class VacayController {
|
||||
@HttpCode(200)
|
||||
addHolidayCalendar(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
|
||||
@Body() body: VacayAddHolidayCalendarDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!body.region) {
|
||||
throw new HttpException({ error: 'region required' }, 400);
|
||||
}
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
|
||||
return { calendar };
|
||||
@@ -62,7 +80,7 @@ export class VacayController {
|
||||
updateHolidayCalendar(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') idParam: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Body() body: VacayUpdateHolidayCalendarDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const id = parseInt(idParam);
|
||||
@@ -87,7 +105,7 @@ export class VacayController {
|
||||
@Put('color')
|
||||
setColor(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { color?: string; target_user_id?: number | string },
|
||||
@Body() body: VacaySetColorDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
@@ -101,12 +119,12 @@ export class VacayController {
|
||||
|
||||
@Post('invite')
|
||||
@HttpCode(200)
|
||||
invite(@CurrentUser() user: User, @Body('user_id') userIdInput?: number | string) {
|
||||
if (!userIdInput) {
|
||||
invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
|
||||
if (!body.user_id) {
|
||||
throw new HttpException({ error: 'user_id required' }, 400);
|
||||
}
|
||||
const plan = this.vacay.getActivePlan(user.id);
|
||||
const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, userIdInput as number);
|
||||
const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
@@ -115,8 +133,8 @@ export class VacayController {
|
||||
|
||||
@Post('invite/accept')
|
||||
@HttpCode(200)
|
||||
acceptInvite(@CurrentUser() user: User, @Body('plan_id') planId: number, @Headers('x-socket-id') socketId?: string) {
|
||||
const result = this.vacay.acceptInvite(user.id, planId, socketId);
|
||||
acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const result = this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
@@ -125,16 +143,16 @@ export class VacayController {
|
||||
|
||||
@Post('invite/decline')
|
||||
@HttpCode(200)
|
||||
declineInvite(@CurrentUser() user: User, @Body('plan_id') planId: number, @Headers('x-socket-id') socketId?: string) {
|
||||
this.vacay.declineInvite(user.id, planId, socketId);
|
||||
declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
|
||||
this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('invite/cancel')
|
||||
@HttpCode(200)
|
||||
cancelInvite(@CurrentUser() user: User, @Body('user_id') targetUserId: number) {
|
||||
cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
|
||||
const plan = this.vacay.getActivePlan(user.id);
|
||||
this.vacay.cancelInvite(plan.id, targetUserId);
|
||||
this.vacay.cancelInvite(plan.id, body.user_id as number);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -159,12 +177,12 @@ export class VacayController {
|
||||
|
||||
@Post('years')
|
||||
@HttpCode(200)
|
||||
addYear(@CurrentUser() user: User, @Body('year') year?: number, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!year) {
|
||||
addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!body.year) {
|
||||
throw new HttpException({ error: 'Year required' }, 400);
|
||||
}
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
return { years: this.vacay.addYear(planId, year, socketId) };
|
||||
return { years: this.vacay.addYear(planId, body.year as number, socketId) };
|
||||
}
|
||||
|
||||
@Delete('years/:year')
|
||||
@@ -199,12 +217,9 @@ export class VacayController {
|
||||
@HttpCode(200)
|
||||
toggleEntry(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { date?: string; target_user_id?: number | string; fraction?: number; kind?: string },
|
||||
@Body() body: VacayToggleEntryDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!body.date) {
|
||||
throw new HttpException({ error: 'date required' }, 400);
|
||||
}
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
let userId = user.id;
|
||||
if (body.target_user_id && parseInt(String(body.target_user_id)) !== user.id) {
|
||||
@@ -221,11 +236,11 @@ export class VacayController {
|
||||
@HttpCode(200)
|
||||
companyHoliday(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { date?: string; note?: string },
|
||||
@Body() body: VacayCompanyHolidayDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const planId = this.vacay.getActivePlanId(user.id);
|
||||
return this.vacay.toggleCompanyHoliday(planId, body.date as string, body.note, socketId);
|
||||
return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId);
|
||||
}
|
||||
|
||||
@Get('stats/:year')
|
||||
@@ -239,7 +254,7 @@ export class VacayController {
|
||||
updateStats(
|
||||
@CurrentUser() user: User,
|
||||
@Param('year') yearParam: string,
|
||||
@Body() body: { vacation_days?: number; target_user_id?: number | string },
|
||||
@Body() body: VacayUpdateStatsDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const year = parseInt(yearParam);
|
||||
@@ -261,13 +276,13 @@ export class VacayController {
|
||||
@HttpCode(200)
|
||||
share(
|
||||
@CurrentUser() user: User,
|
||||
@Body('user_id') userIdInput?: number | string,
|
||||
@Body() body: VacayShareDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!userIdInput) {
|
||||
if (!body.user_id) {
|
||||
throw new HttpException({ error: 'user_id required' }, 400);
|
||||
}
|
||||
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(userIdInput)), socketId);
|
||||
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(body.user_id)), socketId);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,32 @@
|
||||
* shared Zod schemas stay the single source of truth.
|
||||
*/
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { vacayShareUpdateRequestSchema, vacayYearSettingsRequestSchema } from '@trek/shared';
|
||||
import {
|
||||
vacayAddHolidayCalendarRequestSchema,
|
||||
vacayAddYearRequestSchema,
|
||||
vacayCompanyHolidayRequestSchema,
|
||||
vacayInviteActionRequestSchema,
|
||||
vacayInviteRequestSchema,
|
||||
vacaySetColorRequestSchema,
|
||||
vacayShareRequestSchema,
|
||||
vacayShareUpdateRequestSchema,
|
||||
vacayToggleEntryRequestSchema,
|
||||
vacayUpdateHolidayCalendarRequestSchema,
|
||||
vacayUpdatePlanRequestSchema,
|
||||
vacayUpdateStatsRequestSchema,
|
||||
vacayYearSettingsRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
export class VacayUpdatePlanDto extends createZodDto(vacayUpdatePlanRequestSchema) {}
|
||||
export class VacayAddHolidayCalendarDto extends createZodDto(vacayAddHolidayCalendarRequestSchema) {}
|
||||
export class VacayUpdateHolidayCalendarDto extends createZodDto(vacayUpdateHolidayCalendarRequestSchema) {}
|
||||
export class VacaySetColorDto extends createZodDto(vacaySetColorRequestSchema) {}
|
||||
export class VacayInviteDto extends createZodDto(vacayInviteRequestSchema) {}
|
||||
export class VacayInviteActionDto extends createZodDto(vacayInviteActionRequestSchema) {}
|
||||
export class VacayAddYearDto extends createZodDto(vacayAddYearRequestSchema) {}
|
||||
export class VacayToggleEntryDto extends createZodDto(vacayToggleEntryRequestSchema) {}
|
||||
export class VacayCompanyHolidayDto extends createZodDto(vacayCompanyHolidayRequestSchema) {}
|
||||
export class VacayUpdateStatsDto extends createZodDto(vacayUpdateStatsRequestSchema) {}
|
||||
export class VacayShareDto extends createZodDto(vacayShareRequestSchema) {}
|
||||
export class VacayYearSettingsDto extends createZodDto(vacayYearSettingsRequestSchema) {}
|
||||
export class VacayShareUpdateDto extends createZodDto(vacayShareUpdateRequestSchema) {}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import * as svc from '../../services/vacayService';
|
||||
|
||||
type UpdatePlanBody = Parameters<typeof svc.updatePlan>[1];
|
||||
// The wire contract admits holidays_region: null (the MCP tool already passes
|
||||
// null through and SQLite stores NULL); the legacy UpdatePlanBody type is
|
||||
// narrower than the runtime. Widened here at the wrapper seam.
|
||||
type UpdatePlanInput = Omit<UpdatePlanBody, 'holidays_region'> & { holidays_region?: string | null };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing vacay service. All plan logic, the
|
||||
@@ -22,8 +26,8 @@ export class VacayService {
|
||||
return svc.getActivePlan(userId);
|
||||
}
|
||||
|
||||
updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) {
|
||||
return svc.updatePlan(planId, body, socketId);
|
||||
updatePlan(planId: number, body: UpdatePlanInput, socketId: string | undefined) {
|
||||
return svc.updatePlan(planId, body as UpdatePlanBody, socketId);
|
||||
}
|
||||
|
||||
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type?: 'public_holiday' | 'school_holiday') {
|
||||
|
||||
@@ -90,10 +90,10 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock-7');
|
||||
});
|
||||
|
||||
it('400 on entries/toggle without a date', async () => {
|
||||
it('400 from the Zod pipe on entries/toggle without a date', async () => {
|
||||
const res = await request(server).post('/api/addons/vacay/entries/toggle').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'date required' });
|
||||
expect(res.body.error).toContain('date');
|
||||
});
|
||||
|
||||
it('403 on color for a user not in the plan', async () => {
|
||||
|
||||
@@ -32,16 +32,13 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
|
||||
|
||||
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');
|
||||
await makeController({ ...planBase, updatePlan }).updatePlan(user, { block_weekends: true }, 'sock-1');
|
||||
expect(updatePlan).toHaveBeenCalledWith(10, { block_weekends: true }, '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' } }));
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
|
||||
const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
|
||||
@@ -78,56 +75,57 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
|
||||
});
|
||||
|
||||
describe('invites', () => {
|
||||
it('400 when user_id missing', () => {
|
||||
return thrown(() => makeController({ ...planBase }).invite(user, undefined)).then((r) =>
|
||||
// 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().mockReturnValue({ error: 'Already in a plan', status: 409 });
|
||||
return thrown(() => makeController({ ...planBase, sendInvite }).invite(user, 2)).then((r) =>
|
||||
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', () => {
|
||||
const sendInvite = vi.fn().mockReturnValue({});
|
||||
expect(makeController({ ...planBase, sendInvite }).invite(user, 2)).toEqual({ success: true });
|
||||
expect(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().mockReturnValue({ error: 'Invite not found', status: 404 });
|
||||
return thrown(() => makeController({ acceptInvite }).acceptInvite(user, 5)).then((r) =>
|
||||
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', () => {
|
||||
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({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true });
|
||||
expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 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) =>
|
||||
// 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', () => {
|
||||
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, addYear }).addYear(user, { year: 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' } }));
|
||||
});
|
||||
|
||||
// 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) =>
|
||||
@@ -215,20 +213,20 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
|
||||
expect(listShares).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('400 when user_id missing on share', () => {
|
||||
return thrown(() => makeController({}).share(user, undefined)).then((r) =>
|
||||
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().mockReturnValue({ error: 'Already shared', status: 400 });
|
||||
return thrown(() => makeController({ shareCalendar }).share(user, 2)).then((r) =>
|
||||
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)', () => {
|
||||
const shareCalendar = vi.fn().mockReturnValue({});
|
||||
expect(makeController({ shareCalendar }).share(user, '2', 'sock-1')).toEqual({ success: true });
|
||||
expect(makeController({ shareCalendar }).share(user, { user_id: '2' }, 'sock-1')).toEqual({ success: true });
|
||||
expect(shareCalendar).toHaveBeenCalledWith(1, 'u@example.test', 2, 'sock-1');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import {
|
||||
vacayAddHolidayCalendarRequestSchema,
|
||||
vacayCompanyHolidayRequestSchema,
|
||||
vacayInviteActionRequestSchema,
|
||||
vacayInviteRequestSchema,
|
||||
vacaySetColorRequestSchema,
|
||||
vacayShareRequestSchema,
|
||||
vacayShareUpdateRequestSchema,
|
||||
vacayToggleEntryRequestSchema,
|
||||
vacayAddYearRequestSchema,
|
||||
vacayUpdateHolidayCalendarRequestSchema,
|
||||
vacayUpdatePlanRequestSchema,
|
||||
vacayUpdateStatsRequestSchema,
|
||||
vacayYearSettingsRequestSchema,
|
||||
} from './vacay.schema';
|
||||
|
||||
@@ -76,3 +84,81 @@ describe('vacayAddYearRequestSchema', () => {
|
||||
expect(vacayAddYearRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayUpdatePlanRequestSchema', () => {
|
||||
it('accepts any partial subset of the plan settings', () => {
|
||||
expect(vacayUpdatePlanRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(vacayUpdatePlanRequestSchema.safeParse({ block_weekends: true }).success).toBe(true);
|
||||
expect(
|
||||
vacayUpdatePlanRequestSchema.safeParse({ weekend_days: '0,6', week_start: 0, carry_over_enabled: false }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('takes null to clear holidays_region but rejects wrong types', () => {
|
||||
expect(vacayUpdatePlanRequestSchema.safeParse({ holidays_region: null }).success).toBe(true);
|
||||
expect(vacayUpdatePlanRequestSchema.safeParse({ holidays_region: 'CH' }).success).toBe(true);
|
||||
expect(vacayUpdatePlanRequestSchema.safeParse({ block_weekends: 'yes' }).success).toBe(false);
|
||||
expect(vacayUpdatePlanRequestSchema.safeParse({ week_start: 'monday' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayUpdateHolidayCalendarRequestSchema', () => {
|
||||
it('accepts any partial subset including a null label', () => {
|
||||
expect(vacayUpdateHolidayCalendarRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(vacayUpdateHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY', label: null }).success).toBe(true);
|
||||
expect(
|
||||
vacayUpdateHolidayCalendarRequestSchema.safeParse({ type: 'school_holiday', color: '#a5f3fc', sort_order: 2 }).success,
|
||||
).toBe(true);
|
||||
expect(vacayUpdateHolidayCalendarRequestSchema.safeParse({ type: 'bank_holiday' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacaySetColorRequestSchema', () => {
|
||||
it('color and target_user_id are both optional', () => {
|
||||
expect(vacaySetColorRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(vacaySetColorRequestSchema.safeParse({ color: '#6366f1' }).success).toBe(true);
|
||||
expect(vacaySetColorRequestSchema.safeParse({ color: '#6366f1', target_user_id: '3' }).success).toBe(true);
|
||||
expect(vacaySetColorRequestSchema.safeParse({ color: 42 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayInviteActionRequestSchema', () => {
|
||||
it('plan_id is optional (a missing id falls through to the 404 path)', () => {
|
||||
expect(vacayInviteActionRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(vacayInviteActionRequestSchema.safeParse({ plan_id: 7 }).success).toBe(true);
|
||||
expect(vacayInviteActionRequestSchema.safeParse({ plan_id: '7' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayCompanyHolidayRequestSchema', () => {
|
||||
it('requires a date; note optional', () => {
|
||||
expect(vacayCompanyHolidayRequestSchema.safeParse({ date: '2026-12-24' }).success).toBe(true);
|
||||
expect(vacayCompanyHolidayRequestSchema.safeParse({ date: '2026-12-24', note: 'Xmas eve' }).success).toBe(true);
|
||||
expect(vacayCompanyHolidayRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayUpdateStatsRequestSchema', () => {
|
||||
it('vacation_days and target_user_id are both optional', () => {
|
||||
expect(vacayUpdateStatsRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(vacayUpdateStatsRequestSchema.safeParse({ vacation_days: 25 }).success).toBe(true);
|
||||
expect(vacayUpdateStatsRequestSchema.safeParse({ vacation_days: 25, target_user_id: 3 }).success).toBe(true);
|
||||
expect(vacayUpdateStatsRequestSchema.safeParse({ vacation_days: 'many' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayShareRequestSchema', () => {
|
||||
it('accepts a numeric or string user_id', () => {
|
||||
expect(vacayShareRequestSchema.safeParse({ user_id: 2 }).success).toBe(true);
|
||||
expect(vacayShareRequestSchema.safeParse({ user_id: '2' }).success).toBe(true);
|
||||
expect(vacayShareRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayShareUpdateRequestSchema', () => {
|
||||
it('requires a boolean hidden flag', () => {
|
||||
expect(vacayShareUpdateRequestSchema.safeParse({ hidden: true }).success).toBe(true);
|
||||
expect(vacayShareUpdateRequestSchema.safeParse({ hidden: 1 }).success).toBe(false);
|
||||
expect(vacayShareUpdateRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,23 @@ import { z } from 'zod';
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
// Plan settings update (PUT /plan): every field optional — only provided keys
|
||||
// are written (dynamic SET list server-side). weekend_days is the
|
||||
// comma-separated weekday list stored as TEXT (e.g. '0,6'); week_start is
|
||||
// coerced to 0/1 server-side; holidays_region takes null to clear the legacy
|
||||
// single-region field (superseded by holiday calendars, still on the wire).
|
||||
export const vacayUpdatePlanRequestSchema = z.object({
|
||||
block_weekends: z.boolean().optional(),
|
||||
holidays_enabled: z.boolean().optional(),
|
||||
holidays_region: z.string().nullable().optional(),
|
||||
school_holidays_enabled: z.boolean().optional(),
|
||||
company_holidays_enabled: z.boolean().optional(),
|
||||
carry_over_enabled: z.boolean().optional(),
|
||||
weekend_days: z.string().optional(),
|
||||
week_start: z.number().optional(),
|
||||
});
|
||||
export type VacayUpdatePlanRequest = z.infer<typeof vacayUpdatePlanRequestSchema>;
|
||||
|
||||
export const vacayAddHolidayCalendarRequestSchema = z.object({
|
||||
region: z.string().min(1),
|
||||
type: z.enum(['public_holiday', 'school_holiday']).optional(),
|
||||
@@ -25,6 +42,17 @@ export const vacayAddHolidayCalendarRequestSchema = z.object({
|
||||
});
|
||||
export type VacayAddHolidayCalendarRequest = z.infer<typeof vacayAddHolidayCalendarRequestSchema>;
|
||||
|
||||
// Partial calendar update (PUT /plan/holiday-calendars/:id): every field
|
||||
// optional — only provided keys are written (dynamic SET list server-side).
|
||||
export const vacayUpdateHolidayCalendarRequestSchema = z.object({
|
||||
region: z.string().optional(),
|
||||
type: z.enum(['public_holiday', 'school_holiday']).optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
export type VacayUpdateHolidayCalendarRequest = z.infer<typeof vacayUpdateHolidayCalendarRequestSchema>;
|
||||
|
||||
export const vacaySetColorRequestSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
target_user_id: z.union([z.number(), z.string()]).optional(),
|
||||
|
||||
Reference in New Issue
Block a user