/**
* A2A Handler Tests
* Tests the A2A handler: Agent Card generation, JSON-RPC dispatch,
* and task execution across all supported skills.
*/
import { describe, it, expect } from 'vitest';
import {
handleA2ARequest,
handleA2AOptions,
handleA2AGetAgentCard,
} from '../../src/a2a/handler.js';
// ─── Test Helper ───
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,
};
}
/**
* Build a valid JSON-RPC 2.0 request body for the a]2a/tasks.send method.
*/
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 },
},
],
},
},
});
}
// ─── handleA2AOptions ───
describe('handleA2AOptions', () => {
it('returns 204 with CORS headers', () => {
const res = handleA2AOptions();
expect(res.statusCode).toBe(204);
expect(res.body).toBe('');
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');
});
});
// ─── handleA2AGetAgentCard ───
describe('handleA2AGetAgentCard', () => {
it('returns 200 with agent card JSON', () => {
const res = handleA2AGetAgentCard();
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body).toBeDefined();
expect(typeof body.name).toBe('string');
expect(typeof body.description).toBe('string');
expect(typeof body.version).toBe('string');
expect(body.capabilities).toBeDefined();
expect(Array.isArray(body.skills)).toBe(true);
});
it('response includes name "shopify-agentic-mcp" and 5 skills', () => {
const res = handleA2AGetAgentCard();
const body = JSON.parse(res.body);
expect(body.name).toBe('shopify-agentic-mcp');
expect(body.skills).toHaveLength(5);
// Verify all expected skill IDs are present
const skillIds = body.skills.map((s: { id: string }) => s.id);
expect(skillIds).toContain('scout_inventory');
expect(skillIds).toContain('manage_cart');
expect(skillIds).toContain('negotiate_terms');
expect(skillIds).toContain('execute_checkout');
expect(skillIds).toContain('track_order');
});
});
// ─── handleA2ARequest — sendMessage ───
describe('handleA2ARequest — sendMessage', () => {
it('successfully routes to scout_inventory', async () => {
const event = makeEvent({
body: buildSendMessage(1, 'scout_inventory', { query: 'shirt' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Valid JSON-RPC 2.0 response
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(1);
expect(body.error).toBeUndefined();
// Result should contain a task
expect(body.result).toBeDefined();
const task = body.result;
expect(typeof task.id).toBe('string');
expect(task.status).toBeDefined();
expect(typeof task.status.state).toBe('string');
// scout_inventory may fail without Shopify keys — either state is fine
expect(['completed', 'failed']).toContain(task.status.state);
expect(typeof task.status.timestamp).toBe('string');
});
it('successfully routes to manage_cart create', async () => {
const event = makeEvent({
body: buildSendMessage(2, 'manage_cart', { action: 'create' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Valid JSON-RPC 2.0 response
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(2);
expect(body.error).toBeUndefined();
// Task should be completed with a cart_id in the artifact data
const task = body.result;
expect(task.status.state).toBe('completed');
expect(task.artifacts).toBeDefined();
expect(task.artifacts.length).toBeGreaterThan(0);
// The first artifact should contain a data part with cart_id
const dataPart = task.artifacts[0].parts.find(
(p: { type: string }) => p.type === 'data',
);
expect(dataPart).toBeDefined();
expect(typeof dataPart.data.cart_id).toBe('string');
expect(dataPart.data.cart_id.length).toBeGreaterThan(0);
});
it('successfully routes to track_order', async () => {
const event = makeEvent({
body: buildSendMessage(3, 'track_order', { order_id: 'order-123' }),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Valid JSON-RPC 2.0 response
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(3);
expect(body.error).toBeUndefined();
// track_order returns mock data when no API key is configured
const task = body.result;
expect(task.status.state).toBe('completed');
expect(typeof task.status.timestamp).toBe('string');
});
});
// ─── handleA2ARequest — errors ───
describe('handleA2ARequest — errors', () => {
it('returns parse error for invalid JSON body', 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.jsonrpc).toBe('2.0');
expect(body.id).toBeNull();
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32700); // Parse error
});
it('returns method not found for unknown method', async () => {
const event = makeEvent({
body: JSON.stringify({
jsonrpc: '2.0',
id: 10,
method: 'nonexistent/method',
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).toBeDefined();
expect(body.error.code).toBe(-32601); // Method not found
});
it('returns invalid request when jsonrpc field is missing', async () => {
const event = makeEvent({
body: JSON.stringify({
id: 11,
method: 'message/send',
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(11);
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32600); // Invalid request
});
it('returns failed task for unknown skill', async () => {
const event = makeEvent({
body: buildSendMessage(12, 'nonexistent', {}),
});
const res = await handleA2ARequest(event);
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Valid JSON-RPC 2.0 response
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(12);
// The task itself should be returned but with state "failed"
// (unknown skill is not a JSON-RPC error — it's a successfully processed
// request that resulted in a failed task)
expect(body.result).toBeDefined();
const task = body.result;
expect(task.status.state).toBe('failed');
});
});