From 4225d883c01df4d29aaa1d56bcfdf55cd8a45340 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 13:07:38 +0200 Subject: [PATCH] feat(mcp): half days, comp days, and the vacay plan settings The toggle took a date and nothing else, then hardcoded a full vacation day for the calling user, so a half day, a comp day or a day logged for another plan member had no expression. School holidays needed the plan flags that switch them on, and the region list that makes them usable. That list needs care: a group region is stored as COUNTRY|group:CODE and the reader treats a bare code as a subdivision, so the tool now returns the string add_holiday_calendar actually wants. Belgium and the Netherlands are the two countries this affects. --- server/src/nest/vacay/vacay.mcp.ts | 166 ++++++++- server/tests/unit/mcp/tools-vacay.test.ts | 410 +++++++++++++++++++++- 2 files changed, 553 insertions(+), 23 deletions(-) diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index 99744e112..47f6df03e 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -6,6 +6,12 @@ import { demoDenied, errorResult, ok, } from '../../nest-mcp'; import { z } from 'zod'; +import { + vacayAddHolidayCalendarRequestSchema, + vacayToggleEntryRequestSchema, + vacayUpdatePlanRequestSchema, +} from '@trek/shared'; +import type { VacayUpdatePlanRequest } from '@trek/shared'; import { AuthService } from '../auth/auth.service'; import { ADDON_IDS } from '../../addons'; import { VacayService } from './vacay.service'; @@ -15,6 +21,42 @@ import { AddonsService } from '../addons/addons.service'; /** Legacy registrar gate: the whole vacay surface rides the vacay addon. */ const vacayAddonOn = addonGate(ADDON_IDS.VACAY); +/** The controller derives the openholidaysapi response language the same way. */ +function schoolHolidayLanguage(country: string): string { + return country.toUpperCase() === 'DE' ? 'DE' : 'EN'; +} + +/** One node of the provider's region tree; children nest arbitrarily deep. */ +interface RegionNode { code?: string; shortName?: string; children?: RegionNode[] | null } + +function flattenRegionCodes(items: RegionNode[] | undefined): string[] { + const out: string[] = []; + for (const item of items ?? []) { + const code = item.code ?? item.shortName; + if (code) out.push(code); + out.push(...flattenRegionCodes(item.children ?? undefined)); + } + return out; +} + +/** + * The region strings add_holiday_calendar has to be given, which are not always + * the provider's own codes. A group is stored as `|group:`, and + * the reader (parseCalendarRegion in the client's vacay store) treats a bare + * code as a subdivision, so handing back the raw group code produces a calendar + * that queries nothing. Belgium and the Netherlands are the countries that use + * groups; deriving it from which list came back populated keeps the country + * table in the one place it already lives, on the client. + */ +function calendarRegionCodes(country: string, data: unknown): { region: string; kind: 'group' | 'subdivision' }[] { + const payload = (data ?? {}) as { groups?: RegionNode[]; subdivisions?: RegionNode[] }; + const upper = country.toUpperCase(); + return [ + ...flattenRegionCodes(payload.groups).map(code => ({ region: `${upper}|group:${code}`, kind: 'group' as const })), + ...flattenRegionCodes(payload.subdivisions).map(code => ({ region: code, kind: 'subdivision' as const })), + ]; +} + /** * Vacay MCP surface — ported 1:1 from the legacy registrars: the 26 tools from * src/mcp/tools/vacay.ts and the trek://vacay/{plan,entries,holidays} resources @@ -26,8 +68,12 @@ const vacayAddonOn = addonGate(ADDON_IDS.VACAY); * `if (W)` checks, resolved by trekMcpAccessPolicy). Vacay is plan-scoped, not * trip-scoped, so there is no trip-permission layer; the only per-call guard is * the demo-user check on every write (including decline_vacay_invite, which the - * legacy registrar missed). The two nager.at-backed lookups carry the - * open-world readonly annotation. + * legacy registrar missed). The nager.at- and openholidaysapi-backed lookups + * carry the open-world readonly annotation. + * + * Tools past the original 26 close REST surface the legacy registrar never had: + * the leave-year window, the school-holiday lookups behind a school_holiday + * calendar, and the share picker (a different query from the fusion picker). */ @McpController() export class VacayMcp { @@ -50,31 +96,45 @@ export class VacayMcp { return ok({ plan }); } + @Tool({ + name: 'get_vacay_year_settings', + description: "Get the caller's leave-year window: 'calendar' runs January to December, 'fiscal' starts on a configured month and day, 'anniversary' on the hire date's month and day. Read this before interpreting a year in get_vacay_stats or get_vacay_entries, which count over that window rather than the calendar year.", + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + when: vacayAddonOn, + access: { group: 'vacay', mode: 'read' }, + }) + async getVacayYearSettings(_args: Record, ctx: McpContext) { + return ok({ settings: this.vacay.getYearSettings(ctx.userId) }); + } + @Tool({ name: 'update_vacay_plan', - description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).', + description: 'Update vacation plan settings (weekend blocking and which weekdays count as weekend, public and school holidays, company holidays, carry-over).', inputSchema: { - block_weekends: z.boolean().optional(), - holidays_enabled: z.boolean().optional(), - holidays_region: z.string().nullable().optional(), - company_holidays_enabled: z.boolean().optional(), - carry_over_enabled: z.boolean().optional(), + ...vacayUpdatePlanRequestSchema.shape, + weekend_days: vacayUpdatePlanRequestSchema.shape.weekend_days.describe("Comma-separated weekday numbers that count as weekend, 0 is Sunday (e.g. '0,6')"), + week_start: vacayUpdatePlanRequestSchema.shape.week_start.describe('First column of the calendar grid: 0 for Sunday, anything else for Monday'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: vacayAddonOn, access: { group: 'vacay', mode: 'write' }, }) async updateVacayPlan( - { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }: { - block_weekends?: boolean; holidays_enabled?: boolean; holidays_region?: string | null; company_holidays_enabled?: boolean; carry_over_enabled?: boolean; - }, + { + block_weekends, holidays_enabled, holidays_region, school_holidays_enabled, + company_holidays_enabled, carry_over_enabled, weekend_days, week_start, + }: VacayUpdatePlanRequest, ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); // updatePlan already returns the fully-hydrated { plan }; surface it so the // AI consumer sees the updated plan, matching get_vacay_plan. - const result = await this.vacay.updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined); + const result = await this.vacay.updatePlan(planId, { + block_weekends, holidays_enabled, holidays_region, school_holidays_enabled, + company_holidays_enabled, carry_over_enabled, weekend_days, week_start, + }, undefined); return ok(result); } @@ -260,18 +320,33 @@ export class VacayMcp { @Tool({ name: 'toggle_vacay_entry', - description: 'Toggle a day on or off as a vacation day for the current user.', + description: 'Toggle a day in the vacation calendar. Repeating a date with the same fraction and kind clears it, a different fraction or kind converts the day in place. Defaults to a full vacation day for the calling user.', inputSchema: { date: z.string().describe('ISO date YYYY-MM-DD'), + fraction: vacayToggleEntryRequestSchema.shape.fraction.describe('0.5 for a half day, 1 (the default) for a full day'), + kind: vacayToggleEntryRequestSchema.shape.kind.describe("'comp' for a flex/comp day, which does not draw on the entitlement; 'vacation' is the default"), + targetUserId: z.number().int().positive().optional().describe('Log the day for another member of the shared plan instead of the caller'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: vacayAddonOn, access: { group: 'vacay', mode: 'write' }, }) - async toggleVacayEntry({ date }: { date: string }, ctx: McpContext) { + async toggleVacayEntry( + { date, fraction, kind, targetUserId }: { date: string; fraction?: 0.5 | 1; kind?: 'vacation' | 'comp'; targetUserId?: number }, + ctx: McpContext, + ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); - const result = this.vacay.toggleEntry(ctx.userId, planId, date, 1, 'vacation', undefined); + let userId = ctx.userId; + if (targetUserId !== undefined && targetUserId !== ctx.userId) { + // Same gate as POST /entries/toggle: booking for someone else reaches only + // the members of the caller's own plan, and nobody outside it. + if (!this.vacay.getPlanUsers(planId).find((u) => u.id === targetUserId)) { + return errorResult('User not in plan'); + } + userId = targetUserId; + } + const result = this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined); if (result.error) return errorResult(result.error); return ok(result); } @@ -330,9 +405,10 @@ export class VacayMcp { @Tool({ name: 'add_holiday_calendar', - description: 'Add a public holiday calendar (by region code) to the vacation plan.', + description: "Add a holiday calendar (by region code) to the vacation plan. Public holidays take a country or country-region code from list_holiday_countries; a school-holiday calendar needs type 'school_holiday' plus a subdivision code from list_school_holiday_regions, and only shows up once update_vacay_plan has school_holidays_enabled set.", inputSchema: { - region: z.string().describe('Country/region code e.g. US, GB, DE'), + region: z.string().describe("Country/region code e.g. US, GB, DE, or a subdivision like DE-BY. For a school-holiday calendar take calendar_regions[].region from list_school_holiday_regions verbatim, including the COUNTRY|group:CODE form that Belgium and the Netherlands use."), + type: vacayAddHolidayCalendarRequestSchema.shape.type.describe("'school_holiday', or 'public_holiday' (the default)"), label: z.string().nullable().optional(), color: z.string().optional(), sortOrder: z.number().int().optional(), @@ -342,12 +418,14 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async addHolidayCalendar( - { region, label, color, sortOrder }: { region: string; label?: string | null; color?: string; sortOrder?: number }, + { region, type, label, color, sortOrder }: { region: string; type?: 'public_holiday' | 'school_holiday'; label?: string | null; color?: string; sortOrder?: number }, ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); - const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined); + // An omitted type stays undefined so the service default (and the default + // colour that goes with it) applies, exactly as on the REST route. + const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type); return ok({ calendar }); } @@ -422,6 +500,44 @@ export class VacayMcp { return ok({ holidays: result.data }); } + @Tool({ + name: 'list_school_holiday_regions', + description: "List a country's school-holiday regions. Use calendar_regions[].region verbatim as add_holiday_calendar's region: a group is stored as COUNTRY|group:CODE and a bare group code would be read back as a subdivision, leaving the calendar empty. The raw provider payload is alongside it under regions, and its plain codes are what list_school_holidays filters by. Public holiday calendars use list_holiday_countries instead.", + inputSchema: { + country: z.string().describe('ISO 3166-1 alpha-2 code'), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + when: vacayAddonOn, + access: { group: 'vacay', mode: 'read' }, + }) + async listSchoolHolidayRegions({ country }: { country: string }, _ctx: McpContext) { + const result = await this.vacay.getSchoolHolidayRegions(country, schoolHolidayLanguage(country)); + if (result.error) return errorResult(result.error); + return ok({ regions: result.data, calendar_regions: calendarRegionCodes(country, result.data) }); + } + + @Tool({ + name: 'list_school_holidays', + description: 'List school holidays for a country and year, narrowed to a subdivision or group code from list_school_holiday_regions. School holidays are term breaks, so prefer list_holidays for the public holidays a day off is normally counted against.', + inputSchema: { + country: z.string().describe('ISO 3166-1 alpha-2 code'), + year: z.number().int(), + subdivision: z.string().optional().describe('Subdivision code from list_school_holiday_regions, e.g. DE-BY'), + group: z.string().optional().describe('Group code from list_school_holiday_regions'), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + when: vacayAddonOn, + access: { group: 'vacay', mode: 'read' }, + }) + async listSchoolHolidays( + { country, year, subdivision, group }: { country: string; year: number; subdivision?: string; group?: string }, + _ctx: McpContext, + ) { + const result = await this.vacay.getSchoolHolidays(String(year), country, subdivision, schoolHolidayLanguage(country), group); + if (result.error) return errorResult(result.error); + return ok({ holidays: result.data }); + } + @Tool({ name: 'list_vacay_shares', description: 'List read-only calendar shares: who the current user shares their vacation calendar with, and which calendars are shared with them.', @@ -434,6 +550,18 @@ export class VacayMcp { return ok(this.vacay.listShares(ctx.userId)); } + @Tool({ + name: 'get_shareable_vacay_users', + description: "List the users the caller can share their calendar with, for share_vacay_calendar's targetUserId. This is a wider set than get_available_vacay_users, which lists candidates for merging plans and therefore leaves out everyone who already sits in a plan of their own.", + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + when: vacayAddonOn, + access: { group: 'vacay', mode: 'read' }, + }) + async getShareableVacayUsers(_args: Record, ctx: McpContext) { + return ok({ users: this.vacay.getShareAvailableUsers(ctx.userId) }); + } + @Tool({ name: 'share_vacay_calendar', description: "Share the current user's vacation calendar with another user (view only, no merge).", diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts index 0bfc372fb..b8bd5d0d5 100644 --- a/server/tests/unit/mcp/tools-vacay.test.ts +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -1,17 +1,19 @@ /** * Unit tests for MCP vacay tools (vacay addon-gated, VacayMcp — DI-discovered * via the hand-built test registry in tests/helpers/mcp-test-controllers.ts): - * get_vacay_plan, update_vacay_plan, set_vacay_color, + * get_vacay_plan, get_vacay_year_settings, update_vacay_plan, set_vacay_color, * list_vacay_years, add_vacay_year, delete_vacay_year, * get_vacay_entries, toggle_vacay_entry, toggle_company_holiday, * get_vacay_stats, update_vacay_stats, * add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar, - * list_holiday_countries, list_holidays. + * list_holiday_countries, list_holidays, + * list_school_holiday_regions, list_school_holidays, + * get_shareable_vacay_users. * Resources: trek://vacay/plan, trek://vacay/entries/{year}, * trek://vacay/holidays/{year} — these ride the registry too (attached inside * registerTools), so `withTools` must stay on even for resource reads. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -53,14 +55,24 @@ import { ADDON_IDS } from '../../../src/addons'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; +// The plan-settings tests below need the real SQL write, so keep the +// implementation the blanket stub replaces and delegate to it for one call. +const realUpdatePlan = VacayService.prototype.updatePlan; + // Stub the async methods that make external calls (VacayService is DI-native; // the registry constructs a real instance, so spy on the prototype — the // successor of the legacy path-level partial mock of services/vacayService). -vi.spyOn(VacayService.prototype, 'updatePlan').mockResolvedValue({ +const updatePlanSpy = vi.spyOn(VacayService.prototype, 'updatePlan').mockResolvedValue({ plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] }, } as never); vi.spyOn(VacayService.prototype, 'getCountries').mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }); vi.spyOn(VacayService.prototype, 'getHolidays').mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }); +const schoolRegionsSpy = vi.spyOn(VacayService.prototype, 'getSchoolHolidayRegions').mockResolvedValue({ + data: { groups: [{ code: 'DE-BY-1', name: 'Bayern' }], subdivisions: [{ code: 'DE-BY', name: 'Bayern' }] }, +}); +const schoolHolidaysSpy = vi.spyOn(VacayService.prototype, 'getSchoolHolidays').mockResolvedValue({ + data: [{ name: 'Sommerferien', startDate: '2025-07-28', endDate: '2025-09-08' }], +}); beforeAll(() => { createTables(testDb); @@ -73,6 +85,9 @@ beforeEach(() => { // the admin panel writes — and this addon ships disabled by default. setAddonEnabled(testDb, ADDON_IDS.VACAY, true); broadcastMock.mockClear(); + updatePlanSpy.mockClear(); + schoolRegionsSpy.mockClear(); + schoolHolidaysSpy.mockClear(); delete process.env.DEMO_MODE; }); @@ -90,6 +105,23 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis try { await fn(h); } finally { await h.cleanup(); } } +function errorText(result: Awaited>): string { + const text = (result.content as { type: string; text?: string }[]).find((c) => c.type === 'text'); + return text?.text ?? ''; +} + +/** Fuse two users into one plan through the invite tools, as a member would. */ +async function fusePlan(ownerId: number, memberId: number): Promise { + await withHarness(ownerId, async (h) => { + await h.client.callTool({ name: 'send_vacay_invite', arguments: { targetUserId: memberId } }); + }); + const planId = (testDb.prepare('SELECT plan_id FROM vacay_plan_members WHERE user_id = ?').get(memberId) as { plan_id: number }).plan_id; + await withHarness(memberId, async (h) => { + await h.client.callTool({ name: 'accept_vacay_invite', arguments: { planId } }); + }); + return planId; +} + // --------------------------------------------------------------------------- // get_vacay_plan // --------------------------------------------------------------------------- @@ -126,6 +158,46 @@ describe('Tool: update_vacay_plan', () => { }); }); + it('persists school holidays, the weekend weekdays and the week start', async () => { + const { user } = createUser(testDb); + updatePlanSpy.mockImplementationOnce(realUpdatePlan); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_vacay_plan', + arguments: { school_holidays_enabled: true, weekend_days: '5,6', week_start: 0 }, + }); + const data = parseToolResult(result) as any; + expect(data.plan.school_holidays_enabled).toBe(true); + + const row = testDb.prepare('SELECT school_holidays_enabled, weekend_days, week_start FROM vacay_plans WHERE owner_id = ?').get(user.id) as any; + expect(row.school_holidays_enabled).toBe(1); + expect(row.weekend_days).toBe('5,6'); + expect(row.week_start).toBe(0); + }); + }); + + it('leaves the untouched settings alone', async () => { + const { user } = createUser(testDb); + updatePlanSpy.mockImplementationOnce(realUpdatePlan); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_vacay_plan', arguments: { weekend_days: '1,2' } }); + const row = testDb.prepare('SELECT weekend_days, week_start, school_holidays_enabled FROM vacay_plans WHERE owner_id = ?').get(user.id) as any; + expect(row.weekend_days).toBe('1,2'); + // Column defaults, not values this call wrote. + expect(row.week_start).toBe(1); + expect(row.school_holidays_enabled).toBe(0); + }); + }); + + it('refuses a non-numeric week_start', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { week_start: 'monday' } }); + expect(result.isError).toBe(true); + expect(updatePlanSpy).not.toHaveBeenCalled(); + }); + }); + it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); @@ -136,6 +208,36 @@ describe('Tool: update_vacay_plan', () => { }); }); +// --------------------------------------------------------------------------- +// get_vacay_year_settings +// --------------------------------------------------------------------------- + +describe('Tool: get_vacay_year_settings', () => { + it('falls back to the calendar year when nothing is stored', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_vacay_year_settings', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.settings.year_type).toBe('calendar'); + expect(data.settings.year_start_month).toBe(1); + expect(data.settings.year_start_day).toBe(1); + }); + }); + + it('returns a stored fiscal window', async () => { + const { user } = createUser(testDb); + testDb.prepare('INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date) VALUES (?, ?, ?, ?, ?)') + .run(user.id, 'fiscal', 4, 6, null); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_vacay_year_settings', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.settings.year_type).toBe('fiscal'); + expect(data.settings.year_start_month).toBe(4); + expect(data.settings.year_start_day).toBe(6); + }); + }); +}); + // --------------------------------------------------------------------------- // set_vacay_color // --------------------------------------------------------------------------- @@ -258,6 +360,97 @@ describe('Tool: toggle_vacay_entry', () => { }); }); + it('logs a half day', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-16', fraction: 0.5 } }); + const data = parseToolResult(result) as any; + expect(data.action).toBe('added'); + + const row = testDb.prepare('SELECT fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ?').get(user.id, '2025-06-16') as any; + expect(row.fraction).toBe(0.5); + expect(row.kind).toBe('vacation'); + }); + }); + + it('logs a comp day', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-17', kind: 'comp' } }); + const row = testDb.prepare('SELECT fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ?').get(user.id, '2025-06-17') as any; + expect(row.kind).toBe('comp'); + expect(row.fraction).toBe(1); + }); + }); + + it('converts the day in place on a different fraction and clears it on a repeat', async () => { + const { user } = createUser(testDb); + const row = () => testDb.prepare('SELECT fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ?').get(user.id, '2025-06-18') as any; + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-18', fraction: 0.5, kind: 'comp' } }); + expect(row().fraction).toBe(0.5); + + const converted = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-18', fraction: 1, kind: 'comp' } }); + expect((parseToolResult(converted) as any).action).toBe('updated'); + expect(row().fraction).toBe(1); + expect(row().kind).toBe('comp'); + + const cleared = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-18', fraction: 1, kind: 'comp' } }); + expect((parseToolResult(cleared) as any).action).toBe('removed'); + expect(row()).toBeUndefined(); + }); + }); + + it('logs the day for another member of the plan', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + await fusePlan(owner.id, member.id); + + await withHarness(owner.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_vacay_entry', + arguments: { date: '2025-06-19', fraction: 0.5, targetUserId: member.id }, + }); + expect((parseToolResult(result) as any).action).toBe('added'); + + const row = testDb.prepare('SELECT user_id, fraction FROM vacay_entries WHERE date = ?').get('2025-06-19') as any; + expect(row.user_id).toBe(member.id); + expect(row.fraction).toBe(0.5); + }); + }); + + it('refuses a target user outside the plan, the way POST /entries/toggle does', async () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_vacay_entry', + arguments: { date: '2025-06-20', targetUserId: stranger.id }, + }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('User not in plan'); + expect(testDb.prepare('SELECT id FROM vacay_entries WHERE user_id = ?').get(stranger.id)).toBeUndefined(); + }); + }); + + it('refuses a fraction the contract does not allow', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-21', fraction: 0.25 } }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT id FROM vacay_entries WHERE date = ?').get('2025-06-21')).toBeUndefined(); + }); + }); + + it('refuses a leave kind the contract does not allow', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-22', kind: 'sabbatical' } }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT id FROM vacay_entries WHERE date = ?').get('2025-06-22')).toBeUndefined(); + }); + }); + it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); @@ -352,6 +545,41 @@ describe('Tool: add_holiday_calendar', () => { }); }); + it('stores a school-holiday calendar with the colour that goes with the type', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_holiday_calendar', + arguments: { region: 'DE-BY', type: 'school_holiday' }, + }); + const data = parseToolResult(result) as any; + const row = testDb.prepare('SELECT type, region, color FROM vacay_holiday_calendars WHERE id = ?').get(data.calendar.id) as any; + expect(row.type).toBe('school_holiday'); + expect(row.region).toBe('DE-BY'); + expect(row.color).toBe('#a5f3fc'); + }); + }); + + it('still writes a public-holiday calendar when the type is omitted', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'GB' } }); + const data = parseToolResult(result) as any; + const row = testDb.prepare('SELECT type, color FROM vacay_holiday_calendars WHERE id = ?').get(data.calendar.id) as any; + expect(row.type).toBe('public_holiday'); + expect(row.color).toBe('#fecaca'); + }); + }); + + it('refuses a calendar type outside the contract', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'DE', type: 'bank_holiday' } }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE region = ?').get('DE')).toBeUndefined(); + }); + }); + it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); @@ -459,6 +687,86 @@ describe('Tool: list_holidays', () => { }); }); +// --------------------------------------------------------------------------- +// list_school_holiday_regions +// --------------------------------------------------------------------------- + +describe('Tool: list_school_holiday_regions', () => { + it('returns the group and subdivision codes, asking in the country language like the route does', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_school_holiday_regions', arguments: { country: 'de' } }); + const data = parseToolResult(result) as any; + expect(data.regions.subdivisions[0].code).toBe('DE-BY'); + expect(schoolRegionsSpy).toHaveBeenCalledWith('de', 'DE'); + }); + }); + + it('asks in English for every other country', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'list_school_holiday_regions', arguments: { country: 'NL' } }); + expect(schoolRegionsSpy).toHaveBeenCalledWith('NL', 'EN'); + }); + }); + + it('surfaces the upstream failure', async () => { + const { user } = createUser(testDb); + schoolRegionsSpy.mockResolvedValueOnce({ error: 'Failed to fetch school holiday regions' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_school_holiday_regions', arguments: { country: 'DE' } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('Failed to fetch school holiday regions'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_school_holidays +// --------------------------------------------------------------------------- + +describe('Tool: list_school_holidays', () => { + it('forwards the subdivision and group filters', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_school_holidays', + arguments: { country: 'DE', year: 2025, subdivision: 'DE-BY', group: 'DE-BY-1' }, + }); + const data = parseToolResult(result) as any; + expect(data.holidays[0].name).toBe('Sommerferien'); + expect(schoolHolidaysSpy).toHaveBeenCalledWith('2025', 'DE', 'DE-BY', 'DE', 'DE-BY-1'); + }); + }); + + it('leaves both filters unset when they are omitted', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'list_school_holidays', arguments: { country: 'AT', year: 2026 } }); + expect(schoolHolidaysSpy).toHaveBeenCalledWith('2026', 'AT', undefined, 'EN', undefined); + }); + }); + + it('surfaces the upstream failure', async () => { + const { user } = createUser(testDb); + schoolHolidaysSpy.mockResolvedValueOnce({ error: 'Failed to fetch school holidays' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_school_holidays', arguments: { country: 'DE', year: 2025 } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toBe('Failed to fetch school holidays'); + }); + }); + + it('refuses a non-numeric year', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_school_holidays', arguments: { country: 'DE', year: '2025' } }); + expect(result.isError).toBe(true); + expect(schoolHolidaysSpy).not.toHaveBeenCalled(); + }); + }); +}); + // --------------------------------------------------------------------------- // list_vacay_shares // --------------------------------------------------------------------------- @@ -479,6 +787,47 @@ describe('Tool: list_vacay_shares', () => { }); }); +// --------------------------------------------------------------------------- +// get_shareable_vacay_users +// --------------------------------------------------------------------------- + +describe('Tool: get_shareable_vacay_users', () => { + it('offers users the fusion picker leaves out because they already sit in a plan', async () => { + const { user } = createUser(testDb); + const { user: fusedOwner } = createUser(testDb); + const { user: fusedMember } = createUser(testDb); + await fusePlan(fusedOwner.id, fusedMember.id); + + await withHarness(user.id, async (h) => { + const shareable = parseToolResult(await h.client.callTool({ name: 'get_shareable_vacay_users', arguments: {} })) as any; + const fusable = parseToolResult(await h.client.callTool({ name: 'get_available_vacay_users', arguments: {} })) as any; + + const shareableIds = shareable.users.map((u: any) => u.id); + expect(shareableIds).toContain(fusedOwner.id); + expect(shareableIds).toContain(fusedMember.id); + expect(fusable.users.map((u: any) => u.id)).not.toContain(fusedOwner.id); + + // The share picker reaches across plans, so it deliberately answers with + // usernames only rather than widening the instance directory. + expect(shareable.users[0].email).toBeUndefined(); + }); + }); + + it('drops a user the caller already shares with, and never the caller', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + await withHarness(user.id, async (h) => { + const before = parseToolResult(await h.client.callTool({ name: 'get_shareable_vacay_users', arguments: {} })) as any; + expect(before.users.map((u: any) => u.id)).toEqual([other.id]); + + await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } }); + + const after = parseToolResult(await h.client.callTool({ name: 'get_shareable_vacay_users', arguments: {} })) as any; + expect(after.users).toEqual([]); + }); + }); +}); + // --------------------------------------------------------------------------- // share_vacay_calendar // --------------------------------------------------------------------------- @@ -641,3 +990,56 @@ describe('Tool: decline_vacay_invite', () => { }); }); }); + +// --------------------------------------------------------------------------- +// The region code add_holiday_calendar actually needs +// +// A group is stored as COUNTRY|group:CODE and the client's parseCalendarRegion +// reads a bare code as a subdivision, so handing a model the provider's raw +// group code produces a calendar that queries nothing. Belgium and the +// Netherlands are the two countries this hits. +// --------------------------------------------------------------------------- + +describe('Tool: list_school_holiday_regions (the codes a calendar can be built from)', () => { + async function regionsFor(userId: number, country: string): Promise { + let data: any; + await withHarness(userId, async (h) => { + const result = await h.client.callTool({ + name: 'list_school_holiday_regions', + arguments: { country }, + }); + data = parseToolResult(result); + }); + return data; + } + + it('prefixes a group code with the country, the way the calendar reader expects', async () => { + const { user } = createUser(testDb); + schoolRegionsSpy.mockResolvedValueOnce({ data: { groups: [{ code: 'NO', name: 'Noord' }], subdivisions: [] } }); + const data = await regionsFor(user.id, 'NL'); + expect(data.calendar_regions).toEqual([{ region: 'NL|group:NO', kind: 'group' }]); + }); + + it('leaves a subdivision code alone', async () => { + const { user } = createUser(testDb); + schoolRegionsSpy.mockResolvedValueOnce({ data: { groups: [], subdivisions: [{ code: 'DE-BY', name: 'Bayern' }] } }); + const data = await regionsFor(user.id, 'DE'); + expect(data.calendar_regions).toEqual([{ region: 'DE-BY', kind: 'subdivision' }]); + }); + + it('walks nested children, which the provider uses for sub-regions', async () => { + const { user } = createUser(testDb); + schoolRegionsSpy.mockResolvedValueOnce({ + data: { groups: [], subdivisions: [{ code: 'CH-BE', children: [{ code: 'CH-BE-1' }, { code: 'CH-BE-2', children: null }] }] }, + }); + const data = await regionsFor(user.id, 'CH'); + expect(data.calendar_regions.map((r: any) => r.region)).toEqual(['CH-BE', 'CH-BE-1', 'CH-BE-2']); + }); + + it('still returns the raw provider payload alongside it', async () => { + const { user } = createUser(testDb); + schoolRegionsSpy.mockResolvedValueOnce({ data: { groups: [], subdivisions: [{ code: 'AT-9' }] } }); + const data = await regionsFor(user.id, 'AT'); + expect(data.regions.subdivisions).toEqual([{ code: 'AT-9' }]); + }); +});