sh-mcp/packages/payments/test/payments.test.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
* Phase 0b slice: monorepo scaffold + shared core + integration packages

The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku
scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers.

- Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate),
  eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu).
- @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry,
  redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider
  interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2.
- 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base,
  gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind
  injected client interfaces; finance handlers call redact().

Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally
deferred until the 0a spike resolves it (G16/§0.4).

* Complete Cognito auth provider + Phase 1 build brief

Finish the WIP CognitoAuthProvider (client_id allow-list as audience
boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with
its test suite, and check in docs/build-plan-phase-1.md so the Phase 1
work has its governing brief in-tree (design.md §2.5).

* ci: disable cdk synth for Phase 0b (no CDK app yet)

The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but
the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails
with '--app is required'. Disable it here; Phase 1 re-enables it with the
server CDK stubs.
2026-06-26 12:42:17 -04:00

467 lines
15 KiB
TypeScript

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