import { AxiosError } from 'axios';
import { Ok } from 'ts-results-es';
import { z } from 'zod';
import { log } from '../logging/log.js';
import { Server } from '../server.js';
import invariant from '../utils/invariant.js';
import { Tool } from './tool.js';
import { getMockRequestHandlerExtra } from './toolContext.mock.js';
// Mock for product telemetry - tracks calls to send()
const mockTelemetrySend = vi.hoisted(() => vi.fn());
vi.mock('../telemetry/productTelemetry/telemetryForwarder.js', () => ({
getProductTelemetry: vi.fn().mockReturnValue({
send: mockTelemetrySend,
}),
}));
describe('Tool', () => {
const mockExtra = getMockRequestHandlerExtra();
const mockParams = {
server: new Server(),
name: 'get-datasource-metadata',
description: 'A test tool',
paramsSchema: {
param1: z.string(),
},
argsValidator: vi.fn(),
annotations: {
title: 'Get Datasource Metadata',
readOnlyHint: true,
openWorldHint: false,
},
callback: vi.fn(),
} as const;
it('should create a tool instance with correct properties', () => {
const tool = new Tool(mockParams);
expect(tool.name).toBe(mockParams.name);
expect(tool.description).toBe(mockParams.description);
expect(tool.paramsSchema).toBe(mockParams.paramsSchema);
expect(tool.callback).toBe(mockParams.callback);
});
it('should log invocation with provided args', () => {
const spy = vi.spyOn(log, 'debug');
const tool = new Tool(mockParams);
const testArgs = { param1: 'test' };
tool.logInvocation({ requestId: '2', args: testArgs, username: 'test-user' });
const server = expect.any(Object);
expect(spy).toHaveBeenCalledExactlyOnceWith(server, {
type: 'tool',
requestId: '2',
username: 'test-user',
tool: {
name: 'get-datasource-metadata',
args: testArgs,
},
});
});
it('should return successful result when callback succeeds', async () => {
const tool = new Tool(mockParams);
const successResult = { data: 'success' };
const callback = vi
.fn()
.mockImplementation(async (_requestId: string) => new Ok(successResult));
const spy = vi.spyOn(tool, 'logInvocation');
const result = await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test' },
callback,
constrainSuccessResult: (result) => {
return {
type: 'success',
result,
};
},
});
expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
expect(JSON.parse(result.content[0].text)).toEqual(successResult);
expect(spy).toHaveBeenCalledExactlyOnceWith({
requestId: 2,
args: {
param1: 'test',
},
});
});
it('should return error result when callback throws', async () => {
const tool = new Tool(mockParams);
const errorMessage = 'Test error';
const callback = vi.fn().mockImplementation(async (_requestId: string) => {
throw new Error(errorMessage);
});
const result = await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test' },
callback,
constrainSuccessResult: (result) => {
return {
type: 'success',
result,
};
},
});
expect(result.isError).toBe(true);
invariant(result.content[0].type === 'text');
expect(result.content[0].text).toBe('requestId: 2, error: Test error');
});
it('should call argsValidator with provided args', async () => {
const tool = new Tool(mockParams);
const args = { param1: 'test' };
await tool.logAndExecute({
extra: mockExtra,
args,
callback: vi.fn(),
constrainSuccessResult: (result) => {
return {
type: 'success',
result,
};
},
});
expect(mockParams.argsValidator).toHaveBeenCalledWith(args);
});
it('should return error result when argsValidator throws', async () => {
const tool = new Tool({
server: new Server(),
name: 'get-datasource-metadata',
description: 'test',
paramsSchema: z.object({ param1: z.string() }).shape,
annotations: { title: 'test', readOnlyHint: true, openWorldHint: false },
argsValidator: (_) => {
throw new Error('Test error');
},
callback: ({ param1 }) => {
return {
isError: false,
content: [{ type: 'text', text: param1 }],
};
},
});
const result = await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test' },
callback: () => Promise.resolve(Ok('test')),
constrainSuccessResult: (result) => {
return {
type: 'success',
result,
};
},
});
expect(result.isError).toBe(true);
invariant(result.content[0].type === 'text');
expect(result.content[0].text).toBe('requestId: 2, error: Test error');
});
it('should constrain the success result', async () => {
const tool = new Tool(mockParams);
const successResult = { data: 'success' };
const result = await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test' },
callback: () => Promise.resolve(Ok(successResult)),
constrainSuccessResult: (result) => {
return {
type: 'success',
result: {
...result,
additionalField: 'extra',
},
};
},
});
expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
expect(JSON.parse(result.content[0].text)).toEqual({
...successResult,
additionalField: 'extra',
});
});
it('should return empty result when the constrained result is empty', async () => {
const tool = new Tool(mockParams);
const successResult = { data: 'success' };
const result = await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test' },
callback: () => Promise.resolve(Ok(successResult)),
constrainSuccessResult: (_result) => {
return {
type: 'empty',
message: 'No data found',
};
},
});
expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
expect(result.content[0].text).toBe('No data found');
});
it('should return error result when the constrained result is error', async () => {
const tool = new Tool(mockParams);
const successResult = { data: 'success' };
const result = await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test' },
callback: () => Promise.resolve(Ok(successResult)),
constrainSuccessResult: (_result) => {
return {
type: 'error',
message: 'An error occurred',
};
},
});
expect(result.isError).toBe(true);
invariant(result.content[0].type === 'text');
expect(result.content[0].text).toBe('An error occurred');
});
describe('product telemetry', () => {
beforeEach(() => {
mockTelemetrySend.mockClear();
});
it('should send telemetry with success=true and empty error_code on success', async () => {
const tool = new Tool(mockParams);
await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test-value' },
callback: () => Promise.resolve(Ok({ data: 'success' })),
constrainSuccessResult: (result) => ({ type: 'success', result }),
});
expect(mockTelemetrySend).toHaveBeenCalledWith(
'tool_call',
expect.objectContaining({
tool_name: 'get-datasource-metadata',
request_id: '2',
session_id: '',
site_luid: '',
podname: 'https://my-tableau-server.com',
is_hyperforce: false,
success: true,
error_code: '',
}),
);
});
it('should send telemetry with success=false and error_code=400 on validation error', async () => {
const tool = new Tool({
...mockParams,
argsValidator: () => {
throw new Error('Validation failed');
},
});
await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test-value' },
callback: () => Promise.resolve(Ok({ data: 'success' })),
constrainSuccessResult: (result) => ({ type: 'success', result }),
});
expect(mockTelemetrySend).toHaveBeenCalledWith(
'tool_call',
expect.objectContaining({
is_hyperforce: false,
success: false,
error_code: '400',
}),
);
});
it('should send telemetry with success=false on callback error', async () => {
const tool = new Tool(mockParams);
await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test-value' },
callback: () => {
throw new Error('Callback failed');
},
constrainSuccessResult: (result) => ({ type: 'success', result }),
});
expect(mockTelemetrySend).toHaveBeenCalledWith(
'tool_call',
expect.objectContaining({
is_hyperforce: false,
success: false,
error_code: '', // No HTTP status for generic errors
}),
);
});
it('should send telemetry with actual HTTP status code on API error', async () => {
const tool = new Tool(mockParams);
const axiosError = new AxiosError('Unauthorized');
axiosError.response = { status: 401 } as AxiosError['response'];
await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test-value' },
callback: () => {
throw axiosError;
},
constrainSuccessResult: (result) => ({ type: 'success', result }),
});
expect(mockTelemetrySend).toHaveBeenCalledWith(
'tool_call',
expect.objectContaining({
is_hyperforce: false,
success: false,
error_code: '401',
}),
);
});
it('should send telemetry with success=false and empty error_code on constrained error', async () => {
const tool = new Tool(mockParams);
await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test-value' },
callback: () => Promise.resolve(Ok({ data: 'success' })),
constrainSuccessResult: () => ({ type: 'error', message: 'Constrained error' }),
});
expect(mockTelemetrySend).toHaveBeenCalledWith(
'tool_call',
expect.objectContaining({
is_hyperforce: false,
success: false,
error_code: '',
}),
);
});
it('should send telemetry with success=true on constrained empty result', async () => {
const tool = new Tool(mockParams);
await tool.logAndExecute({
extra: mockExtra,
args: { param1: 'test-value' },
callback: () => Promise.resolve(Ok({ data: 'success' })),
constrainSuccessResult: () => ({ type: 'empty', message: 'No data' }),
});
expect(mockTelemetrySend).toHaveBeenCalledWith(
'tool_call',
expect.objectContaining({
is_hyperforce: false,
success: true,
error_code: '',
}),
);
});
});
});