mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-10-30 18:34:19 +00:00
The merge 26a9531 ("merge: upstream/main (sync, brand preserved)") took a
branded 817-line example and an 1196-line upstream rewrite of the same file
and interleaved them. Test bodies ended up under the wrong test names, an
afterEach landed inside a test body, and orphaned statements sat at top level
after a closing paren. Neither example.test.ts nor example.ts parsed. Only
the test file failed the unit gate, because example.ts is not a test suite --
it is the module the suite drives, so its damage showed up as a suite that
could not run rather than as an error of its own.
Both files are restored from the upstream side, which is the one that moved:
our side of the merge contributed only the brand rename, while upstream had
reworked feature flags into local evaluation (queries) with remote evaluation
split off into separate actions. That is what the shipped client does today,
so the upstream side is the one that describes it. The rename is reapplied on
top -- POSTHOG_ to INSIGHTS_, posthog-node to insights-node, components.posthog
to components.insights, PostHog to Insights.
Restoring the pair also gives example.ts back the testThrowError,
testEvaluateFlag, testEvaluateFlagPayload and testEvaluateAllFlags exports
that the scramble had dropped while App.tsx was still calling them.
Two things did not survive as-is. Events now post to ${host}/v1/e, not the
/batch/ route the tests were written against, so the helper that picks event
calls out of the fetch log matched nothing and every capture, identify and
captureException assertion read an empty event; the path is now a named
constant. And upstream had no groupIdentify or alias wrappers, so taking its
example.ts wholesale would have left the component's handlers for both with
no end-to-end coverage -- they are restored from our pre-merge side, moved to
the deterministic scheduler helper and the INSIGHTS_PROJECT_TOKEN the
component actually reads, plus a case for the group-derived distinct_id
fallback that nothing else exercised.
Verified: packages/convex pnpm test:unit completes in ~5s, 8 suites and 151
tests passing, where before the suite could not be parsed. Component coverage
rises to 84.65% of statements and 100% of functions. Repo-wide pnpm build is
34/34. No occurrence of the upstream brand remains in either file.
1326 lines
46 KiB
TypeScript
1326 lines
46 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
|
|
}
|
|
|
|
// Event ingest path, as built by insights-core (`${host}/v1/e`). Batches still post
|
|
// `{ api_key, batch: [...] }`; only the path differs from the older `/batch/` route.
|
|
const INGEST_PATH = '/v1/e'
|
|
|
|
function batchCalls() {
|
|
return fetchCalls.filter((c) => c.url.includes(INGEST_PATH))
|
|
}
|
|
|
|
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 Insights 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_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
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',
|
|
})
|
|
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_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
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',
|
|
})
|
|
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()
|
|
|
|
await t.mutation(api.example.testIdentify, {
|
|
distinctId: 'user-123',
|
|
properties: {
|
|
name: 'Test User',
|
|
email: '[email protected]',
|
|
},
|
|
})
|
|
await finishScheduledFunctions(t)
|
|
|
|
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]')
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('groupIdentify', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
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',
|
|
})
|
|
expect(result).toEqual({ success: true })
|
|
await finishScheduledFunctions(t)
|
|
|
|
expect(batchCalls().length).toBeGreaterThanOrEqual(1)
|
|
const event = firstBatchEvent()
|
|
expect(event.event).toBe('$groupidentify')
|
|
const props = event.properties as Record<string, unknown>
|
|
expect(props.$group_type).toBe('company')
|
|
expect(props.$group_key).toBe('acme')
|
|
})
|
|
|
|
test('sends group properties via $group_set', async () => {
|
|
global.fetch = mockFetch()
|
|
const t = initConvexTest()
|
|
|
|
await t.mutation(api.example.testGroupIdentify, {
|
|
groupType: 'company',
|
|
groupKey: 'acme',
|
|
properties: { industry: 'Technology', size: 100 },
|
|
})
|
|
await finishScheduledFunctions(t)
|
|
|
|
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('falls back to a group-derived distinct_id when none is given', async () => {
|
|
global.fetch = mockFetch()
|
|
const t = initConvexTest()
|
|
|
|
await t.mutation(api.example.testGroupIdentify, {
|
|
groupType: 'company',
|
|
groupKey: 'acme',
|
|
})
|
|
await finishScheduledFunctions(t)
|
|
|
|
expect(firstBatchEvent().distinct_id).toBe('$company_acme')
|
|
})
|
|
|
|
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',
|
|
})
|
|
await finishScheduledFunctions(t)
|
|
|
|
expect(firstBatchEvent().distinct_id).toBe('override-user')
|
|
})
|
|
})
|
|
|
|
describe('alias', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
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 })
|
|
await finishScheduledFunctions(t)
|
|
|
|
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,
|
|
})
|
|
await finishScheduledFunctions(t)
|
|
|
|
const props = firstBatchEvent().properties as Record<string, unknown>
|
|
expect(props.$geoip_disable).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('captureException', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
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(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>
|
|
// 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',
|
|
})
|
|
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 INSIGHTS_PROJECT_TOKEN / INSIGHTS_HOST / INSIGHTS_PERSONAL_API_KEY env vars
|
|
// inside the component action, so the call itself takes no args.
|
|
await t.action(components.insights.lib.refreshFlagDefinitions, {})
|
|
}
|
|
|
|
describe('getFeatureFlag (local eval)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_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('isFeatureEnabled (local eval)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_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('getFeatureFlagPayload (local eval)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_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('getFeatureFlagResult (local eval)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_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('getAllFlags (local eval)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_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('getAllFlagsAndPayloads (local eval)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_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('refreshFlagDefinitions cron action', () => {
|
|
// The retry loop awaits `setTimeout`s — with the default jest fakeTimers config those never
|
|
// fire and the action hangs. Switch to real timers for this block and cut the backoff down
|
|
// to 1ms via the env override so the retry-heavy tests stay snappy.
|
|
beforeEach(() => {
|
|
jest.useRealTimers()
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test_personal_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
process.env.INSIGHTS_FLAGS_RETRY_DELAY_MS_OVERRIDE = '1'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_HOST
|
|
delete process.env.INSIGHTS_FLAGS_RETRY_DELAY_MS_OVERRIDE
|
|
fetchCalls = []
|
|
jest.useFakeTimers()
|
|
})
|
|
|
|
// No credentials are passed to the action — they're env-driven (INSIGHTS_PROJECT_TOKEN,
|
|
// INSIGHTS_HOST, INSIGHTS_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.insights.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 INSIGHTS_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.INSIGHTS_PERSONAL_API_KEY
|
|
global.fetch = mockFetch()
|
|
const t = initConvexTest()
|
|
|
|
const result = (await t.action(components.insights.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.insights.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.insights.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.insights.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.insights.lib.refreshFlagDefinitions, noArgs)
|
|
|
|
// Now Insights 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.insights.lib.refreshFlagDefinitions, noArgs)) as {
|
|
status: string
|
|
}
|
|
|
|
expect(result.status).toBe('stale')
|
|
const row = await t.query(components.insights.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.insights.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.insights.lib.refreshFlagDefinitions, noArgs)) as {
|
|
status: string
|
|
}
|
|
|
|
expect(result.status).toBe('empty')
|
|
const row = await t.query(components.insights.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.insights.lib.refreshFlagDefinitions, noArgs)
|
|
|
|
// Now Insights returns 304 — no body, just the not-modified status.
|
|
global.fetch = sequencedFetch([{ status: 304 }])
|
|
const result = (await t.action(components.insights.lib.refreshFlagDefinitions, noArgs)) as {
|
|
status: string
|
|
}
|
|
|
|
expect(result.status).toBe('unchanged')
|
|
const row = await t.query(components.insights.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
|
|
// `INSIGHTS_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.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_PERSONAL_API_KEY
|
|
delete process.env.INSIGHTS_HOST
|
|
})
|
|
|
|
test('reports localEvalConfigured=false when INSIGHTS_PERSONAL_API_KEY is unset', async () => {
|
|
const t = initConvexTest()
|
|
const row = await t.query(components.insights.lib.getFlagDefinitions, {})
|
|
expect(row.localEvalConfigured).toBe(false)
|
|
expect(row.data).toBeNull()
|
|
expect(row.fetchedAt).toBeNull()
|
|
})
|
|
|
|
test('reports localEvalConfigured=true when INSIGHTS_PERSONAL_API_KEY is set', async () => {
|
|
process.env.INSIGHTS_PERSONAL_API_KEY = 'phx_test'
|
|
const t = initConvexTest()
|
|
const row = await t.query(components.insights.lib.getFlagDefinitions, {})
|
|
expect(row.localEvalConfigured).toBe(true)
|
|
expect(row.data).toBeNull()
|
|
})
|
|
})
|
|
|
|
// --- Remote feature flag evaluation tests ---
|
|
//
|
|
// These hit insights-node's `evaluateFlags` under the hood, which posts to Insights'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.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_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.insights.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.insights.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.insights.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.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_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.insights.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.insights.lib.evaluateFlagPayload, {
|
|
key: 'test-flag',
|
|
distinctId: 'user-123',
|
|
})
|
|
|
|
expect(payload).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('evaluateAllFlags (remote)', () => {
|
|
beforeEach(() => {
|
|
process.env.INSIGHTS_PROJECT_TOKEN = 'phc_test_key'
|
|
process.env.INSIGHTS_HOST = 'https://test.insights.com'
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
delete process.env.INSIGHTS_PROJECT_TOKEN
|
|
delete process.env.INSIGHTS_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.insights.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' } })
|
|
})
|
|
})
|