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.
434 lines
17 KiB
TypeScript
434 lines
17 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
SAMPLE_TREND_QUERIES,
|
|
TEST_ORG_ID,
|
|
TEST_PROJECT_ID,
|
|
createTestClient,
|
|
createTestContext,
|
|
generateUniqueKey,
|
|
parseToolResponse,
|
|
setActiveProjectAndOrg,
|
|
validateEnvironmentVariables,
|
|
} from '@/shared/test-utils'
|
|
import { GENERATED_TOOLS } from '@/tools/generated/alerts'
|
|
import type { Context } from '@/tools/types'
|
|
|
|
describe('Alerts', { concurrent: false }, () => {
|
|
let context: Context
|
|
let insightId: number
|
|
let currentUserId: number
|
|
let currentUserUuid: string
|
|
const createdAlertIds: string[] = []
|
|
|
|
const listTool = GENERATED_TOOLS['alerts-list']!()
|
|
const getTool = GENERATED_TOOLS['alert-get']!()
|
|
const createTool = GENERATED_TOOLS['alert-create']!()
|
|
const updateTool = GENERATED_TOOLS['alert-update']!()
|
|
const deleteTool = GENERATED_TOOLS['alert-delete']!()
|
|
|
|
beforeAll(async () => {
|
|
validateEnvironmentVariables()
|
|
const client = createTestClient()
|
|
context = createTestContext(client)
|
|
await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!)
|
|
|
|
// Create a trends insight as a prerequisite — alerts require an insight to monitor
|
|
const insight = await context.api.request<{ id: number }>({
|
|
method: 'POST',
|
|
path: `/v1/projects/${TEST_PROJECT_ID}/insights/`,
|
|
body: {
|
|
name: generateUniqueKey('alert-test-insight'),
|
|
query: SAMPLE_TREND_QUERIES.basicPageviews,
|
|
},
|
|
})
|
|
insightId = insight.id
|
|
|
|
// Fetch the current user ID — required for subscribed_users
|
|
const user = await context.api.request<{ id: number; uuid: string }>({
|
|
method: 'GET',
|
|
path: '/v1/users/@me/',
|
|
})
|
|
currentUserId = user.id
|
|
currentUserUuid = user.uuid
|
|
})
|
|
|
|
afterEach(async () => {
|
|
for (const id of createdAlertIds) {
|
|
try {
|
|
await deleteTool.handler(context, { id })
|
|
} catch {
|
|
// best effort — alert may already be deleted
|
|
}
|
|
}
|
|
createdAlertIds.length = 0
|
|
})
|
|
|
|
function makeAlertParams(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
name: `test-alert-${generateUniqueKey('alert')}`,
|
|
insight: insightId,
|
|
subscribed_users: [currentUserId],
|
|
threshold: {
|
|
configuration: {
|
|
type: 'absolute' as const,
|
|
bounds: { upper: 100 },
|
|
},
|
|
},
|
|
condition: {
|
|
type: 'absolute_value' as const,
|
|
},
|
|
config: {
|
|
type: 'TrendsAlertConfig' as const,
|
|
series_index: 0,
|
|
},
|
|
enabled: false,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('alerts-list tool', () => {
|
|
it('should return paginated structure', async () => {
|
|
const result = await listTool.handler(context, {})
|
|
const data = parseToolResponse(result)
|
|
|
|
expect(typeof data.count).toBe('number')
|
|
expect(Array.isArray(data.results)).toBe(true)
|
|
expect(typeof data._insightsUrl).toBe('string')
|
|
expect(data._insightsUrl).toContain('/alerts')
|
|
})
|
|
|
|
it('should return a per-alert _insightsUrl on each result', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const createdAlert = parseToolResponse(created)
|
|
createdAlertIds.push(createdAlert.id)
|
|
|
|
const result = await listTool.handler(context, { search: createdAlert.name })
|
|
const data = parseToolResponse(result)
|
|
|
|
const found = data.results.find((a: { id: string }) => a.id === createdAlert.id)
|
|
expect(found).toBeTruthy()
|
|
expect(found._insightsUrl).toContain(`/alerts?alert_type=insights&alert_id=${createdAlert.id}`)
|
|
})
|
|
|
|
it('should respect the limit parameter', async () => {
|
|
const result = await listTool.handler(context, { limit: 1 })
|
|
const data = parseToolResponse(result)
|
|
|
|
expect(Array.isArray(data.results)).toBe(true)
|
|
expect(data.results.length).toBeLessThanOrEqual(1)
|
|
})
|
|
|
|
it('should filter by search', async () => {
|
|
const name = `mcp-search-alert-${generateUniqueKey('search')}`
|
|
const created = await createTool.handler(context, makeAlertParams({ name }))
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
const result = await listTool.handler(context, { search: 'mcp-search-alert' })
|
|
const data = parseToolResponse(result)
|
|
|
|
const found = data.results.find((a: { id: string }) => a.id === alert.id)
|
|
expect(found).toBeTruthy()
|
|
expect(found.name).toBe(name)
|
|
})
|
|
|
|
it('should filter by created_by uuid', async () => {
|
|
const name = `mcp-created-by-alert-${generateUniqueKey('creator')}`
|
|
const created = await createTool.handler(context, makeAlertParams({ name }))
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
const result = await listTool.handler(context, { created_by: currentUserUuid })
|
|
const data = parseToolResponse(result)
|
|
|
|
const found = data.results.find((a: { id: string }) => a.id === alert.id)
|
|
expect(found).toBeTruthy()
|
|
expect(found.created_by?.uuid).toBe(currentUserUuid)
|
|
})
|
|
})
|
|
|
|
describe('alert-create tool', () => {
|
|
it('should create an alert with absolute threshold', async () => {
|
|
const params = makeAlertParams()
|
|
const result = await createTool.handler(context, params)
|
|
const alert = parseToolResponse(result)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
expect(alert.id).toBeTruthy()
|
|
expect(alert.name).toBe(params.name)
|
|
expect(alert.enabled).toBe(false)
|
|
expect(alert.threshold.configuration.type).toBe('absolute')
|
|
expect(alert.threshold.configuration.bounds.upper).toBe(100)
|
|
})
|
|
|
|
it('should create an alert with relative_increase condition', async () => {
|
|
const params = makeAlertParams({
|
|
condition: { type: 'relative_increase' },
|
|
threshold: {
|
|
configuration: {
|
|
type: 'percentage',
|
|
bounds: { upper: 50 },
|
|
},
|
|
},
|
|
})
|
|
const result = await createTool.handler(context, params)
|
|
const alert = parseToolResponse(result)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
expect(alert.id).toBeTruthy()
|
|
expect(alert.condition.type).toBe('relative_increase')
|
|
expect(alert.threshold.configuration.type).toBe('percentage')
|
|
})
|
|
|
|
it('should create an alert with calculation_interval', async () => {
|
|
const params = makeAlertParams({ calculation_interval: 'weekly' })
|
|
const result = await createTool.handler(context, params)
|
|
const alert = parseToolResponse(result)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
expect(alert.calculation_interval).toBe('weekly')
|
|
})
|
|
})
|
|
|
|
describe('alert-get tool', () => {
|
|
it('should retrieve a specific alert by ID', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const createdAlert = parseToolResponse(created)
|
|
createdAlertIds.push(createdAlert.id)
|
|
|
|
const result = await getTool.handler(context, { id: createdAlert.id })
|
|
const alert = parseToolResponse(result)
|
|
|
|
expect(alert.id).toBe(createdAlert.id)
|
|
expect(alert.name).toBe(createdAlert.name)
|
|
expect(alert.threshold).toBeTruthy()
|
|
expect(alert._insightsUrl).toContain(`/alerts?alert_type=insights&alert_id=${createdAlert.id}`)
|
|
})
|
|
|
|
it('should throw for a non-existent UUID', async () => {
|
|
const absentId = crypto.randomUUID()
|
|
await expect(getTool.handler(context, { id: absentId })).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('alert-update tool', () => {
|
|
it('should update the name of an alert', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
const newName = `renamed-${generateUniqueKey('alert')}`
|
|
const result = await updateTool.handler(context, { id: alert.id, name: newName })
|
|
const updated = parseToolResponse(result)
|
|
|
|
expect(updated.name).toBe(newName)
|
|
expect(updated.id).toBe(alert.id)
|
|
})
|
|
|
|
it('should enable and disable an alert', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams({ enabled: false }))
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
const enableResult = await updateTool.handler(context, { id: alert.id, enabled: true })
|
|
const enabled = parseToolResponse(enableResult)
|
|
expect(enabled.enabled).toBe(true)
|
|
|
|
const disableResult = await updateTool.handler(context, { id: alert.id, enabled: false })
|
|
const disabled = parseToolResponse(disableResult)
|
|
expect(disabled.enabled).toBe(false)
|
|
})
|
|
|
|
it('should update threshold bounds', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
const result = await updateTool.handler(context, {
|
|
id: alert.id,
|
|
threshold: {
|
|
configuration: {
|
|
type: 'absolute' as const,
|
|
bounds: { lower: 10, upper: 200 },
|
|
},
|
|
},
|
|
})
|
|
const updated = parseToolResponse(result)
|
|
|
|
expect(updated.threshold.configuration.bounds.lower).toBe(10)
|
|
expect(updated.threshold.configuration.bounds.upper).toBe(200)
|
|
})
|
|
|
|
it('should update calculation_interval', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
const result = await updateTool.handler(context, { id: alert.id, calculation_interval: 'hourly' })
|
|
const updated = parseToolResponse(result)
|
|
|
|
expect(updated.calculation_interval).toBe('hourly')
|
|
})
|
|
})
|
|
|
|
describe('alert-delete tool', () => {
|
|
it('should delete an alert', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const alert = parseToolResponse(created)
|
|
|
|
await deleteTool.handler(context, { id: alert.id })
|
|
await expect(getTool.handler(context, { id: alert.id })).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('anomaly detection alerts', () => {
|
|
const simulateTool = GENERATED_TOOLS['alert-simulate']!()
|
|
|
|
it('should create an alert with detector_config', async () => {
|
|
const params = makeAlertParams({
|
|
detector_config: {
|
|
type: 'zscore',
|
|
threshold: 0.9,
|
|
window: 30,
|
|
},
|
|
})
|
|
const result = await createTool.handler(context, params)
|
|
const alert = parseToolResponse(result)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
expect(alert.id).toBeTruthy()
|
|
expect(alert.detector_config).toBeTruthy()
|
|
expect(alert.detector_config.type).toBe('zscore')
|
|
expect(alert.detector_config.threshold).toBe(0.9)
|
|
expect(alert.detector_config.window).toBe(30)
|
|
})
|
|
|
|
it('should create an ensemble alert with multiple detectors', async () => {
|
|
const params = makeAlertParams({
|
|
detector_config: {
|
|
type: 'ensemble',
|
|
operator: 'or',
|
|
detectors: [
|
|
{ type: 'zscore', threshold: 0.9, window: 30 },
|
|
{ type: 'mad', threshold: 0.9, window: 30 },
|
|
],
|
|
},
|
|
})
|
|
const result = await createTool.handler(context, params)
|
|
const alert = parseToolResponse(result)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
expect(alert.detector_config.type).toBe('ensemble')
|
|
expect(alert.detector_config.operator).toBe('or')
|
|
expect(alert.detector_config.detectors).toHaveLength(2)
|
|
})
|
|
|
|
it('should update an alert to add detector_config', async () => {
|
|
const created = await createTool.handler(context, makeAlertParams())
|
|
const alert = parseToolResponse(created)
|
|
createdAlertIds.push(alert.id)
|
|
|
|
expect(alert.detector_config).toBeNull()
|
|
|
|
const result = await updateTool.handler(context, {
|
|
id: alert.id,
|
|
detector_config: {
|
|
type: 'mad',
|
|
threshold: 0.95,
|
|
window: 60,
|
|
},
|
|
})
|
|
const updated = parseToolResponse(result)
|
|
|
|
expect(updated.detector_config).toBeTruthy()
|
|
expect(updated.detector_config.type).toBe('mad')
|
|
})
|
|
|
|
it('should simulate a detector on an insight', async () => {
|
|
const result = await simulateTool.handler(context, {
|
|
insight: insightId,
|
|
detector_config: {
|
|
type: 'zscore',
|
|
threshold: 0.9,
|
|
window: 30,
|
|
},
|
|
series_index: 0,
|
|
})
|
|
const data = parseToolResponse(result)
|
|
|
|
expect(Array.isArray(data.data)).toBe(true)
|
|
expect(Array.isArray(data.dates)).toBe(true)
|
|
expect(Array.isArray(data.scores)).toBe(true)
|
|
expect(Array.isArray(data.triggered_indices)).toBe(true)
|
|
expect(Array.isArray(data.triggered_dates)).toBe(true)
|
|
expect(typeof data.total_points).toBe('number')
|
|
expect(typeof data.anomaly_count).toBe('number')
|
|
expect(data.total_points).toBeGreaterThan(0)
|
|
expect(data.scores.length).toBe(data.total_points)
|
|
})
|
|
|
|
it('should simulate with a custom date range', async () => {
|
|
const result = await simulateTool.handler(context, {
|
|
insight: insightId,
|
|
detector_config: {
|
|
type: 'zscore',
|
|
threshold: 0.9,
|
|
window: 30,
|
|
},
|
|
date_from: '-7d',
|
|
})
|
|
const data = parseToolResponse(result)
|
|
|
|
expect(data.total_points).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('Alerts workflow', () => {
|
|
it('should support a full create → retrieve → update → delete lifecycle', async () => {
|
|
const name = `workflow-alert-${generateUniqueKey('lifecycle')}`
|
|
|
|
// Create
|
|
const createResult = await createTool.handler(context, makeAlertParams({ name }))
|
|
const created = parseToolResponse(createResult)
|
|
expect(created.id).toBeTruthy()
|
|
expect(created.name).toBe(name)
|
|
|
|
// Retrieve
|
|
const getResult = await getTool.handler(context, { id: created.id })
|
|
const retrieved = parseToolResponse(getResult)
|
|
expect(retrieved.id).toBe(created.id)
|
|
|
|
// Update
|
|
const updatedName = `${name}-updated`
|
|
const updateResult = await updateTool.handler(context, {
|
|
id: created.id,
|
|
name: updatedName,
|
|
enabled: true,
|
|
})
|
|
const updated = parseToolResponse(updateResult)
|
|
expect(updated.name).toBe(updatedName)
|
|
expect(updated.enabled).toBe(true)
|
|
|
|
// Delete
|
|
await deleteTool.handler(context, { id: created.id })
|
|
await expect(getTool.handler(context, { id: created.id })).rejects.toThrow()
|
|
})
|
|
|
|
it('should appear in list results after creation', async () => {
|
|
const name = `list-check-alert-${generateUniqueKey('appear')}`
|
|
|
|
const createResult = await createTool.handler(context, makeAlertParams({ name }))
|
|
const created = parseToolResponse(createResult)
|
|
createdAlertIds.push(created.id)
|
|
|
|
const listResult = await listTool.handler(context, {})
|
|
const data = parseToolResponse(listResult)
|
|
|
|
const found = data.results.find((a: any) => a.id === created.id)
|
|
expect(found).toBeTruthy()
|
|
expect(found.name).toBe(name)
|
|
})
|
|
})
|
|
})
|