/** * 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, 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_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, afterEach, afterAll } from 'vitest'; import { db as testDb } from '../../../src/db/database'; vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); // share_vacay_calendar fires a user notification after inserting; stub it out import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { setAddonEnabled } from '../../helpers/test-db'; import { ADDON_IDS } from '../../../src/addons'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { findRow, insertRow } from '../../helpers/factories/rows'; import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity'; import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity'; import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.entity'; import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity'; import { VacayShares } from '../../../src/db/entities/VacayShares.entity'; import { VacayUserSettings } from '../../../src/db/entities/VacayUserSettings.entity'; let orm: TestOrm; beforeAll(async () => { orm = await createTestOrm(testDb); }); /** The plan the user was invited into. */ async function invitedPlanId(userId: number): Promise { return (await findRow(orm, VacayPlanMembers, { user: userId }))!.plan_id; } // 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). 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' }], }); beforeEach(() => { resetTestDb(testDb); // The `when:` gate reads the injected AddonsService, so the toggle is the row // 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; }); afterAll(async () => { await orm.close(); testDb.close(); }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: true }); 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 = await invitedPlanId(memberId); await withHarness(memberId, async (h) => { await h.client.callTool({ name: 'accept_vacay_invite', arguments: { planId } }); }); return planId; } // --------------------------------------------------------------------------- // get_vacay_plan // --------------------------------------------------------------------------- describe('Tool: get_vacay_plan', () => { it('returns plan data object', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_vacay_plan', arguments: {} }); const data = parseToolResult(result) as any; expect(data.plan).toBeDefined(); }); }); }); // --------------------------------------------------------------------------- // update_vacay_plan // --------------------------------------------------------------------------- describe('Tool: update_vacay_plan', () => { it('calls updatePlan and returns the hydrated plan', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true, holidays_enabled: false }, }); const data = parseToolResult(result) as any; // Now returns the fully-hydrated plan (matching get_vacay_plan), not { success }. expect(data.plan).toBeDefined(); expect(data.plan.block_weekends).toBe(true); expect(data.plan.holidays_enabled).toBe(false); expect(Array.isArray(data.plan.holiday_calendars)).toBe(true); }); }); 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 = (await findRow(orm, VacayPlans, { owner: user.id }))!; 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 = (await findRow(orm, VacayPlans, { owner: user.id }))!; 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' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // 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); await insertRow(orm, VacayUserSettings, { user: user.id, year_type: 'fiscal', year_start_month: 4, year_start_day: 6, hire_date: 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 // --------------------------------------------------------------------------- describe('Tool: set_vacay_color', () => { it('updates color and returns success', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(data.color).toBe('#ff0000'); // echoes the persisted color }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // list_vacay_years // --------------------------------------------------------------------------- describe('Tool: list_vacay_years', () => { it('returns years array', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_vacay_years', arguments: {} }); const data = parseToolResult(result) as any; expect(Array.isArray(data.years)).toBe(true); }); }); }); // --------------------------------------------------------------------------- // add_vacay_year // --------------------------------------------------------------------------- describe('Tool: add_vacay_year', () => { it('adds year to list', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } }); const data = parseToolResult(result) as any; expect(Array.isArray(data.years)).toBe(true); expect(data.years).toContain(2025); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // delete_vacay_year // --------------------------------------------------------------------------- describe('Tool: delete_vacay_year', () => { it('removes year from list', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { // Add year first await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } }); const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } }); const data = parseToolResult(result) as any; expect(Array.isArray(data.years)).toBe(true); expect(data.years).not.toContain(2025); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // get_vacay_entries // --------------------------------------------------------------------------- describe('Tool: get_vacay_entries', () => { it('returns entries array (empty initially)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_vacay_entries', arguments: { year: 2025 } }); const data = parseToolResult(result) as any; expect(data.entries).toBeDefined(); expect(Array.isArray(data.entries.entries)).toBe(true); }); }); }); // --------------------------------------------------------------------------- // toggle_vacay_entry // --------------------------------------------------------------------------- describe('Tool: toggle_vacay_entry', () => { it('toggles entry and returns action', 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' } }); const data = parseToolResult(result) as any; expect(data.action).toBeDefined(); }); }); 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 = (await findRow(orm, VacayEntries, { user: user.id, date: '2025-06-16' }))!; 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 = (await findRow(orm, VacayEntries, { user: user.id, date: '2025-06-17' }))!; 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 = () => findRow(orm, VacayEntries, { user: user.id, date: '2025-06-18' }); 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((await 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((await row())!.fraction).toBe(1); expect((await 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(await row()).toBeNull(); }); }); 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 = (await findRow(orm, VacayEntries, { date: '2025-06-19' }))!; 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(await findRow(orm, VacayEntries, { user: stranger.id })).toBeNull(); }); }); 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(await findRow(orm, VacayEntries, { date: '2025-06-21' })).toBeNull(); }); }); 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(await findRow(orm, VacayEntries, { date: '2025-06-22' })).toBeNull(); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-16' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // toggle_company_holiday // --------------------------------------------------------------------------- describe('Tool: toggle_company_holiday', () => { it('toggles company holiday and returns action', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25', note: 'Christmas' }, }); const data = parseToolResult(result) as any; expect(data.action).toBeDefined(); }); }); it('makes a half company holiday like the REST route (#2439)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const data = parseToolResult(await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-24', half: true }, })) as any; expect(data).toMatchObject({ action: 'added', fraction: 0.5 }); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // get_vacay_stats // --------------------------------------------------------------------------- describe('Tool: get_vacay_stats', () => { it('returns stats object', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_vacay_stats', arguments: { year: 2025 } }); const data = parseToolResult(result) as any; expect(data.stats).toBeDefined(); }); }); }); // --------------------------------------------------------------------------- // update_vacay_stats // --------------------------------------------------------------------------- describe('Tool: update_vacay_stats', () => { it('updates vacation days allowance and returns success', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // add_holiday_calendar // --------------------------------------------------------------------------- describe('Tool: add_holiday_calendar', () => { it('inserts calendar row and returns calendar', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US', label: 'US Holidays', color: '#ff0000' }, }); const data = parseToolResult(result) as any; expect(data.calendar).toBeDefined(); expect(data.calendar.region).toBe('US'); }); }); 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 = (await findRow(orm, VacayHolidayCalendars, { id: data.calendar.id }))!; 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 = (await findRow(orm, VacayHolidayCalendars, { id: data.calendar.id }))!; 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(await findRow(orm, VacayHolidayCalendars, { region: 'DE' })).toBeNull(); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // update_holiday_calendar // --------------------------------------------------------------------------- describe('Tool: update_holiday_calendar', () => { it('updates label and color', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { // First add a calendar const addResult = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'DE', label: 'Germany' }, }); const added = parseToolResult(addResult) as any; const calId = added.calendar.id; const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: calId, label: 'German Holidays', color: '#00ff00' }, }); const data = parseToolResult(result) as any; expect(data.calendar).toBeDefined(); expect(data.calendar.label).toBe('German Holidays'); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // delete_holiday_calendar // --------------------------------------------------------------------------- describe('Tool: delete_holiday_calendar', () => { it('removes calendar and returns success', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const addResult = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'FR' }, }); const added = parseToolResult(addResult) as any; const calId = added.calendar.id; const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: calId } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: 1 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // list_holiday_countries // --------------------------------------------------------------------------- describe('Tool: list_holiday_countries', () => { it('returns countries from mocked service', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_holiday_countries', arguments: {} }); const data = parseToolResult(result) as any; expect(data.countries).toBeDefined(); }); }); }); // --------------------------------------------------------------------------- // list_holidays // --------------------------------------------------------------------------- describe('Tool: list_holidays', () => { it('returns holidays from mocked service', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_holidays', arguments: { country: 'US', year: 2025 } }); const data = parseToolResult(result) as any; expect(data.holidays).toBeDefined(); }); }); }); // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- describe('Tool: list_vacay_shares', () => { it('returns outgoing and incoming shares', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } }); const result = await h.client.callTool({ name: 'list_vacay_shares', arguments: {} }); const data = parseToolResult(result) as any; expect(Array.isArray(data.outgoing)).toBe(true); expect(Array.isArray(data.incoming)).toBe(true); expect(data.outgoing).toHaveLength(1); expect(data.outgoing[0].user_id).toBe(other.id); }); }); }); // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- describe('Tool: share_vacay_calendar', () => { it('creates a share and returns success', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); const row = await findRow(orm, VacayShares, { owner: user.id, user: other.id }); expect(row).not.toBeNull(); }); }); it('errors when sharing with yourself', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: user.id } }); expect(result.isError).toBe(true); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const { user: other } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // unshare_vacay_calendar // --------------------------------------------------------------------------- describe('Tool: unshare_vacay_calendar', () => { it('removes an existing share and returns success', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } }); const shareId = (await findRow(orm, VacayShares, { owner: user.id }))!.id; const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(await findRow(orm, VacayShares, { id: shareId })).toBeNull(); }); }); it('errors when the share does not exist', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId: 99999 } }); expect(result.isError).toBe(true); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId: 1 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // get_shared_vacay_calendars // --------------------------------------------------------------------------- describe('Tool: get_shared_vacay_calendars', () => { it('returns the sharer entries for the viewer', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); await withHarness(owner.id, async (h) => { await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-16' } }); await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: viewer.id } }); }); await withHarness(viewer.id, async (h) => { const result = await h.client.callTool({ name: 'get_shared_vacay_calendars', arguments: { year: 2025 } }); const data = parseToolResult(result) as any; expect(Array.isArray(data.calendars)).toBe(true); expect(data.calendars).toHaveLength(1); expect(data.calendars[0].owner_id).toBe(owner.id); expect(data.calendars[0].entries.map((e: any) => e.date)).toContain('2025-06-16'); }); }); }); // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- describe('Resource: trek://vacay/plan', () => { it('returns plan data', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://vacay/plan' }); const data = parseResourceResult(result) as any; expect(data).toBeDefined(); }); }); }); describe('Resource: trek://vacay/entries/{year}', () => { it('returns entries for a year', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://vacay/entries/2025' }); const data = parseResourceResult(result) as any; expect(data).toBeDefined(); expect(Array.isArray(data.entries)).toBe(true); }); }); }); describe('Resource: trek://vacay/holidays/{year}', () => { it('returns [] while public holidays are disabled or no region is set', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://vacay/holidays/2025' }); const data = parseResourceResult(result) as unknown[]; expect(data).toEqual([]); }); }); }); // --------------------------------------------------------------------------- // decline_vacay_invite (demo guard added by the vacay quirk-fix commit — the // legacy registrar was the only vacay write without it) // --------------------------------------------------------------------------- describe('Tool: decline_vacay_invite', () => { it('declines a pending invite', async () => { const { user: owner } = createUser(testDb); const { user: invitee } = createUser(testDb); await withHarness(owner.id, async (h) => { await h.client.callTool({ name: 'send_vacay_invite', arguments: { targetUserId: invitee.id } }); }); await withHarness(invitee.id, async (h) => { const result = await h.client.callTool({ name: 'decline_vacay_invite', arguments: { planId: await invitedPlanId(invitee.id) } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'decline_vacay_invite', arguments: { planId: 1 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // 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' }]); }); });