/** * @sh-mcp/calendar — unit tests * * All tests inject a mock CalendarClient and a mock AuthContext. * No real network calls. No AWS calls at import time. * * Covers: * - happy path for each tool * - empty result * - API error propagation * - throttle / retryable error path * - scope enforcement (missing scope → ScopeError) * - external-attendee flagging on create_calendar_event */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { AuthContext } from '@sh-mcp/shared'; import { buildCalendarTools } from '../src/tools.js'; import type { CalendarClient, CalendarEvent, AvailabilityResult } from '../src/client.js'; import { CalendarClientError } from '../src/client.js'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const MOCK_CTX: AuthContext = { sub: 'lauren@seahavenind.com', scopes: ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'], aud: 'sh-mcp-ops', }; const CTX_NO_CALENDAR: AuthContext = { sub: 'other@seahavenind.com', scopes: ['ops:read'], aud: 'sh-mcp-ops', }; const INTERNAL_EVENT: CalendarEvent = { id: 'event-001', summary: 'Team standup', start: '2026-06-11T09:00:00-04:00', end: '2026-06-11T09:15:00-04:00', attendees: [ { email: 'lauren@seahavenind.com', self: true, responseStatus: 'accepted' }, { email: 'adam@seahavenind.com', responseStatus: 'accepted' }, ], status: 'confirmed', }; const EXTERNAL_EVENT: CalendarEvent = { id: 'event-002', summary: 'Vendor call', start: '2026-06-11T14:00:00-04:00', end: '2026-06-11T15:00:00-04:00', attendees: [ { email: 'lauren@seahavenind.com', self: true, responseStatus: 'accepted' }, { email: 'vendor@externalco.com', responseStatus: 'needsAction' }, ], status: 'confirmed', }; const AVAILABILITY: AvailabilityResult = { busy: [{ start: '2026-06-11T09:00:00Z', end: '2026-06-11T09:15:00Z' }], free: [ { start: '2026-06-11T08:00:00Z', end: '2026-06-11T09:00:00Z' }, { start: '2026-06-11T09:15:00Z', end: '2026-06-11T17:00:00Z' }, ], }; // --------------------------------------------------------------------------- // Mock client builder // --------------------------------------------------------------------------- function buildMockClient(overrides: Partial = {}): CalendarClient { return { getEvents: vi.fn().mockResolvedValue([INTERNAL_EVENT]), checkAvailability: vi.fn().mockResolvedValue(AVAILABILITY), createEvent: vi.fn().mockResolvedValue(INTERNAL_EVENT), ...overrides, }; } // --------------------------------------------------------------------------- // get_calendar_events // --------------------------------------------------------------------------- describe('get_calendar_events', () => { let mockClient: CalendarClient; let tools: ReturnType; beforeEach(() => { mockClient = buildMockClient(); tools = buildCalendarTools(mockClient); }); const getTool = (ts: ReturnType) => ts.find((t) => t.name === 'get_calendar_events')!; it('happy path — returns events with count', async () => { const tool = getTool(tools); const result = await tool.handler( { timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' }, MOCK_CTX, ); expect(result.count).toBe(1); expect(result.events[0].id).toBe('event-001'); expect(mockClient.getEvents).toHaveBeenCalledWith( MOCK_CTX.sub, expect.objectContaining({ timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z', singleEvents: true, orderBy: 'startTime', }), ); }); it('empty result — returns count 0', async () => { mockClient = buildMockClient({ getEvents: vi.fn().mockResolvedValue([]) }); tools = buildCalendarTools(mockClient); const result = await getTool(tools).handler( { timeMin: '2026-06-12T00:00:00Z', timeMax: '2026-06-12T23:59:59Z' }, MOCK_CTX, ); expect(result.count).toBe(0); expect(result.events).toHaveLength(0); }); it('non-retryable API error is re-thrown as-is', async () => { mockClient = buildMockClient({ getEvents: vi.fn().mockRejectedValue( new CalendarClientError('Forbidden', 403, false), ), }); tools = buildCalendarTools(mockClient); await expect( getTool(tools).handler( { timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' }, MOCK_CTX, ), ).rejects.toThrow('Forbidden'); }); it('retryable/throttle error is wrapped with user-friendly message', async () => { mockClient = buildMockClient({ getEvents: vi.fn().mockRejectedValue( new CalendarClientError('Rate limited', 429, true), ), }); tools = buildCalendarTools(mockClient); await expect( getTool(tools).handler( { timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' }, MOCK_CTX, ), ).rejects.toThrow(/temporarily unavailable.*retryable/i); }); it('missing calendar:self scope throws ScopeError', async () => { await expect( getTool(tools).handler( { timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' }, CTX_NO_CALENDAR, ), ).rejects.toThrow(); // getEvents should never have been called expect(mockClient.getEvents).not.toHaveBeenCalled(); }); it('flags external attendees on returned events', async () => { mockClient = buildMockClient({ getEvents: vi.fn().mockResolvedValue([EXTERNAL_EVENT]), }); tools = buildCalendarTools(mockClient); const result = await getTool(tools).handler( { timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' }, MOCK_CTX, ); expect(result.events[0].hasExternalAttendees).toBe(true); expect((result.events[0] as { externalAttendeeWarning?: string }).externalAttendeeWarning) .toContain('vendor@externalco.com'); }); it('defines correct tool metadata', () => { const tool = getTool(tools); expect(tool.name).toBe('get_calendar_events'); expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('calendar:self'); }); }); // --------------------------------------------------------------------------- // check_availability // --------------------------------------------------------------------------- describe('check_availability', () => { let mockClient: CalendarClient; let tools: ReturnType; beforeEach(() => { mockClient = buildMockClient(); tools = buildCalendarTools(mockClient); }); const getTool = (ts: ReturnType) => ts.find((t) => t.name === 'check_availability')!; it('happy path — returns busy and free blocks with echoed window', async () => { const input = { timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z', }; const result = await getTool(tools).handler(input, MOCK_CTX); expect(result.busy).toHaveLength(1); expect(result.free).toHaveLength(2); expect(result.timeMin).toBe(input.timeMin); expect(result.timeMax).toBe(input.timeMax); }); it('empty availability — no busy blocks', async () => { mockClient = buildMockClient({ checkAvailability: vi.fn().mockResolvedValue({ busy: [], free: [ { start: '2026-06-11T08:00:00Z', end: '2026-06-11T17:00:00Z' }, ] }), }); tools = buildCalendarTools(mockClient); const result = await getTool(tools).handler( { timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' }, MOCK_CTX, ); expect(result.busy).toHaveLength(0); expect(result.free).toHaveLength(1); }); it('non-retryable error is re-thrown', async () => { mockClient = buildMockClient({ checkAvailability: vi.fn().mockRejectedValue( new CalendarClientError('Not found', 404, false), ), }); tools = buildCalendarTools(mockClient); await expect( getTool(tools).handler( { timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' }, MOCK_CTX, ), ).rejects.toThrow('Not found'); }); it('throttle/retryable error wraps with user-friendly message', async () => { mockClient = buildMockClient({ checkAvailability: vi.fn().mockRejectedValue( new CalendarClientError('Quota exceeded', 429, true), ), }); tools = buildCalendarTools(mockClient); await expect( getTool(tools).handler( { timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' }, MOCK_CTX, ), ).rejects.toThrow(/temporarily unavailable.*retryable/i); }); it('missing calendar:self scope throws before calling client', async () => { await expect( getTool(tools).handler( { timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' }, CTX_NO_CALENDAR, ), ).rejects.toThrow(); expect(mockClient.checkAvailability).not.toHaveBeenCalled(); }); it('defines correct tool metadata', () => { const tool = getTool(tools); expect(tool.name).toBe('check_availability'); expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('calendar:self'); }); }); // --------------------------------------------------------------------------- // create_calendar_event // --------------------------------------------------------------------------- describe('create_calendar_event', () => { let mockClient: CalendarClient; let tools: ReturnType; beforeEach(() => { mockClient = buildMockClient(); tools = buildCalendarTools(mockClient); }); const getTool = (ts: ReturnType) => ts.find((t) => t.name === 'create_calendar_event')!; it('happy path — internal attendees only, hasExternalAttendees=false', async () => { const input = { summary: 'Sprint planning', start: '2026-06-15T10:00:00-04:00', end: '2026-06-15T11:00:00-04:00', attendees: [{ email: 'adam@seahavenind.com' }], }; const result = await getTool(tools).handler(input, MOCK_CTX); expect(result.id).toBe('event-001'); expect(result.hasExternalAttendees).toBe(false); expect((result as { externalAttendeeWarning?: string }).externalAttendeeWarning).toBeUndefined(); expect(mockClient.createEvent).toHaveBeenCalledWith( MOCK_CTX.sub, expect.objectContaining({ summary: 'Sprint planning' }), ); }); it('external attendee — hasExternalAttendees=true with warning', async () => { mockClient = buildMockClient({ createEvent: vi.fn().mockResolvedValue(EXTERNAL_EVENT), }); tools = buildCalendarTools(mockClient); const input = { summary: 'Vendor call', start: '2026-06-11T14:00:00-04:00', end: '2026-06-11T15:00:00-04:00', attendees: [ { email: 'lauren@seahavenind.com' }, { email: 'vendor@externalco.com' }, ], }; const result = await getTool(tools).handler(input, MOCK_CTX); expect(result.hasExternalAttendees).toBe(true); expect(result.externalAttendeeWarning).toContain('vendor@externalco.com'); expect(result.externalAttendeeWarning).toContain('seahavenind.com'); }); it('non-retryable error is re-thrown', async () => { mockClient = buildMockClient({ createEvent: vi.fn().mockRejectedValue( new CalendarClientError('Conflict', 409, false), ), }); tools = buildCalendarTools(mockClient); await expect( getTool(tools).handler( { summary: 'Test', start: '2026-06-11T10:00:00Z', end: '2026-06-11T11:00:00Z' }, MOCK_CTX, ), ).rejects.toThrow('Conflict'); }); it('throttle/retryable error wraps with user-friendly message', async () => { mockClient = buildMockClient({ createEvent: vi.fn().mockRejectedValue( new CalendarClientError('Rate limited', 429, true), ), }); tools = buildCalendarTools(mockClient); await expect( getTool(tools).handler( { summary: 'Test', start: '2026-06-11T10:00:00Z', end: '2026-06-11T11:00:00Z' }, MOCK_CTX, ), ).rejects.toThrow(/temporarily unavailable.*retryable/i); }); it('missing calendar:self scope throws before calling client', async () => { await expect( getTool(tools).handler( { summary: 'Test', start: '2026-06-11T10:00:00Z', end: '2026-06-11T11:00:00Z' }, CTX_NO_CALENDAR, ), ).rejects.toThrow(); expect(mockClient.createEvent).not.toHaveBeenCalled(); }); it('event with no attendees — hasExternalAttendees=false', async () => { mockClient = buildMockClient({ createEvent: vi.fn().mockResolvedValue({ ...INTERNAL_EVENT, attendees: [], }), }); tools = buildCalendarTools(mockClient); const result = await getTool(tools).handler( { summary: 'Solo block', start: '2026-06-11T13:00:00Z', end: '2026-06-11T14:00:00Z' }, MOCK_CTX, ); expect(result.hasExternalAttendees).toBe(false); }); it('defines correct tool metadata', () => { const tool = getTool(tools); expect(tool.name).toBe('create_calendar_event'); expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('calendar:self'); }); }); // --------------------------------------------------------------------------- // Index / registry shape // --------------------------------------------------------------------------- describe('buildCalendarTools registry', () => { it('returns exactly 3 tools with distinct names', () => { const mockClient = buildMockClient(); const tools = buildCalendarTools(mockClient); expect(tools).toHaveLength(3); const names = tools.map((t) => t.name); expect(names).toContain('get_calendar_events'); expect(names).toContain('check_availability'); expect(names).toContain('create_calendar_event'); // all distinct expect(new Set(names).size).toBe(3); }); it('all tools are ops tier with calendar:self scope', () => { const mockClient = buildMockClient(); const tools = buildCalendarTools(mockClient); for (const tool of tools) { expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('calendar:self'); } }); });