UNPKG

posthog-node

Version:
1,344 lines (1,183 loc) 42.8 kB
import { MINIMUM_POLLING_INTERVAL, PostHog as PostHog, THIRTY_SECONDS } from '../src/posthog-node' import fetch from '../src/fetch' import { anyDecideCall, anyLocalEvalCall, apiImplementation } from './test-utils' import { waitForPromises, wait } from '../../posthog-core/test/test-utils/test-utils' import { randomUUID } from 'crypto' jest.mock('../src/fetch') jest.mock('../package.json', () => ({ version: '1.2.3' })) const mockedFetch = jest.mocked(fetch, true) const waitForFlushTimer = async (): Promise<void> => { await waitForPromises() // To trigger the flush via the timer jest.runOnlyPendingTimers() // Then wait for the flush promise await waitForPromises() } const getLastBatchEvents = (): any[] | undefined => { expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' })) // reverse mock calls array to get the last call const call = mockedFetch.mock.calls.reverse().find((x) => (x[0] as string).includes('/batch/')) if (!call) { return undefined } return JSON.parse((call[1] as any).body as any).batch } describe('PostHog Node.js', () => { let posthog: PostHog jest.useFakeTimers() beforeEach(() => { posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, }) mockedFetch.mockResolvedValue({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ status: 'ok', }), } as any) }) afterEach(async () => { mockedFetch.mockResolvedValue({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ status: 'ok', }), } as any) // ensure clean shutdown & no test interdependencies await posthog.shutdown() }) describe('core methods', () => { it('should capture an event to shared queue', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 } }) await waitForFlushTimer() const batchEvents = getLastBatchEvents() expect(batchEvents).toEqual([ { distinct_id: '123', event: 'test-event', properties: { $groups: { org: 123 }, foo: 'bar', $geoip_disable: true, $lib: 'posthog-node', $lib_version: '1.2.3', }, uuid: expect.any(String), timestamp: expect.any(String), type: 'capture', library: 'posthog-node', library_version: '1.2.3', }, ]) }) it('shouldnt muddy subsequent capture calls', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 } }) await waitForFlushTimer() expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: '123', event: 'test-event', properties: expect.objectContaining({ $groups: { org: 123 }, foo: 'bar', }), library: 'posthog-node', library_version: '1.2.3', }) ) mockedFetch.mockClear() posthog.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { other_group: 'x' }, }) await waitForFlushTimer() expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: '123', event: 'test-event', properties: expect.objectContaining({ $groups: { other_group: 'x' }, foo: 'bar', $geoip_disable: true, }), library: 'posthog-node', library_version: '1.2.3', }) ) }) it('should capture identify events on shared queue', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.identify({ distinctId: '123', properties: { foo: 'bar' } }) jest.runOnlyPendingTimers() await waitForPromises() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', event: '$identify', properties: { $set: { foo: 'bar', }, $geoip_disable: true, }, }, ]) }) it('should handle identify using $set and $set_once', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.identify({ distinctId: '123', properties: { $set: { foo: 'bar' }, $set_once: { vip: true } } }) jest.runOnlyPendingTimers() await waitForPromises() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', event: '$identify', properties: { $set: { foo: 'bar', }, $set_once: { vip: true, }, $geoip_disable: true, }, }, ]) }) it('should handle identify using $set_once', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.identify({ distinctId: '123', properties: { foo: 'bar', $set_once: { vip: true } } }) jest.runOnlyPendingTimers() await waitForPromises() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', event: '$identify', properties: { $set: { foo: 'bar', }, $set_once: { vip: true, }, $geoip_disable: true, }, }, ]) }) it('should capture alias events on shared queue', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.alias({ distinctId: '123', alias: '1234' }) jest.runOnlyPendingTimers() await waitForPromises() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', event: '$create_alias', properties: { distinct_id: '123', alias: '1234', $geoip_disable: true, }, }, ]) }) it('should allow overriding timestamp', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.capture({ event: 'custom-time', distinctId: '123', timestamp: new Date('2021-02-03') }) await waitForFlushTimer() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', timestamp: '2021-02-03T00:00:00.000Z', event: 'custom-time', uuid: expect.any(String), }, ]) }) it('should allow overriding uuid', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) const uuid = randomUUID() posthog.capture({ event: 'custom-time', distinctId: '123', uuid }) await waitForFlushTimer() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', timestamp: expect.any(String), event: 'custom-time', uuid: uuid, }, ]) }) it('should respect disableGeoip setting if passed in', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 }, disableGeoip: false, }) await waitForFlushTimer() const batchEvents = getLastBatchEvents() expect(batchEvents?.[0].properties).toEqual({ $groups: { org: 123 }, foo: 'bar', $lib: 'posthog-node', $lib_version: '1.2.3', }) }) it('should use default is set, and override on specific disableGeoip calls', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) const client = new PostHog('TEST_API_KEY', { host: 'http://example.com', disableGeoip: false, }) client.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 } }) await waitForFlushTimer() let batchEvents = getLastBatchEvents() expect(batchEvents?.[0].properties).toEqual({ $groups: { org: 123 }, foo: 'bar', $lib: 'posthog-node', $lib_version: '1.2.3', }) client.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 }, disableGeoip: true, }) await waitForFlushTimer() batchEvents = getLastBatchEvents() expect(batchEvents?.[0].properties).toEqual({ $groups: { org: 123 }, foo: 'bar', $lib: 'posthog-node', $lib_version: '1.2.3', $geoip_disable: true, }) client.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 }, disableGeoip: false, }) await waitForFlushTimer() await waitForPromises() batchEvents = getLastBatchEvents() expect(batchEvents?.[0].properties).toEqual({ $groups: { org: 123 }, foo: 'bar', $lib: 'posthog-node', $lib_version: '1.2.3', }) await client.shutdown() }) it('should warn if capture is called with a string', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) posthog.debug(true) // @ts-expect-error - Testing the warning when passing a string instead of an object posthog.capture('test-event') expect(warnSpy).toHaveBeenCalledWith( 'Called capture() with a string as the first argument when an object was expected.' ) warnSpy.mockRestore() }) }) describe('shutdown', () => { let warnSpy: jest.SpyInstance, logSpy: jest.SpyInstance beforeEach(() => { const actualLog = console.log warnSpy = jest.spyOn(console, 'warn').mockImplementation((...args) => { actualLog('spied warn:', ...args) }) logSpy = jest.spyOn(console, 'log').mockImplementation((...args) => { actualLog('spied log:', ...args) }) mockedFetch.mockImplementation(async () => { // simulate network delay await wait(500) return Promise.resolve({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ status: 'ok', }), } as any) }) jest.useRealTimers() }) afterEach(() => { jest.useFakeTimers() }) it('should shutdown cleanly', async () => { const ph = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, flushAt: 1, }) ph.debug(true) // using debug mode to check console.log output // which tells us when the flush is complete ph.capture({ event: 'test-event', distinctId: '123' }) await wait(100) expect(logSpy).toHaveBeenCalledTimes(1) ph.capture({ event: 'test-event', distinctId: '123' }) ph.capture({ event: 'test-event', distinctId: '123' }) await wait(100) expect(logSpy).toHaveBeenCalledTimes(3) await wait(400) // The flush will resolve in this time ph.capture({ event: 'test-event', distinctId: '123' }) ph.capture({ event: 'test-event', distinctId: '123' }) await wait(100) expect(logSpy).toHaveBeenCalledTimes(6) // 5 captures and 1 flush expect(5).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('capture')).length) expect(1).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('flush')).length) logSpy.mockClear() expect(logSpy).toHaveBeenCalledTimes(0) console.warn('YOO!!') await ph.shutdown() // 1 final flush for the events that were queued during shutdown expect(1).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('flush')).length) logSpy.mockRestore() warnSpy.mockRestore() }) it('should shutdown cleanly with pending capture flag promises', async () => { const ph = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, flushAt: 4, }) ph.debug(true) for (let i = 0; i < 10; i++) { ph.capture({ event: 'test-event', distinctId: `${i}`, sendFeatureFlags: true }) } await ph.shutdown() // all capture calls happen during shutdown const batchEvents = getLastBatchEvents() expect(batchEvents?.length).toEqual(6) expect(batchEvents?.[batchEvents?.length - 1]).toMatchObject({ // last event in batch distinct_id: '9', event: 'test-event', library: 'posthog-node', library_version: '1.2.3', properties: { $lib: 'posthog-node', $lib_version: '1.2.3', $geoip_disable: true, }, timestamp: expect.any(String), type: 'capture', }) expect(10).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('capture')).length) // 1 for the captured events, 1 for the final flush of feature flag called events expect(2).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('flush')).length) logSpy.mockRestore() }) }) describe('groupIdentify', () => { it('should identify group with unique id', async () => { posthog.groupIdentify({ groupType: 'posthog', groupKey: 'team-1', properties: { analytics: true } }) jest.runOnlyPendingTimers() await posthog.flush() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '$posthog_team-1', event: '$groupidentify', properties: { $group_type: 'posthog', $group_key: 'team-1', $group_set: { analytics: true }, $lib: 'posthog-node', $geoip_disable: true, }, }, ]) }) it('should allow passing optional distinctID to identify group', async () => { posthog.groupIdentify({ groupType: 'posthog', groupKey: 'team-1', properties: { analytics: true }, distinctId: '123', }) jest.runOnlyPendingTimers() await posthog.flush() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: '123', event: '$groupidentify', properties: { $group_type: 'posthog', $group_key: 'team-1', $group_set: { analytics: true }, $lib: 'posthog-node', $geoip_disable: true, }, }, ]) }) }) describe('feature flags', () => { beforeEach(() => { const mockFeatureFlags = { 'feature-1': true, 'feature-2': true, 'feature-variant': 'variant', 'disabled-flag': false, 'feature-array': true, } // these are stringified in apiImplementation const mockFeatureFlagPayloads = { 'feature-1': { color: 'blue' }, 'feature-variant': 2, 'feature-array': [1], } const multivariateFlag = { id: 1, name: 'Beta Feature', key: 'beta-feature-local', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [{ key: 'email', type: 'person', value: 'test@posthog.com', operator: 'exact' }], rollout_percentage: 100, }, { rollout_percentage: 50, }, ], multivariate: { variants: [ { key: 'first-variant', name: 'First Variant', rollout_percentage: 50 }, { key: 'second-variant', name: 'Second Variant', rollout_percentage: 25 }, { key: 'third-variant', name: 'Third Variant', rollout_percentage: 25 }, ], }, payloads: { 'first-variant': 'some-payload', 'third-variant': JSON.stringify({ a: 'json' }) }, }, } const basicFlag = { id: 1, name: 'Beta Feature', key: 'person-flag', active: true, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, ], rollout_percentage: 100, }, ], payloads: { true: '300' }, }, } const falseFlag = { id: 1, name: 'Beta Feature', key: 'false-flag', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], payloads: { true: '300' }, }, } const arrayFlag = { id: 5, name: 'Beta Feature', key: 'feature-array', active: true, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], payloads: { true: JSON.stringify([1]) }, }, } mockedFetch.mockImplementation( apiImplementation({ decideFlags: mockFeatureFlags, decideFlagPayloads: mockFeatureFlagPayloads, localFlags: { flags: [multivariateFlag, basicFlag, falseFlag, arrayFlag] }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, }) }) it('should do getFeatureFlag', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) await expect(posthog.getFeatureFlag('feature-variant', '123', { groups: { org: '123' } })).resolves.toEqual( 'variant' ) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) it('should do isFeatureEnabled', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) await expect(posthog.isFeatureEnabled('feature-1', '123', { groups: { org: '123' } })).resolves.toEqual(true) await expect(posthog.isFeatureEnabled('feature-4', '123', { groups: { org: '123' } })).resolves.toEqual(undefined) expect(mockedFetch).toHaveBeenCalledTimes(2) }) it('captures feature flags when no personal API key is present', async () => { mockedFetch.mockClear() mockedFetch.mockClear() expect(mockedFetch).toHaveBeenCalledTimes(0) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', flushAt: 1, fetchRetryCount: 0, }) posthog.capture({ distinctId: 'distinct_id', event: 'node test event', sendFeatureFlags: true, }) jest.runOnlyPendingTimers() await waitForPromises() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST' }) ) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: 'distinct_id', event: 'node test event', properties: expect.objectContaining({ $active_feature_flags: ['feature-1', 'feature-2', 'feature-array', 'feature-variant'], '$feature/feature-1': true, '$feature/feature-2': true, '$feature/feature-array': true, '$feature/feature-variant': 'variant', $lib: 'posthog-node', $lib_version: '1.2.3', $geoip_disable: true, }), }) ) // no calls to `/local_evaluation` expect(mockedFetch).not.toHaveBeenCalledWith(...anyLocalEvalCall) expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) it('should use minimum featureFlagsPollingInterval of 100ms if set less to less than 100', async () => { posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, personalApiKey: 'TEST_PERSONAL_API_KEY', featureFlagsPollingInterval: 98, }) expect(posthog.options.featureFlagsPollingInterval).toEqual(MINIMUM_POLLING_INTERVAL) }) it('should use default featureFlagsPollingInterval of 30000ms if none provided', async () => { posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, personalApiKey: 'TEST_PERSONAL_API_KEY', }) expect(posthog.options.featureFlagsPollingInterval).toEqual(THIRTY_SECONDS) }) it('should throw an error when creating SDK if a project key is passed in as personalApiKey', async () => { expect(() => { posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0, personalApiKey: 'phc_abc123', featureFlagsPollingInterval: 100, }) }).toThrow(Error) }) it('captures feature flags with locally evaluated flags', async () => { mockedFetch.mockClear() mockedFetch.mockClear() expect(mockedFetch).toHaveBeenCalledTimes(0) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', flushAt: 1, fetchRetryCount: 0, personalApiKey: 'TEST_PERSONAL_API_KEY', }) jest.runOnlyPendingTimers() await waitForPromises() posthog.capture({ distinctId: 'distinct_id', event: 'node test event', }) expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // no decide call expect(mockedFetch).not.toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST' }) ) jest.runOnlyPendingTimers() await waitForPromises() expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: 'distinct_id', event: 'node test event', properties: expect.objectContaining({ $active_feature_flags: ['beta-feature-local', 'feature-array'], '$feature/beta-feature-local': 'third-variant', '$feature/feature-array': true, '$feature/false-flag': false, $lib: 'posthog-node', $lib_version: '1.2.3', $geoip_disable: true, }), }) ) expect( Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature-local') ).toBe(true) expect(Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature')).toBe( false ) await posthog.shutdown() }) it('doesnt add flag properties when locally evaluated flags are empty', async () => { mockedFetch.mockClear() expect(mockedFetch).toHaveBeenCalledTimes(0) mockedFetch.mockImplementation( apiImplementation({ decideFlags: { a: false, b: 'true' }, decideFlagPayloads: {}, localFlags: { flags: [] } }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', flushAt: 1, fetchRetryCount: 0, personalApiKey: 'TEST_PERSONAL_API_KEY', }) posthog.capture({ distinctId: 'distinct_id', event: 'node test event', }) jest.runOnlyPendingTimers() await waitForPromises() expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // no decide call expect(mockedFetch).not.toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST' }) ) jest.runOnlyPendingTimers() await waitForPromises() expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: 'distinct_id', event: 'node test event', properties: expect.objectContaining({ $lib: 'posthog-node', $lib_version: '1.2.3', $geoip_disable: true, }), }) ) expect( Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature-local') ).toBe(false) expect(Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature')).toBe( false ) }) it('captures feature flags with same geoip setting as capture', async () => { mockedFetch.mockClear() mockedFetch.mockClear() expect(mockedFetch).toHaveBeenCalledTimes(0) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', flushAt: 1, fetchRetryCount: 0, }) posthog.capture({ distinctId: 'distinct_id', event: 'node test event', sendFeatureFlags: true, disableGeoip: false, }) await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.not.stringContaining('geoip_disable') }) ) expect(getLastBatchEvents()?.[0].properties).toEqual({ $active_feature_flags: ['feature-1', 'feature-2', 'feature-array', 'feature-variant'], '$feature/feature-1': true, '$feature/feature-2': true, '$feature/feature-array': true, '$feature/disabled-flag': false, '$feature/feature-variant': 'variant', $lib: 'posthog-node', $lib_version: '1.2.3', }) // no calls to `/local_evaluation` expect(mockedFetch).not.toHaveBeenCalledWith(...anyLocalEvalCall) }) it('manages memory well when sending feature flags', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'decide-fallback-value' } }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', maxCacheSize: 10, fetchRetryCount: 0, flushAt: 1, }) expect(Object.keys(posthog.distinctIdHasSentFlagCalls).length).toEqual(0) for (let i = 0; i < 100; i++) { const distinctId = `some-distinct-id${i}` await posthog.getFeatureFlag('beta-feature', distinctId) await waitForPromises() jest.runOnlyPendingTimers() const batchEvents = getLastBatchEvents() expect(batchEvents).toMatchObject([ { distinct_id: distinctId, event: '$feature_flag_called', properties: expect.objectContaining({ $feature_flag: 'beta-feature', $feature_flag_response: true, $lib: 'posthog-node', $lib_version: '1.2.3', locally_evaluated: true, '$feature/beta-feature': true, }), }, ]) mockedFetch.mockClear() expect(Object.keys(posthog.distinctIdHasSentFlagCalls).length <= 10).toEqual(true) } }) it('$feature_flag_called is called appropriately when querying flags', async () => { mockedFetch.mockClear() const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [{ key: 'region', value: 'USA' }], rollout_percentage: 100, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'decide-flag': 'decide-value' } }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', maxCacheSize: 10, fetchRetryCount: 0, }) jest.runOnlyPendingTimers() expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', name: 'Aloha' }, }) ).toEqual(true) // TRICKY: There's now an extra step before events are queued, so need to wait for that to resolve jest.runOnlyPendingTimers() await waitForPromises() await posthog.flush() expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: 'some-distinct-id', event: '$feature_flag_called', properties: expect.objectContaining({ $feature_flag: 'beta-feature', $feature_flag_response: true, '$feature/beta-feature': true, $lib: 'posthog-node', $lib_version: '1.2.3', locally_evaluated: true, $geoip_disable: true, }), }) ) mockedFetch.mockClear() // # called again for same user, shouldn't call capture again expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', name: 'Aloha' }, }) ).toEqual(true) jest.runOnlyPendingTimers() await waitForPromises() await posthog.flush() expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) // # called for different user, should call capture again expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id2', { groups: { x: 'y' }, personProperties: { region: 'USA', name: 'Aloha' }, disableGeoip: false, }) ).toEqual(true) jest.runOnlyPendingTimers() await waitForPromises() await posthog.flush() expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: 'some-distinct-id2', event: '$feature_flag_called', }) ) expect(getLastBatchEvents()?.[0].properties).toEqual({ $feature_flag: 'beta-feature', $feature_flag_response: true, $lib: 'posthog-node', $lib_version: '1.2.3', locally_evaluated: true, '$feature/beta-feature': true, $groups: { x: 'y' }, }) mockedFetch.mockClear() // # called for different user, but send configuration is false, so should NOT call capture again expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id23', { personProperties: { region: 'USA', name: 'Aloha' }, sendFeatureFlagEvents: false, }) ).toEqual(true) jest.runOnlyPendingTimers() await waitForPromises() await posthog.flush() expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) // # called for different flag, falls back to decide, should call capture again expect( await posthog.getFeatureFlag('decide-flag', 'some-distinct-id2345', { groups: { organization: 'org1' }, personProperties: { region: 'USA', name: 'Aloha' }, }) ).toEqual('decide-value') jest.runOnlyPendingTimers() await waitForPromises() await posthog.flush() // one to decide, one to batch expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ distinct_id: 'some-distinct-id2345', event: '$feature_flag_called', properties: expect.objectContaining({ $feature_flag: 'decide-flag', $feature_flag_response: 'decide-value', $lib: 'posthog-node', $lib_version: '1.2.3', locally_evaluated: false, '$feature/decide-flag': 'decide-value', $groups: { organization: 'org1' }, }), }) ) mockedFetch.mockClear() expect( await posthog.isFeatureEnabled('decide-flag', 'some-distinct-id2345', { groups: { organization: 'org1' }, personProperties: { region: 'USA', name: 'Aloha' }, }) ).toEqual(true) jest.runOnlyPendingTimers() await waitForPromises() await posthog.flush() // call decide, but not batch expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) }) it('should do getFeatureFlagPayloads', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) await expect( posthog.getFeatureFlagPayload('feature-variant', '123', 'variant', { groups: { org: '123' } }) ).resolves.toEqual(2) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) it('should not double parse json with getFeatureFlagPayloads and local eval', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', flushAt: 1, fetchRetryCount: 0, personalApiKey: 'TEST_PERSONAL_API_KEY', }) mockedFetch.mockClear() expect(mockedFetch).toHaveBeenCalledTimes(0) await expect( posthog.getFeatureFlagPayload('feature-array', '123', true, { onlyEvaluateLocally: true }) ).resolves.toEqual([1]) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) mockedFetch.mockClear() await expect(posthog.getFeatureFlagPayload('feature-array', '123')).resolves.toEqual([1]) expect(mockedFetch).toHaveBeenCalledTimes(0) await expect(posthog.getFeatureFlagPayload('false-flag', '123', true)).resolves.toEqual(300) // Check no non-batch API calls were made const additionalNonBatchCalls = mockedFetch.mock.calls.filter((call) => !call[0].includes('/batch')) expect(additionalNonBatchCalls.length).toBe(0) }) it('should not double parse json with getFeatureFlagPayloads and server eval', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) await expect( posthog.getFeatureFlagPayload('feature-array', '123', undefined, { groups: { org: '123' } }) ).resolves.toEqual([1]) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) it('should do getFeatureFlagPayloads without matchValue', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) await expect( posthog.getFeatureFlagPayload('feature-variant', '123', undefined, { groups: { org: '123' } }) ).resolves.toEqual(2) expect(mockedFetch).toHaveBeenCalledTimes(1) }) it('should do getFeatureFlags with geoip disabled and enabled', async () => { expect(mockedFetch).toHaveBeenCalledTimes(0) await expect( posthog.getFeatureFlagPayload('feature-variant', '123', 'variant', { groups: { org: '123' } }) ).resolves.toEqual(2) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) mockedFetch.mockClear() await expect(posthog.isFeatureEnabled('feature-variant', '123', { disableGeoip: false })).resolves.toEqual(true) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.not.stringContaining('geoip_disable') }) ) }) it('should add default person & group properties for feature flags', async () => { await posthog.getFeatureFlag('random_key', 'some_id', { groups: { company: 'id:5', instance: 'app.posthog.com' }, personProperties: { x1: 'y1' }, groupProperties: { company: { x: 'y' } }, }) jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some_id', groups: { company: 'id:5', instance: 'app.posthog.com' }, person_properties: { distinct_id: 'some_id', x1: 'y1', }, group_properties: { company: { $group_key: 'id:5', x: 'y' }, instance: { $group_key: 'app.posthog.com' }, }, geoip_disable: true, flag_keys_to_evaluate: ['random_key'], }), }) ) mockedFetch.mockClear() await posthog.getFeatureFlag('random_key', 'some_id', { groups: { company: 'id:5', instance: 'app.posthog.com' }, personProperties: { distinct_id: 'override' }, groupProperties: { company: { $group_key: 'group_override' } }, }) jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some_id', groups: { company: 'id:5', instance: 'app.posthog.com' }, person_properties: { distinct_id: 'override', }, group_properties: { company: { $group_key: 'group_override' }, instance: { $group_key: 'app.posthog.com' }, }, geoip_disable: true, flag_keys_to_evaluate: ['random_key'], }), }) ) mockedFetch.mockClear() // test nones await posthog.getAllFlagsAndPayloads('some_id', { groups: undefined, personProperties: undefined, groupProperties: undefined, }) jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some_id', groups: {}, person_properties: { distinct_id: 'some_id', }, group_properties: {}, geoip_disable: true, }), }) ) mockedFetch.mockClear() await posthog.getAllFlags('some_id', { groups: { company: 'id:5' }, personProperties: undefined, groupProperties: undefined, }) jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some_id', groups: { company: 'id:5' }, person_properties: { distinct_id: 'some_id', }, group_properties: { company: { $group_key: 'id:5' } }, geoip_disable: true, }), }) ) mockedFetch.mockClear() await posthog.getFeatureFlagPayload('random_key', 'some_id', undefined) jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some_id', groups: {}, person_properties: { distinct_id: 'some_id', }, group_properties: {}, geoip_disable: true, flag_keys_to_evaluate: ['random_key'], }), }) ) mockedFetch.mockClear() await posthog.isFeatureEnabled('random_key', 'some_id') jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some_id', groups: {}, person_properties: { distinct_id: 'some_id', }, group_properties: {}, geoip_disable: true, flag_keys_to_evaluate: ['random_key'], }), }) ) }) it('should log error when decide response has errors', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) mockedFetch.mockImplementation( apiImplementation({ decideFlags: { 'feature-1': true }, decideFlagPayloads: {}, errorsWhileComputingFlags: true, }) ) await posthog.getFeatureFlag('feature-1', '123') expect(errorSpy).toHaveBeenCalledWith( '[FEATURE FLAGS] Error while computing feature flags, some flags may be missing or incorrect. Learn more at https://posthog.com/docs/feature-flags/best-practices' ) errorSpy.mockRestore() }) }) })