Files
insights/services/mcp/tests/tools/dashboards.integration.test.ts
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

529 lines
22 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
type CreatedResources,
SAMPLE_INSIGHTSQL_QUERIES,
TEST_ORG_ID,
TEST_PROJECT_ID,
cleanupResources,
createTestClient,
createTestContext,
generateUniqueKey,
getToolByName,
parseToolResponse,
setActiveProjectAndOrg,
validateEnvironmentVariables,
} from '@/shared/test-utils'
import type { Context } from '@/tools/types'
describe('Dashboards', { concurrent: false }, () => {
let context: Context
const createdResources: CreatedResources = {
featureFlags: [],
insights: [],
dashboards: [],
surveys: [],
actions: [],
cohorts: [],
}
beforeAll(async () => {
validateEnvironmentVariables()
const client = createTestClient()
context = createTestContext(client)
await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!)
})
afterEach(async () => {
await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources)
})
describe('dashboard-create tool', () => {
const createTool = getToolByName('dashboard-create')
it('should create a dashboard with minimal fields', async () => {
const params = {
name: generateUniqueKey('Test Dashboard'),
description: 'Integration test dashboard',
pinned: false,
}
const result = await createTool.handler(context, params)
const dashboardData = parseToolResponse(result)
expect(dashboardData.id).toBeTruthy()
expect(dashboardData.name).toBe(params.name)
expect(dashboardData._insightsUrl).toContain('/dashboard/')
createdResources.dashboards.push(dashboardData.id)
})
it('should create a dashboard with tags', async () => {
const params = {
name: generateUniqueKey('Tagged Dashboard'),
description: 'Dashboard with tags',
tags: ['test', 'integration'],
pinned: false,
}
const result = await createTool.handler(context, params)
const dashboardData = parseToolResponse(result)
expect(dashboardData.id).toBeTruthy()
expect(dashboardData.name).toBe(params.name)
createdResources.dashboards.push(dashboardData.id)
})
})
describe('dashboard-update tool', () => {
const createTool = getToolByName('dashboard-create')
const updateTool = getToolByName('dashboard-update')
it('should update dashboard name and description', async () => {
const createResult = await createTool.handler(context, {
name: generateUniqueKey('Original Dashboard'),
description: 'Original description',
pinned: false,
})
const createdDashboard = parseToolResponse(createResult)
createdResources.dashboards.push(createdDashboard.id)
const updateResult = await updateTool.handler(context, {
id: createdDashboard.id,
name: 'Updated Dashboard Name',
description: 'Updated description',
})
const updatedDashboard = parseToolResponse(updateResult)
expect(updatedDashboard.id).toBe(createdDashboard.id)
expect(updatedDashboard.name).toBe('Updated Dashboard Name')
})
})
describe('dashboards-get-all tool', () => {
const getAllTool = getToolByName('dashboards-get-all')
it('should return dashboards with proper structure', async () => {
const result = await getAllTool.handler(context, {})
const response = parseToolResponse(result)
expect(response.results).toBeTruthy()
expect(Array.isArray(response.results)).toBe(true)
if (response.results.length > 0) {
const dashboard = response.results[0]
expect(dashboard).toHaveProperty('id')
expect(dashboard).toHaveProperty('name')
}
})
})
describe('dashboard-get tool', () => {
const createTool = getToolByName('dashboard-create')
const getOneTool = getToolByName('dashboard-get')
it('should get a specific dashboard by ID', async () => {
const createResult = await createTool.handler(context, {
name: generateUniqueKey('Get Test Dashboard'),
description: 'Test dashboard for get operation',
pinned: false,
})
const createdDashboard = parseToolResponse(createResult)
createdResources.dashboards.push(createdDashboard.id)
const result = await getOneTool.handler(context, { id: createdDashboard.id })
const retrievedDashboard = parseToolResponse(result)
expect(retrievedDashboard.id).toBe(createdDashboard.id)
expect(retrievedDashboard.name).toContain('Get Test Dashboard')
})
})
describe('dashboard-delete tool', () => {
const createTool = getToolByName('dashboard-create')
const deleteTool = getToolByName('dashboard-delete')
it('should delete a dashboard', async () => {
const createResult = await createTool.handler(context, {
name: generateUniqueKey('Delete Test Dashboard'),
description: 'Test dashboard for deletion',
pinned: false,
})
const createdDashboard = parseToolResponse(createResult)
const deleteResult = await deleteTool.handler(context, {
id: createdDashboard.id,
})
const deleteResponse = parseToolResponse(deleteResult)
expect(deleteResponse.deleted).toBe(true)
})
})
describe('Dashboard workflow', () => {
it('should support full CRUD workflow', async () => {
const createTool = getToolByName('dashboard-create')
const updateTool = getToolByName('dashboard-update')
const getOneTool = getToolByName('dashboard-get')
const deleteTool = getToolByName('dashboard-delete')
const createResult = await createTool.handler(context, {
name: generateUniqueKey('Workflow Test Dashboard'),
description: 'Testing full workflow',
pinned: false,
})
const createdDashboard = parseToolResponse(createResult)
const getResult = await getOneTool.handler(context, { id: createdDashboard.id })
const retrievedDashboard = parseToolResponse(getResult)
expect(retrievedDashboard.id).toBe(createdDashboard.id)
const updateResult = await updateTool.handler(context, {
id: createdDashboard.id,
name: 'Updated Workflow Dashboard',
description: 'Updated workflow description',
})
const updatedDashboard = parseToolResponse(updateResult)
expect(updatedDashboard.name).toBe('Updated Workflow Dashboard')
const deleteResult = await deleteTool.handler(context, {
id: createdDashboard.id,
})
const deleteResponse = parseToolResponse(deleteResult)
expect(deleteResponse.deleted).toBe(true)
})
})
describe('response excludes insight results', () => {
const createTool = getToolByName('dashboard-create')
const getOneTool = getToolByName('dashboard-get')
const updateTool = getToolByName('dashboard-update')
const STRIPPED_INSIGHT_FIELDS = [
'result',
'hasMore',
'columns',
'insightsql',
'types',
'query_status',
'cache_target_age',
'next_allowed_client_refresh',
'filters_hash',
'dashboards',
'dashboard_tiles',
'effective_restriction_level',
'effective_privilege_level',
'user_access_level',
'filters',
'is_sample',
'order',
'deleted',
'alerts',
'timezone',
'resolved_date_range',
] as const
async function createDashboardWithInsight(namePrefix: string): Promise<{ dashboardId: number }> {
const dashboardResult = await createTool.handler(context, {
name: generateUniqueKey(namePrefix),
pinned: false,
})
const dashboard = parseToolResponse(dashboardResult)
createdResources.dashboards.push(dashboard.id)
const projectId = await context.stateManager.getProjectId()
const insight = await context.api.request<any>({
method: 'POST',
path: `/v1/projects/${projectId}/insights/`,
body: {
name: generateUniqueKey('Stripped Result Insight'),
query: SAMPLE_INSIGHTSQL_QUERIES.pageviews,
saved: true,
dashboards: [dashboard.id],
},
})
createdResources.insights.push(insight.id)
return { dashboardId: dashboard.id }
}
function assertNoInsightResults(dashboard: any): void {
expect(dashboard.tiles).toBeTruthy()
expect(dashboard.tiles.length).toBeGreaterThan(0)
const insightTiles = dashboard.tiles.filter((t: any) => t?.insight)
expect(insightTiles.length).toBeGreaterThan(0)
for (const tile of insightTiles) {
for (const field of STRIPPED_INSIGHT_FIELDS) {
expect(
tile.insight[field],
`expected tile.insight.${field} to be stripped but was present`
).toBeUndefined()
}
// Sanity: identifying metadata should still be present
expect(tile.insight.id).toBeTruthy()
expect(tile.insight.query).toBeTruthy()
}
}
it('dashboard-get strips insight result fields', async () => {
const { dashboardId } = await createDashboardWithInsight('Get Strips Results')
const result = await getOneTool.handler(context, { id: dashboardId })
const dashboard = parseToolResponse(result)
assertNoInsightResults(dashboard)
})
it('dashboard-update strips insight result fields', async () => {
const { dashboardId } = await createDashboardWithInsight('Update Strips Results')
const result = await updateTool.handler(context, {
id: dashboardId,
description: 'Updated to verify response stripping',
})
const dashboard = parseToolResponse(result)
assertNoInsightResults(dashboard)
})
it('dashboard-create response has no tiles (and therefore no insight results)', async () => {
// dashboard-create creates an empty dashboard — there are no tiles to strip from.
// The exclude list is still declared so the tool stays safe if tiles are ever
// returned on create (e.g. duplicated or template-based creation).
const result = await createTool.handler(context, {
name: generateUniqueKey('Create No Tiles'),
pinned: false,
})
const dashboard = parseToolResponse(result)
createdResources.dashboards.push(dashboard.id)
expect(Array.isArray(dashboard.tiles)).toBe(true)
expect(dashboard.tiles.length).toBe(0)
})
})
describe('dashboard-insights-run tool', () => {
const createTool = getToolByName('dashboard-create')
const runInsightsTool = getToolByName('dashboard-insights-run')
it('returns insight results for tiles (unlike dashboard-get)', async () => {
const dashboardResult = await createTool.handler(context, {
name: generateUniqueKey('Run Insights Dashboard'),
pinned: false,
})
const dashboard = parseToolResponse(dashboardResult)
createdResources.dashboards.push(dashboard.id)
const projectId = await context.stateManager.getProjectId()
const insight = await context.api.request<any>({
method: 'POST',
path: `/v1/projects/${projectId}/insights/`,
body: {
name: generateUniqueKey('Run Insights Insight'),
query: SAMPLE_INSIGHTSQL_QUERIES.pageviews,
saved: true,
dashboards: [dashboard.id],
},
})
createdResources.insights.push(insight.id)
const result = await runInsightsTool.handler(context, {
id: dashboard.id,
refresh: 'blocking',
})
const response = parseToolResponse(result)
expect(Array.isArray(response.results)).toBe(true)
expect(response.results.length).toBeGreaterThan(0)
const tile = response.results[0]
expect(tile.insight).toBeTruthy()
expect(tile.insight.id).toBeTruthy()
// The distinguishing property of this endpoint vs dashboard-get: result is present
expect(tile.insight).toHaveProperty('result')
})
})
describe('dashboard-reorder-tiles tool', () => {
const createDashboard = getToolByName('dashboard-create')
const reorderTiles = getToolByName('dashboard-reorder-tiles')
const getOneDashboard = getToolByName('dashboard-get')
const deleteDashboard = getToolByName('dashboard-delete')
it('should reorder tiles on a dashboard', async () => {
// Create two dashboards — the second is used to verify full-replacement semantics
const dashboardResult = await createDashboard.handler(context, {
name: generateUniqueKey('Reorder Test Dashboard'),
description: 'Dashboard for testing tile reordering',
pinned: false,
})
const dashboard = parseToolResponse(dashboardResult)
createdResources.dashboards.push(dashboard.id)
const dashboard2Result = await createDashboard.handler(context, {
name: generateUniqueKey('Secondary Dashboard'),
pinned: false,
})
const dashboard2 = parseToolResponse(dashboard2Result)
createdResources.dashboards.push(dashboard2.id)
// Create two insights via API
const projectId = await context.stateManager.getProjectId()
const insight1 = await context.api.request<any>({
method: 'POST',
path: `/v1/projects/${projectId}/insights/`,
body: {
name: generateUniqueKey('Insight 1'),
description: 'First insight',
query: SAMPLE_INSIGHTSQL_QUERIES.pageviews,
saved: true,
},
})
createdResources.insights.push(insight1.id)
const insight2 = await context.api.request<any>({
method: 'POST',
path: `/v1/projects/${projectId}/insights/`,
body: {
name: generateUniqueKey('Insight 2'),
description: 'Second insight',
query: SAMPLE_INSIGHTSQL_QUERIES.topEvents,
saved: true,
},
})
createdResources.insights.push(insight2.id)
// Add insight1 to the primary dashboard
await context.api.request({
method: 'PATCH',
path: `/v1/projects/${projectId}/insights/${insight1.id}/`,
body: { dashboards: [dashboard.id] },
})
// Add insight2 to the secondary dashboard first, then append the primary
// dashboard — this verifies full-replacement semantics (must include all IDs)
await context.api.request({
method: 'PATCH',
path: `/v1/projects/${projectId}/insights/${insight2.id}/`,
body: { dashboards: [dashboard2.id] },
})
const updatedInsight2 = await context.api.request<any>({
method: 'PATCH',
path: `/v1/projects/${projectId}/insights/${insight2.id}/`,
body: { dashboards: [dashboard2.id, dashboard.id] },
})
expect(updatedInsight2.dashboards).toContain(dashboard.id)
expect(updatedInsight2.dashboards).toContain(dashboard2.id)
// Get the dashboard to see the tile IDs
const dashboardWithTilesResult = await getOneDashboard.handler(context, {
id: dashboard.id,
})
const dashboardWithTiles = parseToolResponse(dashboardWithTilesResult)
expect(dashboardWithTiles.tiles).toBeTruthy()
expect(dashboardWithTiles.tiles.length).toBeGreaterThanOrEqual(2)
// Get the tile IDs (filter out null tiles)
const tileIds = dashboardWithTiles.tiles
.filter((tile: any) => tile !== null && tile.id !== undefined)
.map((tile: any) => tile.id)
// Reorder tiles (reverse the order)
const reversedTileOrder = [...tileIds].reverse()
const reorderResult = await reorderTiles.handler(context, {
id: dashboard.id,
tile_order: reversedTileOrder,
})
const reorderResponse = parseToolResponse(reorderResult)
expect(reorderResponse.id).toBe(dashboard.id)
expect(reorderResponse._insightsUrl).toContain('/dashboard/')
// Verify insight2 is still on dashboard2 after reordering dashboard1
const dashboard2WithTiles = parseToolResponse(await getOneDashboard.handler(context, { id: dashboard2.id }))
const dashboard2InsightIds = dashboard2WithTiles.tiles
.filter((tile: any) => tile?.insight)
.map((tile: any) => tile.insight.id)
expect(dashboard2InsightIds).toContain(insight2.id)
// Clean up
await deleteDashboard.handler(context, { id: dashboard.id })
await deleteDashboard.handler(context, { id: dashboard2.id })
createdResources.dashboards = createdResources.dashboards.filter(
(id) => id !== dashboard.id && id !== dashboard2.id
)
})
})
describe('dashboard widget tools', () => {
const catalogTool = getToolByName('dashboard-widget-catalog-list')
const batchAddWidgetTool = getToolByName('dashboard-widgets-batch-add')
const runWidgetsTool = getToolByName('dashboard-widgets-run')
const createTool = getToolByName('dashboard-create')
const getOneTool = getToolByName('dashboard-get')
it('dashboard-widget-catalog-list returns widget catalog entries', async () => {
const result = await catalogTool.handler(context, {})
const catalog = parseToolResponse(result)
expect(Array.isArray(catalog.results)).toBe(true)
const errorTracking = catalog.results.find(
(entry: { widget_type: string }) => entry.widget_type === 'error_tracking_list'
)
expect(errorTracking).toBeTruthy()
expect(errorTracking.config_schema?.properties?.limit?.default).toBe(10)
const sessionReplay = catalog.results.find(
(entry: { widget_type: string }) => entry.widget_type === 'session_replay_list'
)
expect(sessionReplay).toBeTruthy()
expect(sessionReplay.config_schema?.properties?.orderBy?.default).toBe('start_time')
})
it('dashboard-widgets-batch-add and dashboard-widgets-run workflow', async () => {
const dashboardResult = await createTool.handler(context, {
name: generateUniqueKey('Widget MCP Dashboard'),
pinned: false,
})
const dashboard = parseToolResponse(dashboardResult)
createdResources.dashboards.push(dashboard.id)
const addResult = await batchAddWidgetTool.handler(context, {
id: dashboard.id,
widgets: [
{
widget_type: 'error_tracking_list',
config: { limit: 5 },
},
],
})
const batchResponse = parseToolResponse(addResult)
expect(Array.isArray(batchResponse.tiles)).toBe(true)
const tile = batchResponse.tiles[0]
expect(tile.id).toBeTruthy()
expect(tile.widget?.widget_type).toBe('error_tracking_list')
expect(batchResponse._insightsUrl).toContain(`/dashboard/${dashboard.id}`)
const dashboardWithTile = parseToolResponse(await getOneTool.handler(context, { id: dashboard.id }))
const widgetTile = dashboardWithTile.tiles?.find((t: { widget?: unknown }) => !!t.widget)
expect(widgetTile?.id).toBe(tile.id)
const runResult = await runWidgetsTool.handler(context, {
id: dashboard.id,
tile_ids: String(tile.id),
})
const runResponse = parseToolResponse(runResult)
expect(runResponse._insightsUrl).toContain(`/dashboard/${dashboard.id}`)
expect(Array.isArray(runResponse.results)).toBe(true)
expect(runResponse.results.length).toBe(1)
expect(runResponse.results[0].tile_id).toBe(tile.id)
expect(runResponse.results[0].widget_type).toBe('error_tracking_list')
})
})
})