/** * 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(); }); });