getDatasourceMetadata.test.ts•22.7 kB
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { Err, Ok } from 'ts-results-es';
import { Server } from '../../server.js';
import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js';
import { exportedForTesting as resourceAccessCheckerExportedForTesting } from '../resourceAccessChecker.js';
import { getGetDatasourceMetadataTool } from './getDatasourceMetadata.js';
const { resetResourceAccessCheckerSingleton } = resourceAccessCheckerExportedForTesting;
const mockReadMetadataResponses = vi.hoisted(() => ({
success: {
data: [
{
fieldName: 'Calculation_123456789',
fieldCaption: 'Profit Ratio',
columnClass: 'CALCULATION',
dataType: 'REAL',
defaultAggregation: 'SUM',
logicalTableId: '',
formula: 'SUM([Profit])/SUM([Sales])',
},
{
fieldName: 'Product Name',
fieldCaption: 'Product Name',
dataType: 'STRING',
defaultAggregation: 'COUNT',
logicalTableId: 'Orders_123456789',
columnClass: 'COLUMN',
},
{
fieldName: 'Quantity',
fieldCaption: 'Quantity',
dataType: 'INTEGER',
defaultAggregation: 'SUM',
logicalTableId: 'Orders_123456789',
columnClass: 'COLUMN',
},
],
extraData: {
parameters: [
{
parameterType: 'QUANTITATIVE_DATE',
parameterName: 'Parameter 1',
parameterCaption: 'Test Date',
dataType: 'DATE',
value: '2025-10-17',
minDate: '2024-01-01',
maxDate: '2026-01-01',
periodType: null,
periodValue: null,
},
{
parameterType: 'QUANTITATIVE_RANGE',
parameterName: 'Parameter 2',
parameterCaption: 'Test Float',
dataType: 'REAL',
value: 2.5,
min: 1.5,
max: null,
step: 1,
},
{
parameterType: 'LIST',
parameterName: 'Parameter 3',
parameterCaption: 'Test Int',
dataType: 'INTEGER',
value: 1,
members: [1, 2, 3],
},
{
parameterType: 'ANY_VALUE',
parameterName: 'Parameter 4',
parameterCaption: 'Test String',
dataType: 'STRING',
value: 'Hello World!',
},
],
},
},
empty: {
data: [],
},
nullData: {
data: null,
},
}));
const mockListFieldsResponses = vi.hoisted(() => ({
success: {
data: {
publishedDatasources: [
{
name: 'Test Datasource',
description: 'Test Description',
owner: {
name: 'Test Owner',
},
fields: [
{
name: 'Profit Ratio',
isHidden: false,
description: 'Calculated profit ratio field',
descriptionInherited: [
{
attribute: 'description',
value: 'Inherited profit description',
},
],
fullyQualifiedName: '[Profit Ratio]',
__typename: 'CalculatedField',
dataCategory: 'QUANTITATIVE',
role: 'MEASURE',
dataType: 'REAL',
defaultFormat: 'p2',
semanticRole: null,
aggregation: 'Sum',
aggregationParam: null,
formula: 'SUM([Sales] - [Cost])',
isAutoGenerated: false,
hasUserReference: true,
},
{
name: 'Product Name',
isHidden: false,
description: 'Name of the product',
descriptionInherited: [],
fullyQualifiedName: '[Product Name]',
__typename: 'ColumnField',
dataCategory: 'NOMINAL',
role: 'DIMENSION',
dataType: 'STRING',
defaultFormat: null,
semanticRole: null,
aggregation: null,
aggregationParam: null,
},
{
name: 'Quantity',
isHidden: false,
description: 'Quantity ordered',
descriptionInherited: [],
fullyQualifiedName: '[Quantity]',
__typename: 'ColumnField',
dataCategory: 'QUANTITATIVE',
role: 'MEASURE',
dataType: 'INTEGER',
defaultFormat: '#,##0',
semanticRole: null,
aggregation: 'Sum',
aggregationParam: null,
},
{
name: 'Binned Field',
isHidden: false,
description: 'A binned field',
descriptionInherited: [],
fullyQualifiedName: '[Binned Field]',
__typename: 'BinField',
dataCategory: 'ORDINAL',
role: 'DIMENSION',
dataType: 'INTEGER',
formula: 'BIN([Some Field])',
binSize: 10,
},
],
},
],
},
},
empty: {
data: {
publishedDatasources: [],
},
},
emptyFields: {
data: {
publishedDatasources: [
{
name: 'Test Datasource',
fields: [],
},
],
},
},
}));
const mocks = vi.hoisted(() => ({
mockReadMetadata: vi.fn(),
mockGraphql: vi.fn(),
mockGetConfig: vi.fn(),
}));
vi.mock('../../restApiInstance.js', () => ({
useRestApi: vi.fn().mockImplementation(async ({ callback }) =>
callback({
vizqlDataServiceMethods: {
readMetadata: mocks.mockReadMetadata,
},
metadataMethods: {
graphql: mocks.mockGraphql,
},
}),
),
}));
vi.mock('../../config.js', () => ({
getConfig: mocks.mockGetConfig,
}));
describe('getDatasourceMetadataTool', () => {
beforeEach(() => {
vi.clearAllMocks();
// Set default config for existing tests
resetResourceAccessCheckerSingleton();
mocks.mockGetConfig.mockReturnValue({
disableMetadataApiRequests: false,
boundedContext: {
projectIds: null,
datasourceIds: null,
workbookIds: null,
},
});
});
it('should create a tool instance with correct properties', () => {
const getDatasourceMetadataTool = getGetDatasourceMetadataTool(new Server());
expect(getDatasourceMetadataTool.name).toBe('get-datasource-metadata');
expect(getDatasourceMetadataTool.description).toEqual(expect.any(String));
expect(getDatasourceMetadataTool.paramsSchema).toMatchObject({
datasourceLuid: expect.any(Object),
});
expect(getDatasourceMetadataTool.annotations).toMatchObject({
title: 'Get Datasource Metadata',
readOnlyHint: true,
openWorldHint: false,
});
});
it('should successfully merge data from both APIs and return enriched metadata', async () => {
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData).toMatchObject({
fields: [
{
name: 'Profit Ratio',
dataType: 'REAL',
defaultAggregation: 'SUM',
description: 'Calculated profit ratio field',
descriptionInherited: [
{
attribute: 'description',
value: 'Inherited profit description',
},
],
dataCategory: 'QUANTITATIVE',
role: 'MEASURE',
defaultFormat: 'p2',
formula: 'SUM([Sales] - [Cost])',
isAutoGenerated: false,
hasUserReference: true,
},
{
name: 'Product Name',
dataType: 'STRING',
description: 'Name of the product',
dataCategory: 'NOMINAL',
role: 'DIMENSION',
},
{
name: 'Quantity',
dataType: 'INTEGER',
defaultAggregation: 'SUM',
description: 'Quantity ordered',
dataCategory: 'QUANTITATIVE',
role: 'MEASURE',
defaultFormat: '#,##0',
},
],
parameters: [
{
dataType: 'DATE',
maxDate: '2026-01-01',
minDate: '2024-01-01',
name: 'Test Date',
parameterType: 'QUANTITATIVE_DATE',
periodType: null,
periodValue: null,
value: '2025-10-17',
},
{
dataType: 'REAL',
min: 1.5,
max: null,
step: 1,
name: 'Test Float',
parameterType: 'QUANTITATIVE_RANGE',
value: 2.5,
},
{
dataType: 'INTEGER',
members: [1, 2, 3],
name: 'Test Int',
parameterType: 'LIST',
value: 1,
},
{
dataType: 'STRING',
name: 'Test String',
parameterType: 'ANY_VALUE',
value: 'Hello World!',
},
],
});
expect(mocks.mockReadMetadata).toHaveBeenCalledWith({
datasource: {
datasourceLuid: 'test-luid',
},
});
expect(mocks.mockGraphql).toHaveBeenCalledWith(expect.stringContaining('datasourceFieldInfo'));
});
it('should handle empty readMetadata response gracefully', async () => {
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.empty));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData).toEqual({
fields: [],
parameters: [],
});
});
it('should handle null readMetadata data gracefully', async () => {
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.nullData));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData).toEqual({
fields: [
{
dataCategory: 'QUANTITATIVE',
dataType: 'REAL',
defaultAggregation: 'Sum',
defaultFormat: 'p2',
description: 'Calculated profit ratio field',
descriptionInherited: [
{
attribute: 'description',
value: 'Inherited profit description',
},
],
formula: 'SUM([Sales] - [Cost])',
hasUserReference: true,
isAutoGenerated: false,
name: 'Profit Ratio',
role: 'MEASURE',
},
{
dataCategory: 'NOMINAL',
dataType: 'STRING',
description: 'Name of the product',
name: 'Product Name',
role: 'DIMENSION',
},
{
dataCategory: 'QUANTITATIVE',
dataType: 'INTEGER',
defaultAggregation: 'Sum',
defaultFormat: '#,##0',
description: 'Quantity ordered',
name: 'Quantity',
role: 'MEASURE',
},
{
binSize: 10,
dataCategory: 'ORDINAL',
dataType: 'INTEGER',
description: 'A binned field',
formula: 'BIN([Some Field])',
name: 'Binned Field',
role: 'DIMENSION',
},
],
parameters: [],
});
});
it('should handle empty listFields response and return basic metadata only', async () => {
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.empty);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
// Should have basic fields from readMetadata without enrichment
expect(responseData).toMatchObject({
fields: [
{
name: 'Profit Ratio',
dataType: 'REAL',
defaultAggregation: 'SUM',
columnClass: 'CALCULATION',
formula: 'SUM([Profit])/SUM([Sales])',
},
{
name: 'Product Name',
dataType: 'STRING',
},
{
name: 'Quantity',
dataType: 'INTEGER',
defaultAggregation: 'SUM',
},
],
parameters: [
{
dataType: 'DATE',
maxDate: '2026-01-01',
minDate: '2024-01-01',
name: 'Test Date',
parameterType: 'QUANTITATIVE_DATE',
periodType: null,
periodValue: null,
value: '2025-10-17',
},
{
dataType: 'REAL',
min: 1.5,
max: null,
step: 1,
name: 'Test Float',
parameterType: 'QUANTITATIVE_RANGE',
value: 2.5,
},
{
dataType: 'INTEGER',
members: [1, 2, 3],
name: 'Test Int',
parameterType: 'LIST',
value: 1,
},
{
dataType: 'STRING',
name: 'Test String',
parameterType: 'ANY_VALUE',
value: 'Hello World!',
},
],
});
// Ensure no enriched fields are present
expect(responseData.fields[0]).not.toHaveProperty('description');
expect(responseData.fields[0]).not.toHaveProperty('dataCategory');
});
it('should handle empty fields in listFields response', async () => {
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.emptyFields);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
// Should have basic fields from readMetadata without enrichment
expect(responseData.fields).toHaveLength(3);
expect(responseData.fields[0]).not.toHaveProperty('description');
});
it('should handle partial field matching between APIs', async () => {
// readMetadata has fields that aren't in listFields
const partialReadMetadata = {
data: [
{
fieldName: 'Existing Field',
fieldCaption: 'Existing Field',
dataType: 'STRING',
logicalTableId: '',
},
{
fieldName: 'Missing Field',
fieldCaption: 'Missing Field',
dataType: 'INTEGER',
logicalTableId: '',
},
],
};
const partialListFields = {
data: {
publishedDatasources: [
{
fields: [
{
name: 'Existing Field',
description: 'This field exists in both',
dataCategory: 'NOMINAL',
role: 'DIMENSION',
},
],
},
],
},
};
mocks.mockReadMetadata.mockResolvedValue(new Ok(partialReadMetadata));
mocks.mockGraphql.mockResolvedValue(partialListFields);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData.fields).toHaveLength(2);
// First field should be enriched
expect(responseData.fields[0]).toMatchObject({
name: 'Existing Field',
dataType: 'STRING',
description: 'This field exists in both',
dataCategory: 'NOMINAL',
role: 'DIMENSION',
});
// Second field should only have basic data
expect(responseData.fields[1]).toMatchObject({
name: 'Missing Field',
dataType: 'INTEGER',
});
expect(responseData.fields[1]).not.toHaveProperty('description');
});
it('should handle binSize property for BinField types', async () => {
const readMetadataWithBin = {
data: [
{
fieldName: 'Binned Field',
fieldCaption: 'Binned Field',
dataType: 'INTEGER',
logicalTableId: '',
},
],
};
mocks.mockReadMetadata.mockResolvedValue(new Ok(readMetadataWithBin));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData.fields[0]).toMatchObject({
name: 'Binned Field',
dataType: 'INTEGER',
binSize: 10,
});
});
it('should handle readMetadata API errors gracefully', async () => {
const errorMessage = 'ReadMetadata API Error';
mocks.mockReadMetadata.mockRejectedValue(new Error(errorMessage));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
'requestId: test-request-id, error: ReadMetadata API Error',
);
});
it('should handle listFields API errors gracefully', async () => {
const errorMessage = 'GraphQL API Error';
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success));
mocks.mockGraphql.mockRejectedValue(new Error(errorMessage));
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData).toMatchObject({
fields: [
{
name: 'Profit Ratio',
dataType: 'REAL',
defaultAggregation: 'SUM',
},
{
name: 'Product Name',
dataType: 'STRING',
},
{
name: 'Quantity',
dataType: 'INTEGER',
defaultAggregation: 'SUM',
},
],
});
});
it('should handle when both APIs fail', async () => {
const readMetadataError = 'ReadMetadata API Error';
const graphqlError = 'GraphQL API Error';
mocks.mockReadMetadata.mockRejectedValue(new Error(readMetadataError));
mocks.mockGraphql.mockRejectedValue(new Error(graphqlError));
const result = await getToolResult();
expect(result.isError).toBe(true);
// Should fail with the first error (readMetadata is called first)
expect(result.content[0].text).toBe(
'requestId: test-request-id, error: ReadMetadata API Error',
);
});
it('should return only readMetadata result when disableMetadataApiRequests is true and readMetadata succeeds', async () => {
// Configure to disable metadata API requests
mocks.mockGetConfig.mockReturnValue({
disableMetadataApiRequests: true,
boundedContext: {
projectIds: null,
datasourceIds: null,
workbookIds: null,
},
});
mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
// Should only have basic fields from readMetadata without enrichment
expect(responseData).toMatchObject({
fields: [
{
name: 'Profit Ratio',
dataType: 'REAL',
defaultAggregation: 'SUM',
},
{
name: 'Product Name',
dataType: 'STRING',
},
{
name: 'Quantity',
dataType: 'INTEGER',
defaultAggregation: 'SUM',
},
],
});
// Ensure no enriched fields are present
expect(responseData.fields[0]).not.toHaveProperty('description');
expect(responseData.fields[0]).not.toHaveProperty('dataCategory');
expect(responseData.fields[0]).not.toHaveProperty('role');
// Verify readMetadata was called but graphql was not
expect(mocks.mockReadMetadata).toHaveBeenCalledWith({
datasource: {
datasourceLuid: 'test-luid',
},
});
expect(mocks.mockGraphql).not.toHaveBeenCalled();
});
it('should return error when disableMetadataApiRequests is true and readMetadata fails', async () => {
// Configure to disable metadata API requests
mocks.mockGetConfig.mockReturnValue({
disableMetadataApiRequests: true,
boundedContext: {
projectIds: null,
datasourceIds: null,
workbookIds: null,
},
});
const errorMessage = 'ReadMetadata API Error';
mocks.mockReadMetadata.mockRejectedValue(new Error(errorMessage));
mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success);
const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
'requestId: test-request-id, error: ReadMetadata API Error',
);
// Verify readMetadata was called but graphql was not
expect(mocks.mockReadMetadata).toHaveBeenCalledWith({
datasource: {
datasourceLuid: 'test-luid',
},
});
expect(mocks.mockGraphql).not.toHaveBeenCalled();
});
it('should show feature-disabled error when VDS is disabled', async () => {
mocks.mockReadMetadata.mockResolvedValue(Err('feature-disabled'));
const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(getVizqlDataServiceDisabledError());
expect(mocks.mockGraphql).not.toHaveBeenCalled();
});
it('should return data source not allowed error when datasource is not allowed', async () => {
mocks.mockGetConfig.mockReturnValue({
boundedContext: {
projectIds: null,
datasourceIds: new Set(['some-other-datasource-luid']),
workbookIds: null,
},
});
const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
[
'The set of allowed data sources that can be queried is limited by the server configuration.',
'Querying the datasource with LUID test-luid is not allowed.',
].join(' '),
);
expect(mocks.mockReadMetadata).not.toHaveBeenCalled();
expect(mocks.mockGraphql).not.toHaveBeenCalled();
});
});
async function getToolResult(): Promise<CallToolResult> {
const getDatasourceMetadataTool = getGetDatasourceMetadataTool(new Server());
return await getDatasourceMetadataTool.callback(
{ datasourceLuid: 'test-luid' },
{
signal: new AbortController().signal,
requestId: 'test-request-id',
sendNotification: vi.fn(),
sendRequest: vi.fn(),
},
);
}