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' }]); + }); +});