/** * Unit tests for @sh-mcp/payments tools. * * All tests use: * - A mock AuthContext with finance:read scope (unless testing scope rejection). * - A mock PaymentsClient — no real DynamoDB or AWS calls. * * Coverage targets: * - Happy path for each tool * - Empty result set * - Client error propagation * - Throttle / retry simulation (transient error then success) * - Scope enforcement: missing scope throws ScopeError * - Redaction: bank account, routing, card numbers are masked; vendor names are not */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { AuthContext } from '@sh-mcp/shared'; import { requireScope } from '@sh-mcp/shared'; import type { PaymentsClient, Payment } from '../src/client.js'; import { makeLookupByVendorTool, makeLookupByInvoiceTool, makeLookupByCheckTool, } from '../src/tools.js'; // --------------------------------------------------------------------------- // Shared test fixtures // --------------------------------------------------------------------------- /** A fully-scoped finance context — happy-path default. */ const financeCtx: AuthContext = { sub: 'adam@seahavenind.com', scopes: ['finance:read'], aud: 'sh-mcp-finance', }; /** A context that lacks finance:read — for scope-rejection tests. */ const opsOnlyCtx: AuthContext = { sub: 'staff@seahavenind.com', scopes: ['ops:read'], aud: 'sh-mcp-ops', }; const samplePayment: Payment = { paymentId: 'pmt-001', vendor: 'Acme Electrical Supply', vendorContact: 'billing@acme.example.com', amount: 4250.0, currency: 'USD', invoiceNumber: 'INV-2026-0042', checkNumber: '10412', paymentDate: '2026-05-15', status: 'cleared', bankAccountNumber: '123456789', bankRoutingNumber: '021000021', cardNumber: '4111111111111111', memo: 'Electrical supplies for Ronkonkoma site', }; // --------------------------------------------------------------------------- // Mock client builder // --------------------------------------------------------------------------- function makeMockClient(overrides?: Partial): PaymentsClient { return { getByVendor: vi.fn().mockResolvedValue([samplePayment]), getByInvoice: vi.fn().mockResolvedValue([samplePayment]), getByCheck: vi.fn().mockResolvedValue([samplePayment]), ...overrides, }; } // --------------------------------------------------------------------------- // lookup_payment_by_vendor // --------------------------------------------------------------------------- describe('lookup_payment_by_vendor', () => { let client: PaymentsClient; beforeEach(() => { client = makeMockClient(); }); it('returns payments and redacts sensitive fields on the happy path', async () => { const tool = makeLookupByVendorTool(client); const result = await tool.handler({ vendor: 'Acme' }, financeCtx); expect(result.count).toBe(1); expect(result.payments).toHaveLength(1); const p = result.payments[0]!; // Vendor name and contact must be intact. expect(p.vendor).toBe('Acme Electrical Supply'); expect(p.vendorContact).toBe('billing@acme.example.com'); // Sensitive fields must be redacted (not equal to the originals). expect(p.bankAccountNumber).not.toBe(samplePayment.bankAccountNumber); expect(p.bankRoutingNumber).not.toBe(samplePayment.bankRoutingNumber); expect(p.cardNumber).not.toBe(samplePayment.cardNumber); // Non-sensitive fields must be preserved. expect(p.amount).toBe(4250.0); expect(p.status).toBe('cleared'); expect(p.paymentDate).toBe('2026-05-15'); }); it('forwards the vendor string and limit to the client', async () => { const tool = makeLookupByVendorTool(client); await tool.handler({ vendor: 'Acme', limit: 5 }, financeCtx); expect(client.getByVendor).toHaveBeenCalledWith('Acme', { limit: 5 }); }); it('caps limit at 100', async () => { const tool = makeLookupByVendorTool(client); await tool.handler({ vendor: 'Acme', limit: 9999 }, financeCtx); expect(client.getByVendor).toHaveBeenCalledWith('Acme', { limit: 100 }); }); it('returns empty result when the client returns no payments', async () => { client = makeMockClient({ getByVendor: vi.fn().mockResolvedValue([]), }); const tool = makeLookupByVendorTool(client); const result = await tool.handler({ vendor: 'Unknown Vendor' }, financeCtx); expect(result.count).toBe(0); expect(result.payments).toEqual([]); }); it('propagates a client error', async () => { client = makeMockClient({ getByVendor: vi.fn().mockRejectedValue(new Error('DynamoDB unavailable')), }); const tool = makeLookupByVendorTool(client); await expect(tool.handler({ vendor: 'Acme' }, financeCtx)).rejects.toThrow( 'DynamoDB unavailable', ); }); it('retries and succeeds after a transient throttle error', async () => { // Simulate a throttle on the first call, success on the second. const throttleError = Object.assign(new Error('ProvisionedThroughputExceededException'), { name: 'ProvisionedThroughputExceededException', }); const getByVendor = vi .fn() .mockRejectedValueOnce(throttleError) .mockResolvedValueOnce([samplePayment]); client = makeMockClient({ getByVendor }); // The tool itself does not implement retry logic — that is the // responsibility of the client implementation. We verify here that // when the client internally retries and succeeds, the tool returns // the correct result. We simulate this by wrapping the tool call // in a simple retry loop (as a client-layer retry would do). const tool = makeLookupByVendorTool(client); let result; for (let attempt = 0; attempt < 2; attempt++) { try { result = await tool.handler({ vendor: 'Acme' }, financeCtx); break; } catch { if (attempt === 1) throw new Error('Max retries exceeded'); } } expect(result?.count).toBe(1); expect(getByVendor).toHaveBeenCalledTimes(2); }); it('throws ScopeError when the caller lacks finance:read', async () => { const tool = makeLookupByVendorTool(client); await expect(tool.handler({ vendor: 'Acme' }, opsOnlyCtx)).rejects.toThrow(); // Verify that the error comes from requireScope, not from the client. expect(client.getByVendor).not.toHaveBeenCalled(); }); it('has the correct tool metadata', () => { const tool = makeLookupByVendorTool(client); expect(tool.name).toBe('lookup_payment_by_vendor'); expect(tool.tier).toBe('finance'); expect(tool.requiredScope).toBe('finance:read'); }); }); // --------------------------------------------------------------------------- // lookup_payment_by_invoice // --------------------------------------------------------------------------- describe('lookup_payment_by_invoice', () => { let client: PaymentsClient; beforeEach(() => { client = makeMockClient(); }); it('returns the matching payment with sensitive fields redacted', async () => { const tool = makeLookupByInvoiceTool(client); const result = await tool.handler({ invoiceNumber: 'INV-2026-0042' }, financeCtx); expect(result.count).toBe(1); const p = result.payments[0]!; expect(p.vendor).toBe('Acme Electrical Supply'); expect(p.bankAccountNumber).not.toBe(samplePayment.bankAccountNumber); expect(p.bankRoutingNumber).not.toBe(samplePayment.bankRoutingNumber); expect(p.cardNumber).not.toBe(samplePayment.cardNumber); }); it('forwards the invoice number to the client', async () => { const tool = makeLookupByInvoiceTool(client); await tool.handler({ invoiceNumber: 'INV-2026-0042' }, financeCtx); expect(client.getByInvoice).toHaveBeenCalledWith('INV-2026-0042'); }); it('returns empty result when invoice is not found', async () => { client = makeMockClient({ getByInvoice: vi.fn().mockResolvedValue([]), }); const tool = makeLookupByInvoiceTool(client); const result = await tool.handler({ invoiceNumber: 'INV-NOTFOUND' }, financeCtx); expect(result.count).toBe(0); expect(result.payments).toEqual([]); }); it('propagates a client error', async () => { client = makeMockClient({ getByInvoice: vi.fn().mockRejectedValue(new Error('Internal service error')), }); const tool = makeLookupByInvoiceTool(client); await expect(tool.handler({ invoiceNumber: 'INV-2026-0042' }, financeCtx)).rejects.toThrow( 'Internal service error', ); }); it('retries and succeeds after a transient throttle error', async () => { const throttleError = Object.assign(new Error('ProvisionedThroughputExceededException'), { name: 'ProvisionedThroughputExceededException', }); const getByInvoice = vi .fn() .mockRejectedValueOnce(throttleError) .mockResolvedValueOnce([samplePayment]); client = makeMockClient({ getByInvoice }); const tool = makeLookupByInvoiceTool(client); let result; for (let attempt = 0; attempt < 2; attempt++) { try { result = await tool.handler({ invoiceNumber: 'INV-2026-0042' }, financeCtx); break; } catch { if (attempt === 1) throw new Error('Max retries exceeded'); } } expect(result?.count).toBe(1); expect(getByInvoice).toHaveBeenCalledTimes(2); }); it('throws ScopeError when the caller lacks finance:read', async () => { const tool = makeLookupByInvoiceTool(client); await expect(tool.handler({ invoiceNumber: 'INV-2026-0042' }, opsOnlyCtx)).rejects.toThrow(); expect(client.getByInvoice).not.toHaveBeenCalled(); }); it('has the correct tool metadata', () => { const tool = makeLookupByInvoiceTool(client); expect(tool.name).toBe('lookup_payment_by_invoice'); expect(tool.tier).toBe('finance'); expect(tool.requiredScope).toBe('finance:read'); }); }); // --------------------------------------------------------------------------- // lookup_payment_by_check // --------------------------------------------------------------------------- describe('lookup_payment_by_check', () => { let client: PaymentsClient; beforeEach(() => { client = makeMockClient(); }); it('returns the matching payment with sensitive fields redacted', async () => { const tool = makeLookupByCheckTool(client); const result = await tool.handler({ checkNumber: '10412' }, financeCtx); expect(result.count).toBe(1); const p = result.payments[0]!; expect(p.vendor).toBe('Acme Electrical Supply'); expect(p.bankAccountNumber).not.toBe(samplePayment.bankAccountNumber); expect(p.bankRoutingNumber).not.toBe(samplePayment.bankRoutingNumber); expect(p.cardNumber).not.toBe(samplePayment.cardNumber); }); it('forwards the check number to the client', async () => { const tool = makeLookupByCheckTool(client); await tool.handler({ checkNumber: '10412' }, financeCtx); expect(client.getByCheck).toHaveBeenCalledWith('10412'); }); it('returns empty result when check number is not found', async () => { client = makeMockClient({ getByCheck: vi.fn().mockResolvedValue([]), }); const tool = makeLookupByCheckTool(client); const result = await tool.handler({ checkNumber: '99999' }, financeCtx); expect(result.count).toBe(0); expect(result.payments).toEqual([]); }); it('propagates a client error', async () => { client = makeMockClient({ getByCheck: vi.fn().mockRejectedValue(new Error('Connection timeout')), }); const tool = makeLookupByCheckTool(client); await expect(tool.handler({ checkNumber: '10412' }, financeCtx)).rejects.toThrow( 'Connection timeout', ); }); it('retries and succeeds after a transient throttle error', async () => { const throttleError = Object.assign(new Error('ProvisionedThroughputExceededException'), { name: 'ProvisionedThroughputExceededException', }); const getByCheck = vi .fn() .mockRejectedValueOnce(throttleError) .mockResolvedValueOnce([samplePayment]); client = makeMockClient({ getByCheck }); const tool = makeLookupByCheckTool(client); let result; for (let attempt = 0; attempt < 2; attempt++) { try { result = await tool.handler({ checkNumber: '10412' }, financeCtx); break; } catch { if (attempt === 1) throw new Error('Max retries exceeded'); } } expect(result?.count).toBe(1); expect(getByCheck).toHaveBeenCalledTimes(2); }); it('throws ScopeError when the caller lacks finance:read', async () => { const tool = makeLookupByCheckTool(client); await expect(tool.handler({ checkNumber: '10412' }, opsOnlyCtx)).rejects.toThrow(); expect(client.getByCheck).not.toHaveBeenCalled(); }); it('has the correct tool metadata', () => { const tool = makeLookupByCheckTool(client); expect(tool.name).toBe('lookup_payment_by_check'); expect(tool.tier).toBe('finance'); expect(tool.requiredScope).toBe('finance:read'); }); }); // --------------------------------------------------------------------------- // Redaction contract — cross-cutting // --------------------------------------------------------------------------- describe('redaction contract', () => { it('does not redact vendor name or vendor contact', async () => { const client = makeMockClient(); const tool = makeLookupByVendorTool(client); const result = await tool.handler({ vendor: 'Acme' }, financeCtx); const p = result.payments[0]!; expect(p.vendor).toBe('Acme Electrical Supply'); expect(p.vendorContact).toBe('billing@acme.example.com'); }); it('redacts all three sensitive fields when present', async () => { const client = makeMockClient(); const tool = makeLookupByVendorTool(client); const result = await tool.handler({ vendor: 'Acme' }, financeCtx); const p = result.payments[0]!; // None of the redacted values should equal the originals. expect(p.bankAccountNumber).not.toBe('123456789'); expect(p.bankRoutingNumber).not.toBe('021000021'); expect(p.cardNumber).not.toBe('4111111111111111'); }); it('omits redacted fields when they were undefined in the source', async () => { const minimalPayment: Payment = { paymentId: 'pmt-002', vendor: 'Generic Vendor', amount: 100, currency: 'USD', paymentDate: '2026-06-01', status: 'cleared', // No bankAccountNumber, bankRoutingNumber, or cardNumber }; const client = makeMockClient({ getByVendor: vi.fn().mockResolvedValue([minimalPayment]), }); const tool = makeLookupByVendorTool(client); const result = await tool.handler({ vendor: 'Generic' }, financeCtx); const p = result.payments[0]!; expect(p.bankAccountNumber).toBeUndefined(); expect(p.bankRoutingNumber).toBeUndefined(); expect(p.cardNumber).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // requireScope integration check // --------------------------------------------------------------------------- describe('requireScope integration', () => { it('requireScope does not throw when finance:read is present', () => { // Smoke-test the shared helper directly to confirm it accepts our fixture. expect(() => requireScope(financeCtx, 'finance:read')).not.toThrow(); }); it('requireScope throws when finance:read is absent', () => { expect(() => requireScope(opsOnlyCtx, 'finance:read')).toThrow(); }); });