Files
zeekay 1cfa22a698 merge: upstream/main (sync, brand preserved)
# Conflicts:
#	.github/workflows/check-insights-major-version.yml
#	.github/workflows/check-posthog-major-version.yml
#	.github/workflows/ci-security.yaml
#	.github/workflows/validate-versioning.yml
#	AGENTS.md
#	AGENTS.md~upstream_main
#	packages/ai/src/otel/capture.ts
#	packages/ai/src/otel/mappers/aiSdk.ts
#	packages/ai/src/otel/mappers/index.ts
#	packages/ai/src/otel/types.ts
#	packages/core/src/__tests__/posthog.init.spec.ts
#	packages/react-native/src/PostHogProvider.tsx
#	packages/react/src/context/PostHogContext.ts
#	packages/react/src/context/PostHogProvider.tsx
#	tooling/eslint-plugin-insights/no-unsafe-web-global.js
2026-06-02 10:52:56 -07:00

1429 lines
48 KiB
TypeScript

/// <reference types="vite/client" />
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals'
import { initConvexTest } from './setup.test.js'
import { api, components } from './_generated/api.js'
// Keep a guard above Jest's 5s default; scheduled functions are driven deterministically below.
jest.setTimeout(15000)
// Collect all fetch calls for assertion
let fetchCalls: Array<{ url: string; body: unknown }> = []
const originalFetch = global.fetch
function mockFetch(responseByUrl?: Record<string, unknown>) {
fetchCalls = []
return jest.fn(async (url: string | URL, init?: RequestInit) => {
const urlStr = url.toString()
let body: unknown
if (init?.body) {
let rawText: string
if (init.body instanceof Blob) {
const headers = init.headers as Record<string, string> | undefined
if (headers?.['Content-Encoding'] === 'gzip') {
const ds = new DecompressionStream('gzip')
rawText = await new Response(init.body.stream().pipeThrough(ds)).text()
} else {
rawText = await init.body.text()
}
} else {
rawText = init.body as string
}
try {
body = JSON.parse(rawText)
} catch {
body = rawText
}
}
fetchCalls.push({ url: urlStr, body })
if (responseByUrl) {
for (const [pattern, response] of Object.entries(responseByUrl)) {
if (urlStr.includes(pattern)) {
return new Response(JSON.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
}
}
return new Response(JSON.stringify({ status: 1 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
}
function batchCalls() {
return fetchCalls.filter((c) => c.url.includes('/batch'))
}
function flagsCalls() {
return fetchCalls.filter((c) => c.url.includes('/flags'))
}
// Extract the first event from the first batch call
function firstBatchEvent(): Record<string, unknown> {
const batches = batchCalls()
const batch = batches[0]?.body as { batch: Record<string, unknown>[] }
return batch?.batch?.[0] ?? {}
}
async function finishScheduledFunctions(t: ReturnType<typeof initConvexTest>) {
// Run only the Convex scheduler timer that was pending before this call. `runAllTimers()` also
// executes timers created by the scheduled action itself, such as PostHog request timeout/retry
// timers, which can make these tests race or hang under fake timers.
await jest.runOnlyPendingTimersAsync()
await t.finishInProgressScheduledFunctions()
}
describe('capture', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('sends event to Insights API with correct distinct_id and event name', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testCapture, {
distinctId: 'user-123',
event: 'button_clicked',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('sends event to PostHog API with correct distinct_id and event name', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testCapture, {
distinctId: 'user-123',
event: 'button_clicked',
})
expect(result).toEqual({ success: true })
await finishScheduledFunctions(t)
expect(batchCalls().length).toBeGreaterThanOrEqual(1)
const batch = batchCalls()[0].body as { api_key: string }
expect(batch.api_key).toBe('phc_test_key')
const event = firstBatchEvent()
expect(event.distinct_id).toBe('user-123')
expect(event.event).toBe('button_clicked')
})
test('sends properties and groups', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCapture, {
distinctId: 'user-456',
event: 'purchase',
properties: { plan: 'pro', amount: 99 },
groups: { company: 'acme' },
})
await finishScheduledFunctions(t)
const event = firstBatchEvent()
const props = event.properties as Record<string, unknown>
expect(props.plan).toBe('pro')
expect(props.amount).toBe(99)
expect(props.$groups).toEqual({ company: 'acme' })
})
test('beforeSend enriches properties with environment', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCapture, {
distinctId: 'user-123',
event: 'test',
properties: { foo: 'bar' },
})
await finishScheduledFunctions(t)
const props = firstBatchEvent().properties as Record<string, unknown>
expect(props.environment).toBe('example-app')
expect(props.foo).toBe('bar')
})
test('sends disableGeoip flag', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCapture, {
distinctId: 'user-123',
event: 'test',
disableGeoip: true,
})
await finishScheduledFunctions(t)
const props = firstBatchEvent().properties as Record<string, unknown>
expect(props.$geoip_disable).toBe(true)
})
test('sends custom uuid', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCapture, {
distinctId: 'user-123',
event: 'test',
uuid: 'custom-uuid-abc',
})
await finishScheduledFunctions(t)
const event = firstBatchEvent()
expect(event.uuid).toBe('custom-uuid-abc')
})
test('sends timestamp', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCapture, {
distinctId: 'user-123',
event: 'test',
timestamp: '2024-06-15T12:00:00Z',
})
await finishScheduledFunctions(t)
const event = firstBatchEvent()
expect(event.timestamp).toContain('2024-06-15')
})
})
describe('identify', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('sends $identify event', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testIdentify, {
distinctId: 'user-123',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_HOST
fetchCalls = []
})
const event = firstBatchEvent()
// insights-node puts properties into $set inside event.properties
const props = event.properties as Record<string, unknown>
const $set = props.$set as Record<string, unknown>
expect($set.name).toBe('Test User')
expect($set.email).toBe('[email protected]')
})
const result = await t.mutation(api.example.testIdentify, {
distinctId: 'user-123',
})
expect(result).toEqual({ success: true })
await finishScheduledFunctions(t)
expect(batchCalls().length).toBeGreaterThanOrEqual(1)
const event = firstBatchEvent()
expect(event.event).toBe('$identify')
expect(event.distinct_id).toBe('user-123')
})
test('sends user properties', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
describe('groupIdentify', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('sends $groupidentify event with group type and key', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testGroupIdentify, {
groupType: 'company',
groupKey: 'acme',
})
test('sends disableGeoip', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testIdentify, {
distinctId: 'user-123',
disableGeoip: true,
})
await finishScheduledFunctions(t)
const props = firstBatchEvent().properties as Record<string, unknown>
expect(props.$geoip_disable).toBe(true)
})
jest.runAllTimers()
await t.finishInProgressScheduledFunctions()
const props = firstBatchEvent().properties as Record<string, unknown>
const groupSet = props.$group_set as Record<string, unknown>
expect(groupSet.industry).toBe('Technology')
expect(groupSet.size).toBe(100)
})
test('uses distinctId override when provided', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testGroupIdentify, {
groupType: 'company',
groupKey: 'acme',
distinctId: 'override-user',
})
jest.runAllTimers()
await t.finishInProgressScheduledFunctions()
expect(firstBatchEvent().distinct_id).toBe('override-user')
})
})
describe('alias', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('sends $create_alias event', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testAlias, {
distinctId: 'user-123',
alias: 'anon-456',
})
expect(result).toEqual({ success: true })
jest.runAllTimers()
await t.finishInProgressScheduledFunctions()
expect(batchCalls().length).toBeGreaterThanOrEqual(1)
const event = firstBatchEvent()
expect(event.event).toBe('$create_alias')
const props = event.properties as Record<string, unknown>
expect(props.distinct_id).toBe('user-123')
expect(props.alias).toBe('anon-456')
})
test('sends disableGeoip', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testAlias, {
distinctId: 'user-123',
alias: 'anon-456',
disableGeoip: true,
})
jest.runAllTimers()
await t.finishInProgressScheduledFunctions()
const props = firstBatchEvent().properties as Record<string, unknown>
expect(props.$geoip_disable).toBe(true)
})
})
describe('captureException', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('sends $exception event with Error object', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testCaptureException, {
errorMessage: 'Something went wrong',
errorType: 'error',
})
expect(batchCalls().length).toBeGreaterThanOrEqual(1)
const event = firstBatchEvent()
expect(event.event).toBe('$exception')
const props = event.properties as Record<string, unknown>
// insights-node v5 uses $exception_list instead of $exception_message
const exceptionList = props.$exception_list as Array<{
value: string
type: string
}>
expect(exceptionList[0].value).toBe('Something went wrong')
})
test('sends $exception event with string error', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCaptureException, {
errorMessage: 'string error',
errorType: 'string',
})
test('sends $exception event with Error object', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
const result = await t.mutation(api.example.testCaptureException, {
errorMessage: 'Something went wrong',
errorType: 'error',
})
expect(result).toEqual({ success: true })
await finishScheduledFunctions(t)
expect(batchCalls().length).toBeGreaterThanOrEqual(1)
const event = firstBatchEvent()
expect(event.event).toBe('$exception')
const props = event.properties as Record<string, unknown>
// posthog-node v5 uses $exception_list instead of $exception_message
const exceptionList = props.$exception_list as Array<{
value: string
type: string
}>
expect(exceptionList[0].value).toBe('Something went wrong')
})
test('sends $exception event with string error', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCaptureException, {
errorMessage: 'string error',
errorType: 'string',
})
await finishScheduledFunctions(t)
const props = firstBatchEvent().properties as Record<string, unknown>
const exceptionList = props.$exception_list as Array<{
value: string
}>
expect(exceptionList[0].value).toBe('string error')
})
test('sends $exception event with object error', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCaptureException, {
errorMessage: 'obj error',
errorType: 'object',
})
await finishScheduledFunctions(t)
const props = firstBatchEvent().properties as Record<string, unknown>
const exceptionList = props.$exception_list as Array<{
value: string
}>
expect(exceptionList[0].value).toBe('obj error')
})
test('includes additional properties', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCaptureException, {
errorMessage: 'test',
additionalProperties: { page: '/checkout', step: 3 },
})
await finishScheduledFunctions(t)
const props = firstBatchEvent().properties as Record<string, unknown>
expect(props.page).toBe('/checkout')
expect(props.step).toBe(3)
})
test('uses distinctId when provided', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
await t.mutation(api.example.testCaptureException, {
errorMessage: 'test',
distinctId: 'specific-user',
})
await finishScheduledFunctions(t)
expect(firstBatchEvent().distinct_id).toBe('specific-user')
})
})
// --- Local feature flag evaluation tests ---
//
// In v1, feature flags are evaluated locally against definitions cached by the component cron.
// Tests mock `/flags/definitions`, trigger the refresh action to populate the cache, then
// invoke the user-defined queries.
type FlagDefinition = {
id: number
name: string
key: string
filters: {
groups: Array<{
properties: Array<{ key: string; value: unknown; operator?: string; type?: string }>
rollout_percentage?: number
variant?: string
}>
multivariate?: { variants: Array<{ key: string; rollout_percentage: number }> }
payloads?: Record<string, string>
aggregation_group_type_index?: number
}
deleted: boolean
active: boolean
rollout_percentage: number | null
ensure_experience_continuity: boolean
experiment_set: number[]
}
function flagDef(key: string, overrides: Partial<FlagDefinition> = {}): FlagDefinition {
return {
id: 1,
name: key,
key,
deleted: false,
active: true,
rollout_percentage: null,
ensure_experience_continuity: false,
experiment_set: [],
filters: { groups: [{ properties: [], rollout_percentage: 100 }] },
...overrides,
}
}
function definitionsResponse(flags: FlagDefinition[]) {
return {
'/flags/definitions': {
flags,
group_type_mapping: {},
cohorts: {},
},
}
}
async function loadDefinitions(t: ReturnType<typeof initConvexTest>) {
// Credentials are read from POSTHOG_PROJECT_TOKEN / POSTHOG_HOST / POSTHOG_PERSONAL_API_KEY env vars
// inside the component action, so the call itself takes no args.
await t.action(components.posthog.lib.refreshFlagDefinitions, {})
}
describe('getFeatureFlag (local eval)', () => {
beforeEach(() => {
process.env.POSTHOG_PROJECT_TOKEN = 'phc_test_key'
process.env.POSTHOG_PERSONAL_API_KEY = 'phx_test_personal_key'
process.env.POSTHOG_HOST = 'https://test.posthog.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns true for fully-rolled-out boolean flag', async () => {
global.fetch = mockFetch(definitionsResponse([flagDef('test-flag')]))
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
expect(result).toEqual({ flagKey: 'test-flag', value: true })
})
test('returns null for non-existent flag', async () => {
global.fetch = mockFetch(definitionsResponse([]))
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'missing',
})
expect(result).toEqual({ flagKey: 'missing', value: null })
})
test('returns null when local definitions have not loaded yet', async () => {
global.fetch = mockFetch()
const t = initConvexTest()
// Intentionally skip loadDefinitions — the cache is empty.
const result = await t.query(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
expect(result).toEqual({ flagKey: 'test-flag', value: null })
})
test('matches by person properties', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('test-flag', {
filters: {
groups: [
{
properties: [{ key: 'email', value: ['[email protected]'], operator: 'exact', type: 'person' }],
rollout_percentage: 100,
},
],
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const matched = await t.query(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'test-flag',
personProperties: { email: '[email protected]' },
})
expect(matched.value).toBe(true)
const unmatched = await t.query(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'test-flag',
personProperties: { email: '[email protected]' },
})
expect(unmatched.value).toBe(false)
})
test('returns variant key for multivariate flags', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('mv-flag', {
filters: {
groups: [{ properties: [], rollout_percentage: 100, variant: 'variant-a' }],
multivariate: {
variants: [
{ key: 'variant-a', rollout_percentage: 100 },
{ key: 'variant-b', rollout_percentage: 0 },
],
},
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'mv-flag',
})
expect(result.value).toBe('variant-a')
})
})
describe('getFeatureFlag', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('returns flag value', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': 'variant-a' }))
const t = initConvexTest()
const result = await t.action(api.example.testGetFeatureFlag, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns true for enabled boolean flag', async () => {
global.fetch = mockFetch(definitionsResponse([flagDef('on-flag')]))
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testIsFeatureEnabled, {
distinctId: 'user-123',
flagKey: 'on-flag',
})
expect(result).toEqual({ flagKey: 'on-flag', enabled: true })
})
test('returns true for a string variant', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('mv-flag', {
filters: {
groups: [{ properties: [], rollout_percentage: 100, variant: 'variant-a' }],
multivariate: {
variants: [
{ key: 'variant-a', rollout_percentage: 100 },
{ key: 'variant-b', rollout_percentage: 0 },
],
},
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testIsFeatureEnabled, {
distinctId: 'user-123',
flagKey: 'mv-flag',
})
expect(result).toEqual({ flagKey: 'mv-flag', enabled: true })
})
test('returns null for non-existent flag', async () => {
global.fetch = mockFetch(definitionsResponse([]))
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testIsFeatureEnabled, {
distinctId: 'user-123',
flagKey: 'missing',
})
expect(result).toEqual({ flagKey: 'missing', enabled: null })
})
})
describe('isFeatureEnabled', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('returns true for enabled flag', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': true }))
const t = initConvexTest()
const result = await t.action(api.example.testIsFeatureEnabled, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns payload for matching flag', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('test-flag', {
filters: {
groups: [{ properties: [], rollout_percentage: 100 }],
payloads: { true: JSON.stringify({ key: 'value' }) },
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlagPayload, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
expect(result).toEqual({ flagKey: 'test-flag', payload: { key: 'value' } })
})
test('returns null when no payload is configured', async () => {
global.fetch = mockFetch(definitionsResponse([flagDef('test-flag')]))
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlagPayload, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
expect(result).toEqual({ flagKey: 'test-flag', payload: null })
})
test('honours matchValue parameter', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('mv-flag', {
filters: {
groups: [{ properties: [], rollout_percentage: 0 }],
multivariate: {
variants: [
{ key: 'variant-a', rollout_percentage: 50 },
{ key: 'variant-b', rollout_percentage: 50 },
],
},
payloads: { 'variant-a': 'payload-data' },
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlagPayload, {
distinctId: 'user-123',
flagKey: 'mv-flag',
matchValue: 'variant-a',
})
expect(result.flagKey).toBe('mv-flag')
expect(result.payload).toBe('payload-data')
})
})
describe('getFeatureFlagPayload', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('returns payload for flag', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': true }, { 'test-flag': { key: 'value' } }))
const t = initConvexTest()
const result = await t.action(api.example.testGetFeatureFlagPayload, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns variant and payload for multivariate flag', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('test-flag', {
filters: {
groups: [{ properties: [], rollout_percentage: 100, variant: 'variant-a' }],
multivariate: {
variants: [
{ key: 'variant-a', rollout_percentage: 100 },
{ key: 'variant-b', rollout_percentage: 0 },
],
},
payloads: { 'variant-a': JSON.stringify({ config: true }) },
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlagResult, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
expect(result.flagKey).toBe('test-flag')
expect(result.result).not.toBeNull()
expect(result.result!.enabled).toBe(true)
expect(result.result!.variant).toBe('variant-a')
expect(result.result!.payload).toEqual({ config: true })
})
test('returns null for non-existent flag', async () => {
global.fetch = mockFetch(definitionsResponse([]))
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetFeatureFlagResult, {
distinctId: 'user-123',
flagKey: 'missing',
})
expect(result).toEqual({ flagKey: 'missing', result: null })
})
})
describe('getFeatureFlagResult', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('returns full result with variant and payload', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': 'variant-a' }, { 'test-flag': { config: true } }))
const t = initConvexTest()
const result = await t.action(api.example.testGetFeatureFlagResult, {
distinctId: 'user-123',
flagKey: 'test-flag',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns all flag values', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('flag-a'),
flagDef('flag-b', {
filters: {
groups: [{ properties: [], rollout_percentage: 100, variant: 'variant-1' }],
multivariate: {
variants: [
{ key: 'variant-1', rollout_percentage: 100 },
{ key: 'variant-2', rollout_percentage: 0 },
],
},
},
}),
flagDef('flag-c', { active: false }),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetAllFlags, { distinctId: 'user-123' })
expect(result.flags).toEqual({
'flag-a': true,
'flag-b': 'variant-1',
'flag-c': false,
})
})
test('respects flagKeys filter', async () => {
global.fetch = mockFetch(
definitionsResponse([flagDef('flag-a'), flagDef('flag-b'), flagDef('flag-c')])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetAllFlags, {
distinctId: 'user-123',
flagKeys: ['flag-a', 'flag-b'],
})
expect(Object.keys(result.flags).sort()).toEqual(['flag-a', 'flag-b'])
})
})
describe('getAllFlags', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('returns all flags', async () => {
global.fetch = mockFetch(
flagsResponse({
'flag-a': true,
'flag-b': 'variant-1',
'flag-c': false,
})
)
const t = initConvexTest()
const result = await t.action(api.example.testGetAllFlags, {
distinctId: 'user-123',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns flag values and payloads', async () => {
global.fetch = mockFetch(
definitionsResponse([
flagDef('flag-a', {
filters: {
groups: [{ properties: [], rollout_percentage: 100 }],
payloads: { true: JSON.stringify({ config: 'value' }) },
},
}),
flagDef('flag-b', {
filters: {
groups: [{ properties: [], rollout_percentage: 100, variant: 'variant' }],
multivariate: {
variants: [{ key: 'variant', rollout_percentage: 100 }],
},
},
}),
])
)
const t = initConvexTest()
await loadDefinitions(t)
const result = await t.query(api.example.testGetAllFlagsAndPayloads, { distinctId: 'user-123' })
expect(result.featureFlags).toEqual({ 'flag-a': true, 'flag-b': 'variant' })
expect(result.featureFlagPayloads).toEqual({ 'flag-a': { config: 'value' } })
})
})
describe('getAllFlagsAndPayloads', () => {
beforeEach(() => {
process.env.INSIGHTS_API_KEY = 'phc_test_key'
process.env.INSIGHTS_HOST = 'https://test.insights.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.INSIGHTS_API_KEY
delete process.env.INSIGHTS_HOST
fetchCalls = []
})
test('returns flags and payloads', async () => {
global.fetch = mockFetch(flagsResponse({ 'flag-a': true, 'flag-b': 'variant' }, { 'flag-a': { config: 'value' } }))
const t = initConvexTest()
const result = await t.action(api.example.testGetAllFlagsAndPayloads, {
distinctId: 'user-123',
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
delete process.env.POSTHOG_FLAGS_RETRY_DELAY_MS_OVERRIDE
fetchCalls = []
jest.useFakeTimers()
})
// No credentials are passed to the action — they're env-driven (POSTHOG_PROJECT_TOKEN,
// POSTHOG_HOST, POSTHOG_PERSONAL_API_KEY) and configured in beforeEach.
const noArgs = {}
/** Builds a fetch mock whose responses are picked per call from the supplied sequence. */
function sequencedFetch(
responses: Array<{ status: number; body?: unknown; headers?: Record<string, string> }>
) {
fetchCalls = []
let i = 0
// Statuses where the spec forbids a body (Response constructor throws on non-null body).
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304])
return jest.fn(async (url: string | URL) => {
fetchCalls.push({ url: url.toString(), body: undefined })
const r = responses[Math.min(i, responses.length - 1)]
i++
const payload =
NULL_BODY_STATUSES.has(r.status) || r.body === undefined
? null
: typeof r.body === 'string'
? r.body
: JSON.stringify(r.body)
return new Response(payload, { status: r.status, headers: r.headers ?? {} })
}) as unknown as typeof fetch
}
test('hits /flags/definitions with personal API key', async () => {
global.fetch = mockFetch(definitionsResponse([flagDef('flag-a')]))
const t = initConvexTest()
await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)
const definitionCalls = fetchCalls.filter((c) => c.url.includes('/flags/definitions'))
expect(definitionCalls).toHaveLength(1)
expect(definitionCalls[0].url).toContain('token=phc_test_key')
expect(definitionCalls[0].url).toContain('send_cohorts')
})
test('no-ops when POSTHOG_PERSONAL_API_KEY env var is missing', async () => {
// Clearing the env var simulates a deployment where local evaluation isn't configured —
// the refresh action returns a skip status rather than fetching.
delete process.env.POSTHOG_PERSONAL_API_KEY
global.fetch = mockFetch()
const t = initConvexTest()
const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as { status: string }
expect(result.status).toBe('skipped')
expect(fetchCalls.filter((c) => c.url.includes('/flags/definitions'))).toHaveLength(0)
})
test('retries transient 5xx and persists on eventual 200', async () => {
// First two calls flap as 502/503, third succeeds — definitions still land in the cache.
const flag = flagDef('flag-a')
global.fetch = sequencedFetch([
{ status: 502 },
{ status: 503 },
{
status: 200,
body: { flags: [flag], group_type_mapping: {}, cohorts: {} },
headers: { ETag: 'W/"fresh"' },
},
])
const t = initConvexTest()
const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as {
status: string
}
expect(result.status).toBe('updated')
expect(fetchCalls.filter((c) => c.url.includes('/flags/definitions'))).toHaveLength(3)
})
test('503 cold-cache with no prior cache writes an empty snapshot', async () => {
global.fetch = sequencedFetch([
{ status: 503, body: 'Required data not found in cache. This is likely a temporary issue.' },
])
const t = initConvexTest()
const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as {
status: string
}
expect(result.status).toBe('empty')
// Subsequent reads see the empty snapshot rather than null.
const row = await t.query(components.posthog.lib.getFlagDefinitions, {})
expect(row).not.toBeNull()
expect(JSON.parse(row!.data)).toEqual({ flags: [], groupTypeMapping: {}, cohorts: {} })
})
test('503 cold-cache with a fresh prior cache keeps the existing snapshot', async () => {
// Seed the cache with real defs.
const t = initConvexTest()
global.fetch = mockFetch(definitionsResponse([flagDef('seed')]))
await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)
// Now PostHog flaps cold-cache 503s. Cache is < 5min old so we keep what we have.
global.fetch = sequencedFetch([
{ status: 503, body: 'Required data not found in cache.' },
])
const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as {
status: string
}
expect(result.status).toBe('stale')
const row = await t.query(components.posthog.lib.getFlagDefinitions, {})
expect(JSON.parse(row!.data).flags).toHaveLength(1)
})
test('503 cold-cache with a stale (>5min) prior cache replaces with empty', async () => {
// Fake `Date.now` only — leave `setTimeout`/`setImmediate` real so the retry loop's
// `await new Promise(r => setTimeout(r, …))` still resolves.
jest.useFakeTimers({ doNotFake: ['setTimeout', 'setImmediate', 'queueMicrotask'] })
try {
const t = initConvexTest()
global.fetch = mockFetch(definitionsResponse([flagDef('seed')]))
await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)
// Jump 6 minutes forward; the cached defs now count as stale.
jest.setSystemTime(new Date(Date.now() + 6 * 60 * 1000))
global.fetch = sequencedFetch([
{ status: 503, body: 'Required data not found in cache.' },
])
const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as {
status: string
}
expect(result.status).toBe('empty')
const row = await t.query(components.posthog.lib.getFlagDefinitions, {})
expect(JSON.parse(row!.data).flags).toHaveLength(0)
} finally {
jest.useRealTimers()
}
})
test('304 not-modified leaves the existing snapshot in place', async () => {
// Seed.
const t = initConvexTest()
global.fetch = mockFetch({
'/flags/definitions': { flags: [flagDef('seed')], group_type_mapping: {}, cohorts: {} },
})
await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)
// Now PostHog returns 304 — no body, just the not-modified status.
global.fetch = sequencedFetch([{ status: 304 }])
const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as {
status: string
}
expect(result.status).toBe('unchanged')
const row = await t.query(components.posthog.lib.getFlagDefinitions, {})
expect(JSON.parse(row!.data).flags).toHaveLength(1)
})
})
describe('getFlagDefinitions query', () => {
// The query exposes a `localEvalConfigured` flag based on whether the component sees
// `POSTHOG_PERSONAL_API_KEY` in its env. The client uses this to distinguish "not configured"
// (throw) from "configured but not warmed up" (undefined). These tests guard the query
// surface that promise rests on.
beforeEach(() => {
process.env.POSTHOG_PROJECT_TOKEN = 'phc_test_key'
process.env.POSTHOG_HOST = 'https://test.posthog.com'
})
afterEach(() => {
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_PERSONAL_API_KEY
delete process.env.POSTHOG_HOST
})
test('reports localEvalConfigured=false when POSTHOG_PERSONAL_API_KEY is unset', async () => {
const t = initConvexTest()
const row = await t.query(components.posthog.lib.getFlagDefinitions, {})
expect(row.localEvalConfigured).toBe(false)
expect(row.data).toBeNull()
expect(row.fetchedAt).toBeNull()
})
test('reports localEvalConfigured=true when POSTHOG_PERSONAL_API_KEY is set', async () => {
process.env.POSTHOG_PERSONAL_API_KEY = 'phx_test'
const t = initConvexTest()
const row = await t.query(components.posthog.lib.getFlagDefinitions, {})
expect(row.localEvalConfigured).toBe(true)
expect(row.data).toBeNull()
})
})
// --- Remote feature flag evaluation tests ---
//
// These hit posthog-node's `evaluateFlags` under the hood, which posts to PostHog's `/flags`
// endpoint with the user's distinctId + properties. Mocked at the fetch level.
function flagsResponse(featureFlags: Record<string, unknown>, featureFlagPayloads: Record<string, unknown> = {}) {
return {
'/flags': {
featureFlags,
featureFlagPayloads,
requestId: 'test-request-id',
},
}
}
describe('evaluateFlag (remote)', () => {
beforeEach(() => {
process.env.POSTHOG_PROJECT_TOKEN = 'phc_test_key'
process.env.POSTHOG_HOST = 'https://test.posthog.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns the flag value from /flags', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': 'variant-a' }))
const t = initConvexTest()
const value = await t.action(components.posthog.lib.evaluateFlag, {
key: 'test-flag',
distinctId: 'user-123',
})
expect(value).toBe('variant-a')
const flagsCalls = fetchCalls.filter((c) => c.url.includes('/flags'))
expect(flagsCalls.length).toBeGreaterThanOrEqual(1)
})
test('returns null for missing flags', async () => {
global.fetch = mockFetch(flagsResponse({}))
const t = initConvexTest()
const value = await t.action(components.posthog.lib.evaluateFlag, {
key: 'missing',
distinctId: 'user-123',
})
expect(value).toBeNull()
})
test('forwards person and group properties', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': true }))
const t = initConvexTest()
await t.action(components.posthog.lib.evaluateFlag, {
key: 'test-flag',
distinctId: 'user-123',
groups: { company: 'acme' },
personProperties: { email: '[email protected]' },
groupProperties: { company: { industry: 'tech' } },
})
const flagsCalls = fetchCalls.filter((c) => c.url.includes('/flags'))
const body = flagsCalls[0].body as Record<string, unknown>
expect(body.distinct_id).toBe('user-123')
expect(body.groups).toEqual({ company: 'acme' })
expect(body.person_properties).toMatchObject({ email: '[email protected]' })
expect(body.group_properties).toMatchObject({ company: { industry: 'tech' } })
})
})
describe('evaluateFlagPayload (remote)', () => {
beforeEach(() => {
process.env.POSTHOG_PROJECT_TOKEN = 'phc_test_key'
process.env.POSTHOG_HOST = 'https://test.posthog.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns the payload from /flags', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': true }, { 'test-flag': { config: 'value' } }))
const t = initConvexTest()
const payload = await t.action(components.posthog.lib.evaluateFlagPayload, {
key: 'test-flag',
distinctId: 'user-123',
})
expect(payload).toEqual({ config: 'value' })
})
test('returns null when no payload is configured', async () => {
global.fetch = mockFetch(flagsResponse({ 'test-flag': true }))
const t = initConvexTest()
const payload = await t.action(components.posthog.lib.evaluateFlagPayload, {
key: 'test-flag',
distinctId: 'user-123',
})
expect(payload).toBeNull()
})
})
describe('evaluateAllFlags (remote)', () => {
beforeEach(() => {
process.env.POSTHOG_PROJECT_TOKEN = 'phc_test_key'
process.env.POSTHOG_HOST = 'https://test.posthog.com'
})
afterEach(() => {
global.fetch = originalFetch
delete process.env.POSTHOG_PROJECT_TOKEN
delete process.env.POSTHOG_HOST
fetchCalls = []
})
test('returns all flags and payloads', async () => {
global.fetch = mockFetch(
flagsResponse(
{ 'flag-a': true, 'flag-b': 'variant', 'flag-c': false },
{ 'flag-a': { config: 'value' } }
)
)
const t = initConvexTest()
const result = (await t.action(components.posthog.lib.evaluateAllFlags, {
distinctId: 'user-123',
})) as { featureFlags: Record<string, unknown>; featureFlagPayloads: Record<string, unknown> }
expect(result.featureFlags).toEqual({
'flag-a': true,
'flag-b': 'variant',
'flag-c': false,
})
expect(result.featureFlagPayloads).toEqual({ 'flag-a': { config: 'value' } })
})
})