import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { SchedulerClient, CreateScheduleInput, CreateScheduleOutput } from '../src/client.js'; import { buildReminderTools } from '../src/tools.js'; import type { AuthContext } from '@sh-mcp/shared'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Returns a Date that is `offsetMs` milliseconds in the future. */ function futureDate(offsetMs = 5 * 60 * 1000): Date { return new Date(Date.now() + offsetMs); } /** AuthContext with ops:tasks scope — the happy-path identity. */ function makeCtx(overrides: Partial = {}): AuthContext { return { sub: 'lauren@seahavenind.com', scopes: ['ops:read', 'ops:tasks'], aud: 'sh-mcp-ops', ...overrides, }; } /** Build a mock SchedulerClient whose createSchedule can be controlled per-test. */ function makeMockClient( impl?: (input: CreateScheduleInput) => Promise, ): SchedulerClient { return { createSchedule: vi.fn( impl ?? (async (input: CreateScheduleInput): Promise => ({ scheduleArn: `arn:aws:scheduler:us-east-1:328440206208:schedule/default/${input.scheduleName}`, })), ), }; } // --------------------------------------------------------------------------- // Suite // --------------------------------------------------------------------------- describe('create_reminder', () => { let mockClient: SchedulerClient; let createReminder: ReturnType[0]; beforeEach(() => { mockClient = makeMockClient(); [createReminder] = buildReminderTools({ client: mockClient }); }); // ------------------------------------------------------------------------- // Tool definition contract // ------------------------------------------------------------------------- describe('tool definition', () => { it('has the correct name', () => { expect(createReminder.name).toBe('create_reminder'); }); it('is in the ops tier', () => { expect(createReminder.tier).toBe('ops'); }); it('requires the ops:tasks scope', () => { expect(createReminder.requiredScope).toBe('ops:tasks'); }); it('has a non-empty description', () => { expect(createReminder.description.length).toBeGreaterThan(10); }); it('input schema declares remind_at and message as required', () => { const schema = createReminder.inputSchema as { required: string[]; properties: Record; }; expect(schema.required).toContain('remind_at'); expect(schema.required).toContain('message'); }); }); // ------------------------------------------------------------------------- // Happy path // ------------------------------------------------------------------------- describe('happy path', () => { it('creates a schedule and returns a confirmation', async () => { const remindAt = futureDate(10 * 60 * 1000).toISOString(); const ctx = makeCtx(); const result = await createReminder.handler( { remind_at: remindAt, message: 'Pick up the dry cleaning' }, ctx, ); expect(result.remind_at).toBe(new Date(remindAt).toISOString()); expect(result.reminder_id).toMatch(/^sh-reminder-/); expect(result.confirmation).toContain('Pick up the dry cleaning'); }); it('passes the message and sub in the schedule payload', async () => { const remindAt = futureDate(10 * 60 * 1000).toISOString(); const ctx = makeCtx(); await createReminder.handler({ remind_at: remindAt, message: 'Follow up with vendor' }, ctx); const calls = (mockClient.createSchedule as ReturnType).mock.calls; expect(calls).toHaveLength(1); const [callInput] = calls[0] as [CreateScheduleInput]; expect(callInput.payload['sub']).toBe('lauren@seahavenind.com'); expect(callInput.payload['message']).toBe('Follow up with vendor'); }); it('defaults recipient to the calling user sub when not provided', async () => { const remindAt = futureDate(10 * 60 * 1000).toISOString(); const ctx = makeCtx(); await createReminder.handler({ remind_at: remindAt, message: 'Check email' }, ctx); const [callInput] = (mockClient.createSchedule as ReturnType).mock.calls[0] as [ CreateScheduleInput, ]; expect(callInput.payload['recipient']).toBe('lauren@seahavenind.com'); }); it('resolves recipient to sub when "me" is passed explicitly', async () => { const remindAt = futureDate(10 * 60 * 1000).toISOString(); const ctx = makeCtx(); await createReminder.handler( { remind_at: remindAt, message: 'Standup in 5', recipient: 'me' }, ctx, ); const [callInput] = (mockClient.createSchedule as ReturnType).mock.calls[0] as [ CreateScheduleInput, ]; expect(callInput.payload['recipient']).toBe('lauren@seahavenind.com'); }); it('forwards a custom channel recipient unchanged', async () => { const remindAt = futureDate(10 * 60 * 1000).toISOString(); const ctx = makeCtx(); await createReminder.handler( { remind_at: remindAt, message: 'Team standup', recipient: 'C01234567' }, ctx, ); const [callInput] = (mockClient.createSchedule as ReturnType).mock.calls[0] as [ CreateScheduleInput, ]; expect(callInput.payload['recipient']).toBe('C01234567'); }); it('scheduleAt is the ISO string of remind_at', async () => { const remindAt = futureDate(30 * 60 * 1000).toISOString(); const ctx = makeCtx(); await createReminder.handler({ remind_at: remindAt, message: 'Check metrics' }, ctx); const [callInput] = (mockClient.createSchedule as ReturnType).mock.calls[0] as [ CreateScheduleInput, ]; expect(callInput.scheduleAt).toBe(new Date(remindAt).toISOString()); }); }); // ------------------------------------------------------------------------- // Scope enforcement // ------------------------------------------------------------------------- describe('scope enforcement', () => { it('throws when ops:tasks scope is missing', async () => { const ctx = makeCtx({ scopes: ['ops:read'] }); const remindAt = futureDate(10 * 60 * 1000).toISOString(); await expect( createReminder.handler({ remind_at: remindAt, message: 'Unauthorized' }, ctx), ).rejects.toThrow(); }); it('throws when scopes array is empty', async () => { const ctx = makeCtx({ scopes: [] }); const remindAt = futureDate(10 * 60 * 1000).toISOString(); await expect( createReminder.handler({ remind_at: remindAt, message: 'No scopes' }, ctx), ).rejects.toThrow(); }); it('succeeds when only ops:tasks is present (minimal grant)', async () => { const ctx = makeCtx({ scopes: ['ops:tasks'] }); const remindAt = futureDate(10 * 60 * 1000).toISOString(); await expect( createReminder.handler({ remind_at: remindAt, message: 'Minimal scope' }, ctx), ).resolves.toBeDefined(); }); }); // ------------------------------------------------------------------------- // Input validation // ------------------------------------------------------------------------- describe('input validation', () => { it('rejects an invalid remind_at string', async () => { const ctx = makeCtx(); await expect( createReminder.handler({ remind_at: 'not-a-date', message: 'Bad date' }, ctx), ).rejects.toThrow(/invalid remind_at/); }); it('rejects a remind_at that is in the past', async () => { const ctx = makeCtx(); const past = new Date(Date.now() - 60_000).toISOString(); await expect( createReminder.handler({ remind_at: past, message: 'Past date' }, ctx), ).rejects.toThrow(/at least 1 minute in the future/); }); it('rejects a remind_at less than 1 minute in the future', async () => { const ctx = makeCtx(); const tooSoon = new Date(Date.now() + 30_000).toISOString(); await expect( createReminder.handler({ remind_at: tooSoon, message: 'Too soon' }, ctx), ).rejects.toThrow(/at least 1 minute in the future/); }); }); // ------------------------------------------------------------------------- // Empty / minimal result // ------------------------------------------------------------------------- describe('edge cases', () => { it('handles a single-character message without truncating confirmation', async () => { const ctx = makeCtx(); const remindAt = futureDate(10 * 60 * 1000).toISOString(); const result = await createReminder.handler({ remind_at: remindAt, message: 'A' }, ctx); expect(result.confirmation).toContain('"A"'); }); it('truncates long messages in the confirmation with ellipsis', async () => { const ctx = makeCtx(); const remindAt = futureDate(10 * 60 * 1000).toISOString(); const longMessage = 'x'.repeat(200); const result = await createReminder.handler( { remind_at: remindAt, message: longMessage }, ctx, ); expect(result.confirmation).toContain('…'); }); it('schedule name is within EventBridge name length limit (64 chars)', async () => { const ctx = makeCtx({ sub: 'a-very-long-user-sub-that-exceeds-typical-length@seahavenind.com', }); const remindAt = futureDate(10 * 60 * 1000).toISOString(); const result = await createReminder.handler( { remind_at: remindAt, message: 'Name length check' }, ctx, ); expect(result.reminder_id.length).toBeLessThanOrEqual(64); }); }); // ------------------------------------------------------------------------- // Error path // ------------------------------------------------------------------------- describe('error handling', () => { it('surfaces a generic scheduler error to the caller', async () => { const failingClient = makeMockClient(async () => { throw new Error('Scheduler internal error'); }); const [failTool] = buildReminderTools({ client: failingClient }); const ctx = makeCtx(); const remindAt = futureDate(10 * 60 * 1000).toISOString(); await expect( failTool.handler({ remind_at: remindAt, message: 'Will fail' }, ctx), ).rejects.toThrow('Scheduler internal error'); }); }); // ------------------------------------------------------------------------- // Throttle / retry simulation // ------------------------------------------------------------------------- describe('throttle / retry', () => { it('surfaces a ThrottlingException from the scheduler', async () => { let callCount = 0; const throttlingClient = makeMockClient(async () => { callCount += 1; const err = Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException', $fault: 'client', }); throw err; }); const [throttleTool] = buildReminderTools({ client: throttlingClient }); const ctx = makeCtx(); const remindAt = futureDate(10 * 60 * 1000).toISOString(); await expect( throttleTool.handler({ remind_at: remindAt, message: 'Throttled' }, ctx), ).rejects.toThrow('Rate exceeded'); // The tool itself does not retry — retries are the transport/server layer's // responsibility. Assert the client was called exactly once. expect(callCount).toBe(1); }); it('surfaces a ConflictException when a schedule name already exists', async () => { const conflictClient = makeMockClient(async () => { const err = Object.assign(new Error('Schedule already exists with this name'), { name: 'ConflictException', }); throw err; }); const [conflictTool] = buildReminderTools({ client: conflictClient }); const ctx = makeCtx(); const remindAt = futureDate(10 * 60 * 1000).toISOString(); await expect( conflictTool.handler({ remind_at: remindAt, message: 'Duplicate' }, ctx), ).rejects.toThrow('Schedule already exists'); }); }); });