mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
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.
529 lines
22 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|