sh-mcp/packages/internal-data/test/internal-data.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

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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> = {},
): 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<typeof makeTools>[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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof makeTools>[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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof makeTools>[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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
}
});
});