|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | +import { tools as toolRegistry } from '@/tools/registry' |
| 6 | +import { OutlookBlock } from './outlook' |
| 7 | + |
| 8 | +const block = OutlookBlock |
| 9 | + |
| 10 | +/** Every calendar operation exposed by the block's operation dropdown. */ |
| 11 | +const CALENDAR_OPERATIONS = [ |
| 12 | + 'list_events_calendar', |
| 13 | + 'get_event_calendar', |
| 14 | + 'create_event_calendar', |
| 15 | + 'update_event_calendar', |
| 16 | + 'delete_event_calendar', |
| 17 | + 'respond_calendar', |
| 18 | +] as const |
| 19 | + |
| 20 | +/** Representative values for every calendar subBlock, as the editor would supply them. */ |
| 21 | +const CALENDAR_SUBBLOCK_VALUES: Record<string, unknown> = { |
| 22 | + oauthCredential: 'cred-1', |
| 23 | + calendarId: 'cal-1', |
| 24 | + calEventId: 'evt-1', |
| 25 | + calWindowStart: '2025-06-03T00:00:00Z', |
| 26 | + calWindowEnd: '2025-06-10T00:00:00Z', |
| 27 | + calMaxResults: '25', |
| 28 | + calOrderBy: 'start/dateTime', |
| 29 | + calPageToken: '', |
| 30 | + calSubject: 'Sync', |
| 31 | + calStartDateTime: '2025-06-03T10:00:00Z', |
| 32 | + calEndDateTime: '2025-06-03T11:00:00Z', |
| 33 | + calBody: 'agenda', |
| 34 | + calContentType: 'text', |
| 35 | + calLocation: 'Room 1', |
| 36 | + calAttendees: 'a@x.com', |
| 37 | + calTimeZone: 'America/Los_Angeles', |
| 38 | + calIsAllDay: false, |
| 39 | + calIsOnlineMeeting: true, |
| 40 | + calResponseType: 'accept', |
| 41 | + calComment: 'see you', |
| 42 | + calSendResponse: 'true', |
| 43 | +} |
| 44 | + |
| 45 | +describe('OutlookBlock calendar operations', () => { |
| 46 | + it.each(CALENDAR_OPERATIONS)('%s resolves to a registered tool in tools.access', (operation) => { |
| 47 | + const toolId = block.tools.config.tool?.({ operation }) as string |
| 48 | + expect(toolRegistry[toolId]).toBeDefined() |
| 49 | + expect(block.tools.access).toContain(toolId) |
| 50 | + }) |
| 51 | + |
| 52 | + it.each(CALENDAR_OPERATIONS)('%s supplies every required tool param', (operation) => { |
| 53 | + const toolId = block.tools.config.tool?.({ operation }) as string |
| 54 | + const mapped = block.tools.config.params?.({ operation, ...CALENDAR_SUBBLOCK_VALUES }) ?? {} |
| 55 | + const required = Object.entries(toolRegistry[toolId].params ?? {}) |
| 56 | + .filter(([id, config]) => config.required && id !== 'accessToken') |
| 57 | + .map(([id]) => id) |
| 58 | + |
| 59 | + const missing = required.filter((id) => mapped[id] === undefined || mapped[id] === '') |
| 60 | + expect(missing).toEqual([]) |
| 61 | + }) |
| 62 | + |
| 63 | + it.each(CALENDAR_OPERATIONS)('%s emits no params the tool cannot accept', (operation) => { |
| 64 | + const toolId = block.tools.config.tool?.({ operation }) as string |
| 65 | + const mapped = block.tools.config.params?.({ operation, ...CALENDAR_SUBBLOCK_VALUES }) ?? {} |
| 66 | + // `operation` and the credential ride along for the executor, not the tool contract. |
| 67 | + const accepted = new Set([ |
| 68 | + ...Object.keys(toolRegistry[toolId].params ?? {}), |
| 69 | + 'operation', |
| 70 | + 'oauthCredential', |
| 71 | + ]) |
| 72 | + |
| 73 | + const orphans = Object.keys(mapped).filter( |
| 74 | + (key) => !accepted.has(key) && mapped[key] !== undefined |
| 75 | + ) |
| 76 | + expect(orphans).toEqual([]) |
| 77 | + }) |
| 78 | + |
| 79 | + it('maps calendar operations onto calendar tools one-to-one', () => { |
| 80 | + const reachable = CALENDAR_OPERATIONS.map( |
| 81 | + (operation) => block.tools.config.tool?.({ operation }) as string |
| 82 | + ) |
| 83 | + const declared = block.tools.access.filter((id) => id.startsWith('outlook_calendar_')) |
| 84 | + // No calendar tool is declared but unreachable, and none is reached twice. |
| 85 | + expect([...reachable].sort()).toEqual([...declared].sort()) |
| 86 | + expect(new Set(reachable).size).toBe(reachable.length) |
| 87 | + }) |
| 88 | + |
| 89 | + it('routes the calendar picker through the calendarId canonical param', () => { |
| 90 | + const members = block.subBlocks.filter((s) => s.canonicalParamId === 'calendarId') |
| 91 | + expect(members.map((s) => s.id).sort()).toEqual(['calendarSelector', 'manualCalendarId']) |
| 92 | + // Exactly one basic-mode member, per the canonical-group contract. |
| 93 | + expect(members.filter((s) => s.mode === 'basic')).toHaveLength(1) |
| 94 | + // canonicalParamId must never collide with a subBlock id. |
| 95 | + expect(block.subBlocks.some((s) => s.id === 'calendarId')).toBe(false) |
| 96 | + // A blank pick means "default calendar", so it must not be forwarded. |
| 97 | + const mapped = block.tools.config.params?.({ |
| 98 | + operation: 'create_event_calendar', |
| 99 | + ...CALENDAR_SUBBLOCK_VALUES, |
| 100 | + calendarId: ' ', |
| 101 | + }) |
| 102 | + expect(mapped?.calendarId).toBeUndefined() |
| 103 | + }) |
| 104 | + |
| 105 | + it('always sends sendResponse so an unset dropdown cannot read as "do not notify"', () => { |
| 106 | + const withoutChoice = block.tools.config.params?.({ |
| 107 | + operation: 'respond_calendar', |
| 108 | + ...CALENDAR_SUBBLOCK_VALUES, |
| 109 | + calSendResponse: undefined, |
| 110 | + }) |
| 111 | + expect(withoutChoice?.sendResponse).toBe(true) |
| 112 | + |
| 113 | + const declined = block.tools.config.params?.({ |
| 114 | + operation: 'respond_calendar', |
| 115 | + ...CALENDAR_SUBBLOCK_VALUES, |
| 116 | + calSendResponse: 'false', |
| 117 | + }) |
| 118 | + expect(declined?.sendResponse).toBe(false) |
| 119 | + }) |
| 120 | +}) |
0 commit comments