/** * Unit tests for @sh-mcp/internal-data tools. * * Strategy: * - All DynamoDB access is replaced by a typed mock that satisfies InternalDataClient. * - AuthContext is a mock with the required `ops:read` scope (or deliberately wrong * scopes for the scope-enforcement tests). * - No AWS SDK, no network, no environment variables required. * * Coverage: * - Happy path (record found) for each tool * - Empty result (record not found → found=false) * - Downstream error (client rejects) → handler re-throws * - Throttle / retry signal (ProvisionedThroughputExceededException) * - Scope enforcement (missing ops:read) → ScopeError thrown */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { InternalDataClient, WorkOrderRecord, PurchaseOrderRecord, SiteRecord } from '../src/client.js'; import { makeTools } from '../src/tools.js'; import type { AuthContext } from '@sh-mcp/shared'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeCtx(scopes: string[] = ['ops:read']): AuthContext { return { sub: 'lauren@seahavenind.com', scopes: scopes as AuthContext['scopes'], aud: 'sh-mcp-ops', }; } function makeMockClient( overrides: Partial = {}, ): InternalDataClient { return { getWorkOrder: vi.fn().mockResolvedValue(null), getPurchaseOrder: vi.fn().mockResolvedValue(null), getSite: vi.fn().mockResolvedValue(null), ...overrides, }; } // Fixture records const WORK_ORDER_RECORD: WorkOrderRecord = { workOrderId: 'WO-20240101-001', title: 'Replace HVAC filter – Building A', status: 'open', siteId: 'SITE-NYC-001', assignedTo: 'tech1@seahavenind.com', createdAt: '2024-01-01T10:00:00Z', updatedAt: '2024-01-02T08:00:00Z', description: 'Quarterly filter replacement per schedule.', }; const PURCHASE_ORDER_RECORD: PurchaseOrderRecord = { purchaseOrderId: 'PO-2024-00123', vendor: 'Acme Supplies', status: 'approved', totalAmount: 1250.0, currency: 'USD', issuedAt: '2024-01-05T09:00:00Z', updatedAt: '2024-01-06T11:00:00Z', lineItems: [ { description: 'HVAC filter 20x25', quantity: 10, unitPrice: 125.0 }, ], }; const SITE_RECORD: SiteRecord = { siteId: 'SITE-NYC-001', name: 'Sea Haven HQ – New York', address: '123 Main St, New York, NY 10001', region: 'northeast', status: 'active', assignedTechnicians: ['tech1@seahavenind.com'], }; // --------------------------------------------------------------------------- // Tests — lookup_work_order // --------------------------------------------------------------------------- describe('lookup_work_order', () => { let client: InternalDataClient; let tool: ReturnType[0]; beforeEach(() => { client = makeMockClient(); [tool] = makeTools(client); }); it('happy path: returns found=true with shaped output for a known work order', async () => { (client.getWorkOrder as ReturnType).mockResolvedValue(WORK_ORDER_RECORD); const result = await tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx()); expect(result.found).toBe(true); expect(result.workOrder).toMatchObject({ workOrderId: 'WO-20240101-001', title: 'Replace HVAC filter – Building A', status: 'open', siteId: 'SITE-NYC-001', assignedTo: 'tech1@seahavenind.com', }); expect(client.getWorkOrder).toHaveBeenCalledWith('WO-20240101-001'); }); it('empty result: returns found=false when work order does not exist', async () => { (client.getWorkOrder as ReturnType).mockResolvedValue(null); const result = await tool.handler({ workOrderId: 'WO-NOTEXIST' }, makeCtx()); expect(result.found).toBe(false); expect(result.workOrder).toBeUndefined(); }); it('error: re-throws when the DynamoDB client rejects', async () => { (client.getWorkOrder as ReturnType).mockRejectedValue( new Error('DynamoDB unavailable'), ); await expect(tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx())).rejects.toThrow( 'DynamoDB unavailable', ); }); it('throttle/retry: re-throws ProvisionedThroughputExceededException for the caller to retry', async () => { const throttleError = Object.assign( new Error('ProvisionedThroughputExceededException: Rate exceeded'), { name: 'ProvisionedThroughputExceededException' }, ); (client.getWorkOrder as ReturnType).mockRejectedValue(throttleError); await expect(tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx())).rejects.toMatchObject( { name: 'ProvisionedThroughputExceededException' }, ); }); it('scope enforcement: throws when ops:read scope is missing', async () => { // The real shared requireScope throws a ScopeError; our mock honours the // same contract (imported from @sh-mcp/shared in the handler). await expect( tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx([])), ).rejects.toThrow(); }); }); // --------------------------------------------------------------------------- // Tests — lookup_purchase_order // --------------------------------------------------------------------------- describe('lookup_purchase_order', () => { let client: InternalDataClient; let tool: ReturnType[1]; beforeEach(() => { client = makeMockClient(); [, tool] = makeTools(client); }); it('happy path: returns found=true with shaped output for a known PO', async () => { (client.getPurchaseOrder as ReturnType).mockResolvedValue(PURCHASE_ORDER_RECORD); const result = await tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx()); expect(result.found).toBe(true); expect(result.purchaseOrder).toMatchObject({ purchaseOrderId: 'PO-2024-00123', vendor: 'Acme Supplies', status: 'approved', totalAmount: 1250.0, currency: 'USD', }); expect(result.purchaseOrder?.lineItems).toHaveLength(1); expect(client.getPurchaseOrder).toHaveBeenCalledWith('PO-2024-00123'); }); it('empty result: returns found=false when PO does not exist', async () => { (client.getPurchaseOrder as ReturnType).mockResolvedValue(null); const result = await tool.handler({ purchaseOrderId: 'PO-NOTEXIST' }, makeCtx()); expect(result.found).toBe(false); expect(result.purchaseOrder).toBeUndefined(); }); it('error: re-throws when the DynamoDB client rejects', async () => { (client.getPurchaseOrder as ReturnType).mockRejectedValue( new Error('ResourceNotFoundException'), ); await expect( tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx()), ).rejects.toThrow('ResourceNotFoundException'); }); it('throttle/retry: re-throws ProvisionedThroughputExceededException', async () => { const throttleError = Object.assign( new Error('ProvisionedThroughputExceededException: Rate exceeded'), { name: 'ProvisionedThroughputExceededException' }, ); (client.getPurchaseOrder as ReturnType).mockRejectedValue(throttleError); await expect( tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx()), ).rejects.toMatchObject({ name: 'ProvisionedThroughputExceededException' }); }); it('scope enforcement: throws when ops:read scope is missing', async () => { await expect( tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx([])), ).rejects.toThrow(); }); }); // --------------------------------------------------------------------------- // Tests — lookup_site // --------------------------------------------------------------------------- describe('lookup_site', () => { let client: InternalDataClient; let tool: ReturnType[2]; beforeEach(() => { client = makeMockClient(); [, , tool] = makeTools(client); }); it('happy path: returns found=true with shaped output for a known site', async () => { (client.getSite as ReturnType).mockResolvedValue(SITE_RECORD); const result = await tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx()); expect(result.found).toBe(true); expect(result.site).toMatchObject({ siteId: 'SITE-NYC-001', name: 'Sea Haven HQ – New York', address: '123 Main St, New York, NY 10001', region: 'northeast', status: 'active', }); expect(result.site?.assignedTechnicians).toContain('tech1@seahavenind.com'); expect(client.getSite).toHaveBeenCalledWith('SITE-NYC-001'); }); it('empty result: returns found=false when site does not exist', async () => { (client.getSite as ReturnType).mockResolvedValue(null); const result = await tool.handler({ siteId: 'SITE-NOTEXIST' }, makeCtx()); expect(result.found).toBe(false); expect(result.site).toBeUndefined(); }); it('error: re-throws when the DynamoDB client rejects', async () => { (client.getSite as ReturnType).mockRejectedValue( new Error('Internal server error'), ); await expect( tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx()), ).rejects.toThrow('Internal server error'); }); it('throttle/retry: re-throws ProvisionedThroughputExceededException', async () => { const throttleError = Object.assign( new Error('ProvisionedThroughputExceededException: Rate exceeded'), { name: 'ProvisionedThroughputExceededException' }, ); (client.getSite as ReturnType).mockRejectedValue(throttleError); await expect( tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx()), ).rejects.toMatchObject({ name: 'ProvisionedThroughputExceededException' }); }); it('scope enforcement: throws when ops:read scope is missing', async () => { await expect( tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx([])), ).rejects.toThrow(); }); it('scope enforcement: throws when a finance scope is present but ops:read is absent', async () => { // A token from sh-mcp-finance has finance:read but no ops:read. await expect( tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx(['finance:read'])), ).rejects.toThrow(); }); }); // --------------------------------------------------------------------------- // Tool metadata contract tests // --------------------------------------------------------------------------- describe('tool metadata', () => { it('all tools have required metadata fields', () => { const client = makeMockClient(); const tools = makeTools(client); for (const tool of tools) { expect(tool.name).toBeTruthy(); expect(tool.description).toBeTruthy(); expect(tool.tier).toBe('ops'); expect(tool.requiredScope).toBe('ops:read'); expect(tool.inputSchema).toBeTruthy(); expect(typeof tool.handler).toBe('function'); } }); it('tool names are the expected identifiers', () => { const client = makeMockClient(); const tools = makeTools(client); const names = tools.map((t) => t.name); expect(names).toEqual(['lookup_work_order', 'lookup_purchase_order', 'lookup_site']); }); it('input schemas declare required fields and disallow additional properties', () => { const client = makeMockClient(); const [wo, po, site] = makeTools(client); expect((wo.inputSchema as { required: string[] }).required).toContain('workOrderId'); expect((po.inputSchema as { required: string[] }).required).toContain('purchaseOrderId'); expect((site.inputSchema as { required: string[] }).required).toContain('siteId'); for (const tool of [wo, po, site]) { expect((tool.inputSchema as { additionalProperties: boolean }).additionalProperties).toBe(false); } }); });