/**
* A2A Handler Extended Tests
* Covers error paths and edge cases not in the existing handler.test.ts:
* - tasks/get and tasks/cancel methods
* - getTask / cancelTask aliases
* - Invalid JSON-RPC params (missing message)
* - Empty body
* - Base64-encoded body
* - sendMessage alias
* - Null id in JSON-RPC request
* - Internal server error path
*/
// Mock the TaskManager to control processMessage behavior
const mockProcessMessage = vi.fn();
vi.mock('../../src/a2a/task-manager.js', () => ({
TaskManager: vi.fn().mockImplementation(() => ({
processMessage: mockProcessMessage,
})),
}));
import {
handleA2ARequest,
handleA2AOptions,
handleA2AGetAgentCard,
} from '../../src/a2a/handler.js';
// ─── Helpers ───
interface APIGatewayEvent {
httpMethod: string;
path: string;
headers: Record<string, string | undefined>;
body: string | null;
isBase64Encoded: boolean;
}
function makeEvent(overrides: Partial<APIGatewayEvent> = {}): APIGatewayEvent {
return {
httpMethod: 'POST',
path: '/a2a',
headers: { 'content-type': 'application/json' },
body: null,
isBase64Encoded: false,
...overrides,
};
}
function buildJsonRpc(
id: string | number,
method: string,
params?: Record<string, unknown>,
): string {
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
}
function _buildSendMessage(
id: string | number,
skill: string,
params: Record<string, unknown>,
): string {
return JSON.stringify({
jsonrpc: '2.0',
id,
method: 'message/send',
params: {
message: {
role: 'user',
parts: [{ type: 'data', data: { skill, params } }],
},
},
});
}
// ─── Tests ───
describe('handleA2ARequest — tasks/get', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns task not found for tasks/get with a task ID', async () => {
const event = makeEvent({
body: buildJsonRpc(1, 'tasks/get', { id: 'task-123' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(1);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32001); // TASK_NOT_FOUND
expect(body.error.data).toContain('task-123');
expect(body.error.data).toContain('stateless');
});
it('returns task not found for getTask alias', async () => {
const event = makeEvent({
body: buildJsonRpc(2, 'getTask', { id: 'task-abc' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32001);
expect(body.error.data).toContain('task-abc');
});
it('uses "unknown" when tasks/get params have no id', async () => {
const event = makeEvent({
body: buildJsonRpc(3, 'tasks/get', {}),
});
const res = await handleA2ARequest(event);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32001);
expect(body.error.data).toContain('unknown');
});
});
describe('handleA2ARequest — tasks/cancel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns task not cancelable for tasks/cancel', async () => {
const event = makeEvent({
body: buildJsonRpc(4, 'tasks/cancel', { id: 'task-456' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(4);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32002); // TASK_NOT_CANCELABLE
expect(body.error.data).toContain('task-456');
expect(body.error.data).toContain('cannot be canceled');
});
it('returns task not cancelable for cancelTask alias', async () => {
const event = makeEvent({
body: buildJsonRpc(5, 'cancelTask', { id: 'task-def' }),
});
const res = await handleA2ARequest(event);
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32002);
expect(body.error.data).toContain('task-def');
});
it('uses "unknown" when tasks/cancel params have no id', async () => {
const event = makeEvent({
body: buildJsonRpc(6, 'tasks/cancel', {}),
});
const res = await handleA2ARequest(event);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32002);
expect(body.error.data).toContain('unknown');
});
});
describe('handleA2ARequest — sendMessage edge cases', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns invalid params when message/send has no message field', async () => {
const event = makeEvent({
body: buildJsonRpc(7, 'message/send', { something: 'else' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32602); // INVALID_PARAMS
});
it('returns invalid params when sendMessage alias has no message', async () => {
const event = makeEvent({
body: buildJsonRpc(8, 'sendMessage', {}),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32602);
});
it('returns invalid params when message/send has no params at all', async () => {
const event = makeEvent({
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'message/send' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32602);
});
it('routes sendMessage alias to TaskManager and returns task', async () => {
mockProcessMessage.mockResolvedValue({
id: 'task-mock-1',
status: { state: 'completed', timestamp: '2026-02-19T10:00:00.000Z' },
artifacts: [{ name: 'result', parts: [{ type: 'data', data: { ok: true } }] }],
});
const event = makeEvent({
body: JSON.stringify({
jsonrpc: '2.0',
id: 10,
method: 'sendMessage',
params: {
message: {
role: 'user',
parts: [{ type: 'data', data: { skill: 'scout_inventory', params: {} } }],
},
},
}),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(10);
expect(body.error).toBeUndefined();
expect(body.result).toBeDefined();
expect(body.result.id).toBe('task-mock-1');
expect(body.result.status.state).toBe('completed');
});
it('returns internal error when TaskManager.processMessage throws', async () => {
mockProcessMessage.mockRejectedValue(new Error('Unexpected crash'));
const event = makeEvent({
body: JSON.stringify({
jsonrpc: '2.0',
id: 11,
method: 'message/send',
params: {
message: {
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
},
},
}),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(500);
const body = JSON.parse(res.body);
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(11);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32603); // INTERNAL_ERROR
expect(body.error.data).toBe('Unexpected crash');
});
it('returns internal error with stringified non-Error throw', async () => {
mockProcessMessage.mockRejectedValue('raw string error');
const event = makeEvent({
body: JSON.stringify({
jsonrpc: '2.0',
id: 12,
method: 'message/send',
params: {
message: {
role: 'user',
parts: [{ type: 'text', text: 'test' }],
},
},
}),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(500);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32603);
expect(body.error.data).toBe('raw string error');
});
});
describe('handleA2ARequest — JSON-RPC validation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns parse error when body is null (empty)', async () => {
const event = makeEvent({ body: null });
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.id).toBeNull();
expect(body.error.code).toBe(-32700); // PARSE_ERROR
});
it('returns parse error when body is empty string', async () => {
const event = makeEvent({ body: '' });
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.id).toBeNull();
expect(body.error.code).toBe(-32700);
});
it('returns parse error for malformed JSON', async () => {
const event = makeEvent({ body: '{not valid json' });
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.id).toBeNull();
expect(body.error.code).toBe(-32700);
});
it('returns invalid request when jsonrpc is not "2.0"', async () => {
const event = makeEvent({
body: JSON.stringify({ jsonrpc: '1.0', id: 20, method: 'message/send' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32600); // INVALID_REQUEST
});
it('returns invalid request when method is missing', async () => {
const event = makeEvent({
body: JSON.stringify({ jsonrpc: '2.0', id: 21 }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32600);
});
it('returns invalid request when id is null', async () => {
const event = makeEvent({
body: JSON.stringify({ jsonrpc: '2.0', id: null, method: 'message/send' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32600);
});
it('returns invalid request when id is undefined (missing)', async () => {
const event = makeEvent({
body: JSON.stringify({ jsonrpc: '2.0', method: 'message/send' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32600);
});
it('returns method not found for completely unknown method', async () => {
const event = makeEvent({
body: buildJsonRpc(22, 'some/random/method'),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.id).toBe(22);
expect(body.error.code).toBe(-32601); // METHOD_NOT_FOUND
});
});
describe('handleA2ARequest — base64 body decoding', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('decodes base64-encoded body when isBase64Encoded is true', async () => {
const jsonBody = buildJsonRpc(30, 'tasks/get', { id: 'task-b64' });
const base64Body = Buffer.from(jsonBody, 'utf-8').toString('base64');
const event = makeEvent({
body: base64Body,
isBase64Encoded: true,
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Should have correctly decoded and parsed the request
expect(body.id).toBe(30);
expect(body.error.code).toBe(-32001); // TASK_NOT_FOUND (correctly routed)
expect(body.error.data).toContain('task-b64');
});
it('returns parse error for base64 flag with null body', async () => {
const event = makeEvent({
body: null,
isBase64Encoded: true,
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.error.code).toBe(-32700); // PARSE_ERROR
});
});
describe('handleA2ARequest — CORS headers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('includes CORS headers in all responses', async () => {
const event = makeEvent({
body: buildJsonRpc(40, 'tasks/get', { id: 'test' }),
});
const res = await handleA2ARequest(event);
expect(res.headers['Content-Type']).toBe('application/json');
expect(res.headers['Access-Control-Allow-Origin']).toBe('*');
expect(res.headers['Access-Control-Allow-Methods']).toContain('POST');
expect(res.headers['Access-Control-Allow-Headers']).toContain('Content-Type');
});
});
describe('handleA2AOptions', () => {
it('returns 204 with CORS headers and empty body', () => {
const res = handleA2AOptions();
expect(res.statusCode).toBe(204);
expect(res.body).toBe('');
expect(res.headers['Access-Control-Allow-Origin']).toBe('*');
});
});
describe('handleA2AGetAgentCard', () => {
it('returns agent card with all 5 skills', () => {
const res = handleA2AGetAgentCard();
expect(res.statusCode).toBe(200);
expect(res.headers['Content-Type']).toBe('application/json');
const card = JSON.parse(res.body);
expect(card.name).toBe('shopify-agentic-mcp');
expect(card.skills).toHaveLength(5);
expect(card.capabilities).toBeDefined();
expect(card.capabilities.streaming).toBe(false);
});
});