5f28550551
Add string-to-array coercion for ids and memorySessionIds in DataRoutes.ts batch endpoints so MCP clients sending "[1,2,3]" or "1,2,3" instead of native arrays no longer get 400 errors. Wrap observation storage path in SessionRoutes.ts with try/catch returning 200 on recoverable errors instead of 500, preventing hook breakage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
/**
|
|
* DataRoutes Type Coercion Tests
|
|
*
|
|
* Tests that MCP clients sending string-encoded arrays for `ids` and
|
|
* `memorySessionIds` are properly coerced before validation.
|
|
*
|
|
* Mock Justification:
|
|
* - Express req/res mocks: Required because route handlers expect Express objects
|
|
* - DatabaseManager/SessionStore: Avoids database setup; we test coercion logic, not queries
|
|
* - Logger spies: Suppress console output during tests
|
|
*/
|
|
|
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
|
import type { Request, Response } from 'express';
|
|
import { logger } from '../../../../src/utils/logger.js';
|
|
|
|
// Mock dependencies before importing DataRoutes
|
|
mock.module('../../../../src/shared/paths.js', () => ({
|
|
getPackageRoot: () => '/tmp/test',
|
|
}));
|
|
mock.module('../../../../src/shared/worker-utils.js', () => ({
|
|
getWorkerPort: () => 37777,
|
|
}));
|
|
|
|
import { DataRoutes } from '../../../../src/services/worker/http/routes/DataRoutes.js';
|
|
|
|
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
|
|
|
// Helper to create mock req/res
|
|
function createMockReqRes(body: any): { req: Partial<Request>; res: Partial<Response>; jsonSpy: ReturnType<typeof mock>; statusSpy: ReturnType<typeof mock> } {
|
|
const jsonSpy = mock(() => {});
|
|
const statusSpy = mock(() => ({ json: jsonSpy }));
|
|
return {
|
|
req: { body, path: '/test', query: {} } as Partial<Request>,
|
|
res: { json: jsonSpy, status: statusSpy } as unknown as Partial<Response>,
|
|
jsonSpy,
|
|
statusSpy,
|
|
};
|
|
}
|
|
|
|
describe('DataRoutes Type Coercion', () => {
|
|
let routes: DataRoutes;
|
|
let mockGetObservationsByIds: ReturnType<typeof mock>;
|
|
let mockGetSdkSessionsBySessionIds: ReturnType<typeof mock>;
|
|
|
|
beforeEach(() => {
|
|
loggerSpies = [
|
|
spyOn(logger, 'info').mockImplementation(() => {}),
|
|
spyOn(logger, 'debug').mockImplementation(() => {}),
|
|
spyOn(logger, 'warn').mockImplementation(() => {}),
|
|
spyOn(logger, 'error').mockImplementation(() => {}),
|
|
spyOn(logger, 'failure').mockImplementation(() => {}),
|
|
];
|
|
|
|
mockGetObservationsByIds = mock(() => [{ id: 1 }, { id: 2 }]);
|
|
mockGetSdkSessionsBySessionIds = mock(() => [{ id: 'abc' }]);
|
|
|
|
const mockDbManager = {
|
|
getSessionStore: () => ({
|
|
getObservationsByIds: mockGetObservationsByIds,
|
|
getSdkSessionsBySessionIds: mockGetSdkSessionsBySessionIds,
|
|
}),
|
|
};
|
|
|
|
routes = new DataRoutes(
|
|
{} as any, // paginationHelper
|
|
mockDbManager as any,
|
|
{} as any, // sessionManager
|
|
{} as any, // sseBroadcaster
|
|
{} as any, // workerService
|
|
Date.now()
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
loggerSpies.forEach(spy => spy.mockRestore());
|
|
mock.restore();
|
|
});
|
|
|
|
describe('handleGetObservationsByIds — ids coercion', () => {
|
|
// Access the handler via setupRoutes
|
|
let handler: (req: Request, res: Response) => void;
|
|
|
|
beforeEach(() => {
|
|
const mockApp = {
|
|
get: mock(() => {}),
|
|
post: mock((path: string, fn: any) => {
|
|
if (path === '/api/observations/batch') handler = fn;
|
|
}),
|
|
delete: mock(() => {}),
|
|
};
|
|
routes.setupRoutes(mockApp as any);
|
|
});
|
|
|
|
it('should accept a native array of numbers', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ ids: [1, 2, 3] });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should coerce a JSON-encoded string array "[1,2,3]" to native array', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ ids: '[1,2,3]' });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should coerce a comma-separated string "1,2,3" to native array', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ ids: '1,2,3' });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject non-integer values after coercion', () => {
|
|
const { req, res, statusSpy } = createMockReqRes({ ids: 'foo,bar' });
|
|
handler(req as Request, res as Response);
|
|
|
|
// NaN values should fail the Number.isInteger check
|
|
expect(statusSpy).toHaveBeenCalledWith(400);
|
|
});
|
|
|
|
it('should reject missing ids', () => {
|
|
const { req, res, statusSpy } = createMockReqRes({});
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(statusSpy).toHaveBeenCalledWith(400);
|
|
});
|
|
|
|
it('should return empty array for empty ids array', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ ids: [] });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(jsonSpy).toHaveBeenCalledWith([]);
|
|
});
|
|
});
|
|
|
|
describe('handleGetSdkSessionsByIds — memorySessionIds coercion', () => {
|
|
let handler: (req: Request, res: Response) => void;
|
|
|
|
beforeEach(() => {
|
|
const mockApp = {
|
|
get: mock(() => {}),
|
|
post: mock((path: string, fn: any) => {
|
|
if (path === '/api/sdk-sessions/batch') handler = fn;
|
|
}),
|
|
delete: mock(() => {}),
|
|
};
|
|
routes.setupRoutes(mockApp as any);
|
|
});
|
|
|
|
it('should accept a native array of strings', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: ['abc', 'def'] });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should coerce a JSON-encoded string array to native array', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: '["abc","def"]' });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should coerce a comma-separated string to native array', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: 'abc,def' });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should trim whitespace from comma-separated values', () => {
|
|
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: 'abc, def , ghi' });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def', 'ghi']);
|
|
expect(jsonSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject non-array, non-string values', () => {
|
|
const { req, res, statusSpy } = createMockReqRes({ memorySessionIds: 42 });
|
|
handler(req as Request, res as Response);
|
|
|
|
expect(statusSpy).toHaveBeenCalledWith(400);
|
|
});
|
|
});
|
|
});
|