Files
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

623 lines
19 KiB
TypeScript

import { ApiClient } from '@/api/client'
import { MemoryCache } from '@/lib/cache/MemoryCache'
import { SessionManager } from '@/lib/SessionManager'
import { StateManager } from '@/lib/StateManager'
import type { InsightQuery } from '@/schema/query'
import { GENERATED_TOOL_MAP } from '@/tools/generated'
import { TOOL_MAP } from '@/tools/index'
import type { Context, ToolBase, ZodObjectAny } from '@/tools/types'
export const API_BASE_URL = process.env.TEST_INSIGHTS_API_BASE_URL || 'http://localhost:8010'
export const API_TOKEN = process.env.TEST_INSIGHTS_PERSONAL_API_KEY
export const TEST_ORG_ID = process.env.TEST_ORG_ID
export const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID
export interface CreatedResources {
featureFlags: number[]
insights: number[]
dashboards: number[]
surveys: string[]
actions: number[]
cohorts: number[]
}
export function validateEnvironmentVariables(): void {
if (!API_TOKEN) {
throw new Error('TEST_INSIGHTS_PERSONAL_API_KEY environment variable is required')
}
if (!TEST_ORG_ID) {
throw new Error('TEST_ORG_ID environment variable is required')
}
if (!TEST_PROJECT_ID) {
throw new Error('TEST_PROJECT_ID environment variable is required')
}
}
export function createTestClient(): ApiClient {
return new ApiClient({
apiToken: API_TOKEN!,
baseUrl: API_BASE_URL,
})
}
export function createTestContext(client: ApiClient): Context {
const cache = new MemoryCache<any>('test-user')
const stateManager = new StateManager(cache, client)
const context: Context = {
api: client,
cache,
env: {} as any,
stateManager,
sessionManager: new SessionManager(cache),
getDistinctId: async () => 'test-distinct-id',
trackEvent: async () => {},
}
return context
}
export async function setActiveProjectAndOrg(context: Context, projectId: string, orgId: string): Promise<void> {
const cache = context.cache
await cache.set('projectId', projectId)
await cache.set('orgId', orgId)
}
export async function cleanupResources(
client: ApiClient,
projectId: string,
resources: CreatedResources
): Promise<void> {
for (const flagId of resources.featureFlags) {
try {
await client.request({
method: 'PATCH',
path: `/v1/projects/${projectId}/feature_flags/${flagId}/`,
body: { deleted: true },
})
} catch (error) {
console.warn(`Failed to cleanup feature flag ${flagId}:`, error)
}
}
resources.featureFlags = []
for (const insightId of resources.insights) {
try {
await client.insights({ projectId }).delete({ insightId })
} catch (error) {
console.warn(`Failed to cleanup insight ${insightId}:`, error)
}
}
resources.insights = []
for (const dashboardId of resources.dashboards) {
try {
await client.request({
method: 'PATCH',
path: `/v1/projects/${projectId}/dashboards/${dashboardId}/`,
body: { deleted: true },
})
} catch (error) {
console.warn(`Failed to cleanup dashboard ${dashboardId}:`, error)
}
}
resources.dashboards = []
for (const surveyId of resources.surveys) {
try {
await client.request({
method: 'DELETE',
path: `/v1/projects/${projectId}/surveys/${surveyId}/`,
})
} catch (error) {
console.warn(`Failed to cleanup survey ${surveyId}:`, error)
}
}
resources.surveys = []
for (const actionId of resources.actions) {
try {
await client.request({
method: 'PATCH',
path: `/v1/projects/${projectId}/actions/${actionId}/`,
body: { deleted: true },
})
} catch (error) {
console.warn(`Failed to cleanup action ${actionId}:`, error)
}
}
resources.actions = []
for (const cohortId of resources.cohorts) {
try {
await client.request({
method: 'PATCH',
path: `/v1/projects/${projectId}/cohorts/${cohortId}/`,
body: { deleted: true },
})
} catch (error) {
console.warn(`Failed to cleanup cohort ${cohortId}:`, error)
}
}
resources.cohorts = []
}
export function parseToolResponse(result: any): any {
// Tool handlers now return plain JSON objects directly
// The MCP server wraps them with formatResponse, but tests call handlers directly
return result
}
/**
* Look up a tool by name. When called with a single argument, searches the
* combined hand-written and generated tool maps. When called with a tool map
* as the first argument, searches only that map.
*/
export function getToolByName(name: string): ToolBase<ZodObjectAny>
export function getToolByName(
toolMap: Record<string, () => ToolBase<ZodObjectAny>>,
name: string
): ToolBase<ZodObjectAny>
export function getToolByName(
nameOrMap: string | Record<string, () => ToolBase<ZodObjectAny>>,
maybeName?: string
): ToolBase<ZodObjectAny> {
let toolMap: Record<string, () => ToolBase<ZodObjectAny>>
let name: string
if (typeof nameOrMap === 'string') {
toolMap = { ...TOOL_MAP, ...GENERATED_TOOL_MAP }
name = nameOrMap
} else {
toolMap = nameOrMap
name = maybeName!
}
const factory = toolMap[name]
if (!factory) {
throw new Error(`Tool "${name}" not found`)
}
return factory()
}
export function generateUniqueKey(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
}
type SampleInsightsQLQuery = 'pageviews' | 'topEvents'
export const SAMPLE_INSIGHTSQL_QUERIES: Record<SampleInsightsQLQuery, InsightQuery> = {
pageviews: {
kind: 'DataVisualizationNode',
source: {
kind: 'InsightsQLQuery',
query: "SELECT event, count() AS event_count FROM events WHERE timestamp >= now() - INTERVAL 7 DAY AND event = '$pageview' GROUP BY event ORDER BY event_count DESC LIMIT 10",
filters: {
dateRange: {
date_from: '-7d',
date_to: '-1d',
},
},
},
},
topEvents: {
kind: 'DataVisualizationNode',
source: {
kind: 'InsightsQLQuery',
query: 'SELECT event, count() AS event_count FROM events WHERE timestamp >= now() - INTERVAL 7 DAY GROUP BY event ORDER BY event_count DESC LIMIT 10',
filters: {
dateRange: {
date_from: '-7d',
date_to: '-1d',
},
},
},
},
}
type SampleTrendQuery = 'basicPageviews' | 'uniqueUsers' | 'multipleEvents' | 'withBreakdown' | 'withPropertyFilter'
export const SAMPLE_TREND_QUERIES = {
basicPageviews: {
kind: 'InsightVizNode',
source: {
kind: 'TrendsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Page Views',
math: 'total',
},
],
dateRange: {
date_from: '-7d',
date_to: null,
},
interval: 'day',
properties: [],
filterTestAccounts: false,
},
},
uniqueUsers: {
kind: 'InsightVizNode',
source: {
kind: 'TrendsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Unique Users',
math: 'dau',
},
],
dateRange: {
date_from: '-30d',
date_to: null,
},
interval: 'day',
properties: [],
filterTestAccounts: true,
},
},
multipleEvents: {
kind: 'InsightVizNode',
source: {
kind: 'TrendsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Page Views',
math: 'total',
},
{
kind: 'EventsNode',
event: 'button_clicked',
custom_name: 'Button Clicks',
math: 'total',
},
],
dateRange: {
date_from: '-14d',
date_to: null,
},
interval: 'day',
properties: [],
filterTestAccounts: false,
},
},
withBreakdown: {
kind: 'InsightVizNode',
source: {
kind: 'TrendsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Page Views by Browser',
math: 'total',
},
],
breakdownFilter: {
breakdown_type: 'event',
breakdown: '$browser',
breakdown_limit: 10,
},
dateRange: {
date_from: '-7d',
date_to: null,
},
interval: 'day',
properties: [],
filterTestAccounts: false,
},
},
withPropertyFilter: {
kind: 'InsightVizNode',
source: {
kind: 'TrendsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Chrome/Safari Page Views',
math: 'total',
properties: [
{
key: '$browser',
value: ['Chrome', 'Safari'],
operator: 'exact',
type: 'event',
},
],
},
],
dateRange: {
date_from: '-7d',
date_to: null,
},
interval: 'day',
properties: [],
filterTestAccounts: false,
},
},
} as const satisfies Record<SampleTrendQuery, InsightQuery>
type SampleFunnelQuery =
| 'basicFunnel'
| 'strictOrderFunnel'
| 'funnelWithBreakdown'
| 'conversionWindow'
| 'onboardingFunnel'
export const SAMPLE_FUNNEL_QUERIES = {
basicFunnel: {
kind: 'InsightVizNode',
source: {
kind: 'FunnelsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Page View',
},
{
kind: 'EventsNode',
event: 'button_clicked',
custom_name: 'Button Clicked',
},
],
dateRange: {
date_from: '-7d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
strictOrderFunnel: {
kind: 'InsightVizNode',
source: {
kind: 'FunnelsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Landing Page View',
},
{
kind: 'EventsNode',
event: 'sign_up_started',
custom_name: 'Sign Up Started',
},
{
kind: 'EventsNode',
event: 'sign_up_completed',
custom_name: 'Sign Up Completed',
},
],
funnelsFilter: {
layout: 'vertical',
breakdownAttributionType: 'first_touch',
funnelOrderType: 'strict',
funnelVizType: 'steps',
funnelWindowInterval: 7,
funnelWindowIntervalUnit: 'day',
funnelStepReference: 'total',
},
dateRange: {
date_from: '-30d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
funnelWithBreakdown: {
kind: 'InsightVizNode',
source: {
kind: 'FunnelsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Product Page View',
},
{
kind: 'EventsNode',
event: 'purchase',
custom_name: 'Purchase',
},
],
breakdownFilter: {
breakdown_type: 'event',
breakdown: '$browser',
breakdown_limit: 5,
},
funnelsFilter: {
layout: 'vertical',
breakdownAttributionType: 'first_touch',
funnelOrderType: 'ordered',
funnelVizType: 'steps',
funnelWindowInterval: 14,
funnelWindowIntervalUnit: 'day',
funnelStepReference: 'total',
},
dateRange: {
date_from: '-14d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
conversionWindow: {
kind: 'InsightVizNode',
source: {
kind: 'FunnelsQuery',
series: [
{
kind: 'EventsNode',
event: '$pageview',
custom_name: 'Page View',
},
{
kind: 'EventsNode',
event: 'add_to_cart',
custom_name: 'Add to Cart',
},
{
kind: 'EventsNode',
event: 'purchase',
custom_name: 'Purchase',
},
],
funnelsFilter: {
layout: 'vertical',
breakdownAttributionType: 'first_touch',
funnelOrderType: 'ordered',
funnelVizType: 'steps',
funnelWindowInterval: 1,
funnelWindowIntervalUnit: 'hour',
funnelStepReference: 'total',
},
dateRange: {
date_from: '-7d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
onboardingFunnel: {
kind: 'InsightVizNode',
source: {
kind: 'FunnelsQuery',
series: [
{
kind: 'EventsNode',
event: 'Signed In',
custom_name: 'User Signs In',
},
{
kind: 'EventsNode',
event: '$pageview',
properties: [
{
key: '$pathname',
type: 'event',
value: 'get-started',
operator: 'icontains',
},
],
custom_name: 'Views Get-Started Page',
},
{
kind: 'EventsNode',
event: 'Integration Connected',
custom_name: 'Connects Integration',
},
{
kind: 'EventsNode',
event: 'Onboarding Completed',
custom_name: 'Completes Onboarding',
},
],
dateRange: {
date_to: 'today',
date_from: '-30d',
},
funnelsFilter: {
layout: 'vertical',
breakdownAttributionType: 'first_touch',
funnelOrderType: 'ordered',
funnelVizType: 'steps',
funnelWindowInterval: 24,
funnelWindowIntervalUnit: 'hour',
funnelStepReference: 'total',
},
properties: [],
filterTestAccounts: true,
},
},
} as const satisfies Record<SampleFunnelQuery, InsightQuery>
type SamplePathsQuery = 'basicPageviewPaths' | 'customEventPaths' | 'pathsWithStartPoint' | 'pathsWithInsightsQL'
export const SAMPLE_PATHS_QUERIES = {
basicPageviewPaths: {
kind: 'InsightVizNode',
source: {
kind: 'PathsQuery',
pathsFilter: {
includeEventTypes: ['$pageview'],
stepLimit: 5,
edgeLimit: 50,
},
dateRange: {
date_from: '-7d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
customEventPaths: {
kind: 'InsightVizNode',
source: {
kind: 'PathsQuery',
pathsFilter: {
includeEventTypes: ['custom_event'],
stepLimit: 3,
edgeLimit: 30,
excludeEvents: ['$feature_flag_called'],
},
dateRange: {
date_from: '-14d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
pathsWithStartPoint: {
kind: 'InsightVizNode',
source: {
kind: 'PathsQuery',
pathsFilter: {
includeEventTypes: ['$pageview'],
startPoint: '/',
stepLimit: 5,
edgeLimit: 50,
},
dateRange: {
date_from: '-7d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
pathsWithInsightsQL: {
kind: 'InsightVizNode',
source: {
kind: 'PathsQuery',
pathsFilter: {
includeEventTypes: ['insightsql'],
pathsInsightsQLExpression: 'event',
stepLimit: 5,
edgeLimit: 50,
},
dateRange: {
date_from: '-7d',
date_to: null,
},
properties: [],
filterTestAccounts: false,
},
},
} as const satisfies Record<SamplePathsQuery, InsightQuery>