UNPKG

posthog-node

Version:
1,722 lines (1,614 loc) 115 kB
// import { PostHog, PostHogOptions } from '../' // Uncomment below line while developing to not compile code everytime import { PostHog as PostHog, PostHogOptions } from '../src/posthog-node' import { matchProperty, InconclusiveMatchError, relativeDateParseForFeatureFlagMatching } from '../src/feature-flags' import fetch from '../src/fetch' import { anyDecideCall, anyLocalEvalCall, apiImplementation } from './test-utils' import { waitForPromises } from 'posthog-core/test/test-utils/test-utils' jest.mock('../src/fetch') jest.spyOn(console, 'debug').mockImplementation() const mockedFetch = jest.mocked(fetch, true) const posthogImmediateResolveOptions: PostHogOptions = { fetchRetryCount: 0, } describe('local evaluation', () => { let posthog: PostHog jest.useFakeTimers() afterEach(async () => { // ensure clean shutdown & no test interdependencies await posthog.shutdown() }) it('evaluates person properties with undefined property values', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'person-flag', active: true, filters: { groups: [ { variant: null, properties: [ { key: 'latestBuildVersion', type: 'person', value: '.+', operator: 'regex', }, { key: 'latestBuildVersionMajor', type: 'person', value: '23', operator: 'gt', }, { key: 'latestBuildVersionMinor', type: 'person', value: '31', operator: 'gt', }, { key: 'latestBuildVersionPatch', type: 'person', value: '0', operator: 'gt', }, ], rollout_percentage: 100, }, ], }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('person-flag', 'some-distinct-id', { personProperties: { latestBuildVersion: undefined, latestBuildVersionMajor: undefined, latestBuildVersionMinor: undefined, latestBuildVersionPatch: undefined, } as unknown as Record<string, string>, }) ).toEqual(false) expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) }) it('evaluates person properties', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'person-flag', active: true, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, ], rollout_percentage: null, }, ], }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('person-flag', 'some-distinct-id', { personProperties: { region: 'USA' } }) ).toEqual(true) expect( await posthog.getFeatureFlag('person-flag', 'some-distinct-id', { personProperties: { region: 'Canada' } }) ).toEqual(false) expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) }) it('evaluates group properties', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'group-flag', active: true, filters: { aggregation_group_type_index: 0, groups: [ { properties: [ { group_type_index: 0, key: 'name', operator: 'exact', value: ['Project Name 1'], type: 'group', }, ], rollout_percentage: 35, }, ], }, }, ], group_type_mapping: { '0': 'company', '1': 'project' }, } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # groups not passed in, hence false expect( await posthog.getFeatureFlag('group-flag', 'some-distinct-id', { groupProperties: { company: { name: 'Project Name 1' } }, }) ).toEqual(false) expect( await posthog.getFeatureFlag('group-flag', 'some-distinct-2', { groupProperties: { company: { name: 'Project Name 2' } }, }) ).toEqual(false) // # this is good expect( await posthog.getFeatureFlag('group-flag', 'some-distinct-2', { groups: { company: 'amazon_without_rollout' }, groupProperties: { company: { name: 'Project Name 1' } }, }) ).toEqual(true) // # rollout % not met expect( await posthog.getFeatureFlag('group-flag', 'some-distinct-2', { groups: { company: 'amazon' }, groupProperties: { company: { name: 'Project Name 1' } }, }) ).toEqual(false) // # property mismatch expect( await posthog.getFeatureFlag('group-flag', 'some-distinct-2', { groups: { company: 'amazon_without_rollout' }, groupProperties: { company: { name: 'Project Name 2' } }, }) ).toEqual(false) expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // decide not called expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('evaluates group properties and falls back to decide when group_type_mappings not present', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'group-flag', active: true, filters: { aggregation_group_type_index: 0, groups: [ { properties: [ { group_type_index: 0, key: 'name', operator: 'exact', value: ['Project Name 1'], type: 'group', }, ], rollout_percentage: 35, }, ], }, }, ], // "group_type_mapping": {"0": "company", "1": "project"} } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'group-flag': 'decide-fallback-value' } }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # group_type_mappings not present, so fallback to `/decide` expect( await posthog.getFeatureFlag('group-flag', 'some-distinct-2', { groupProperties: { company: { name: 'Project Name 1' }, }, }) ).toEqual('decide-fallback-value') }) it('evaluates flag with complex definition', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'complex-flag', active: true, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, { key: 'name', operator: 'exact', value: ['Aloha'], type: 'person', }, ], rollout_percentage: undefined, }, { properties: [ { key: 'email', operator: 'exact', value: ['a@b.com', 'b@c.com'], type: 'person', }, ], rollout_percentage: 30, }, { properties: [ { key: 'doesnt_matter', operator: 'exact', value: ['1', '2'], type: 'person', }, ], rollout_percentage: 0, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'complex-flag': 'decide-fallback-value' } }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { region: 'USA', name: 'Aloha' }, }) ).toEqual(true) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # this distinctIDs hash is < rollout % expect( await posthog.getFeatureFlag('complex-flag', 'some-distinct-id_within_rollout?', { personProperties: { region: 'USA', email: 'a@b.com' }, }) ).toEqual(true) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # will fall back on `/decide`, as all properties present for second group, but that group resolves to false expect( await posthog.getFeatureFlag('complex-flag', 'some-distinct-id_outside_rollout?', { personProperties: { region: 'USA', email: 'a@b.com' }, }) ).toEqual('decide-fallback-value') expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some-distinct-id_outside_rollout?', groups: {}, person_properties: { distinct_id: 'some-distinct-id_outside_rollout?', region: 'USA', email: 'a@b.com', }, group_properties: {}, geoip_disable: true, flag_keys_to_evaluate: ['complex-flag'], }), }) ) mockedFetch.mockClear() // # same as above expect( await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { doesnt_matter: '1' } }) ).toEqual('decide-fallback-value') expect(mockedFetch).toHaveBeenCalledWith( 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: 'some-distinct-id', groups: {}, person_properties: { distinct_id: 'some-distinct-id', doesnt_matter: '1' }, group_properties: {}, geoip_disable: true, flag_keys_to_evaluate: ['complex-flag'], }), }) ) mockedFetch.mockClear() expect( await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { region: 'USA' } }) ).toEqual('decide-fallback-value') expect(mockedFetch).toHaveBeenCalledTimes(1) // TODO: Check this mockedFetch.mockClear() // # won't need to fallback when all values are present, and resolves to False expect( await posthog.getFeatureFlag('complex-flag', 'some-distinct-id_outside_rollout?', { personProperties: { region: 'USA', email: 'a@b.com', name: 'X', doesnt_matter: '1' }, }) ).toEqual(false) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('falls back to decide', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [{ key: 'id', value: 98, operator: undefined, type: 'cohort' }], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'beta-feature2', active: true, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, ], rollout_percentage: 100, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'alakazam', 'beta-feature2': 'alakazam2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # beta-feature fallbacks to decide because property type is unknown expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id')).toEqual('alakazam') expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) mockedFetch.mockClear() // # beta-feature2 fallbacks to decide because region property not given with call expect(await posthog.getFeatureFlag('beta-feature2', 'some-distinct-id')).toEqual('alakazam2') expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) }) it('dont fall back to decide when local evaluation is set', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [{ key: 'id', value: 98, operator: undefined, type: 'cohort' }], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'beta-feature2', active: true, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, ], rollout_percentage: 100, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'alakazam', 'beta-feature2': 'alakazam2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # beta-feature should fallback to decide because property type is unknown // # but doesn't because only_evaluate_locally is true expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual( undefined ) expect(await posthog.isFeatureEnabled('beta-feature', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual( undefined ) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # beta-feature2 should fallback to decide because region property not given with call // # but doesn't because only_evaluate_locally is true expect(await posthog.getFeatureFlag('beta-feature2', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual( undefined ) expect(await posthog.isFeatureEnabled('beta-feature2', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual( undefined ) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it("doesn't return undefined when flag is evaluated successfully", async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags, decideFlags: {} })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # beta-feature resolves to False expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id')).toEqual(false) expect(await posthog.isFeatureEnabled('beta-feature', 'some-distinct-id')).toEqual(false) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # beta-feature2 falls back to decide, and whatever decide returns is the value expect(await posthog.getFeatureFlag('beta-feature2', 'some-distinct-id')).toEqual(undefined) expect(await posthog.isFeatureEnabled('beta-feature2', 'some-distinct-id')).toEqual(undefined) expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) }) it('experience continuity flags are not evaluated locally', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, ensure_experience_continuity: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], }, }, ], } 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', ...posthogImmediateResolveOptions, }) // # beta-feature2 falls back to decide, which on error returns default expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id')).toEqual('decide-fallback-value') expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) }) it('get all flags with fallback', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], }, }, { id: 3, name: 'Beta Feature', key: 'beta-feature2', active: true, filters: { groups: [ { properties: [{ key: 'country', value: 'US' }], rollout_percentage: 0, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # beta-feature value overridden by /decide expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2', 'disabled-feature': false, }) expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) mockedFetch.mockClear() }) it('get all payloads with fallback', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], payloads: { true: 'some-payload', }, }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], payloads: { true: 'another-payload', }, }, }, { id: 3, name: 'Beta Feature', key: 'beta-feature2', active: true, filters: { groups: [ { properties: [{ key: 'country', value: 'US' }], rollout_percentage: 0, }, ], payloads: { true: 'payload-3', }, }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, decideFlagPayloads: { 'beta-feature': 100, 'beta-feature2': 300 }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # beta-feature value overridden by /decide expect((await posthog.getAllFlagsAndPayloads('distinct-id')).featureFlagPayloads).toEqual({ 'beta-feature': 100, 'beta-feature2': 300, }) expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) mockedFetch.mockClear() }) it('get all flags with fallback but only_locally_evaluated set', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], }, }, { id: 3, name: 'Beta Feature', key: 'beta-feature2', active: true, filters: { groups: [ { properties: [{ key: 'country', value: 'US' }], rollout_percentage: 0, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) // # beta-feature2 has no value expect(await posthog.getAllFlags('distinct-id', { onlyEvaluateLocally: true })).toEqual({ 'beta-feature': true, 'disabled-feature': false, }) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('get all payloads with fallback but only_evaluate_locally set', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], payloads: { true: 'some-payload', }, }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], payloads: { true: 'another-payload', }, }, }, { id: 3, name: 'Beta Feature', key: 'beta-feature2', active: true, filters: { groups: [ { properties: [{ key: 'country', value: 'US' }], rollout_percentage: 0, }, ], payloads: { true: 'payload-3', }, }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, decideFlagPayloads: { 'beta-feature': 100, 'beta-feature2': 300 }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( (await posthog.getAllFlagsAndPayloads('distinct-id', { onlyEvaluateLocally: true })).featureFlagPayloads ).toEqual({ 'beta-feature': 'some-payload', }) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('get all flags with fallback, with no local flags', async () => { const flags = { flags: [], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2', }) expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) mockedFetch.mockClear() }) it('get all payloads with fallback, with no local payloads', async () => { const flags = { flags: [], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, decideFlagPayloads: { 'beta-feature': 100, 'beta-feature2': 300 }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect((await posthog.getAllFlagsAndPayloads('distinct-id')).featureFlagPayloads).toEqual({ 'beta-feature': 100, 'beta-feature2': 300, }) expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) mockedFetch.mockClear() }) it('get all flags with no fallback', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': true, 'disabled-feature': false }) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('get all payloads with no fallback', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], payloads: { true: 'new', }, }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], payloads: { true: 'some-payload', }, }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect((await posthog.getAllFlagsAndPayloads('distinct-id')).featureFlagPayloads).toEqual({ 'beta-feature': 'new' }) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('computes inactive flags locally as well', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 0, }, ], }, }, ], } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' }, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': true, 'disabled-feature': false }) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # Now, after a poll interval, flag 1 is inactive, and flag 2 rollout is set to 100%. const flags2 = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: false, rollout_percentage: 100, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, { id: 2, name: 'Beta Feature', key: 'disabled-feature', active: true, filters: { groups: [ { properties: [], rollout_percentage: 100, }, ], }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags2 })) // # force reload to simulate poll interval await posthog.reloadFeatureFlags() expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': false, 'disabled-feature': true }) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('computes complex cohorts locally', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, { key: 'id', value: 98, type: 'cohort' }, ], rollout_percentage: 100, }, ], }, }, ], cohorts: { '98': { type: 'OR', values: [ { key: 'id', value: 1, type: 'cohort' }, { key: 'nation', operator: 'exact', value: ['UK'], type: 'person', }, ], }, '1': { type: 'AND', values: [{ key: 'other', operator: 'exact', value: ['thing'], type: 'person' }], }, }, } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: {}, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'UK' } }) ).toEqual(false) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # even though 'other' property is not present, the cohort should still match since it's an OR condition expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', nation: 'UK' }, }) ).toEqual(true) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # even though 'other' property is not present, the cohort should still match since it's an OR condition expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', other: 'thing' }, }) ).toEqual(true) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('computes complex cohorts with negation locally', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, rollout_percentage: 100, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, { key: 'id', value: 98, type: 'cohort' }, ], rollout_percentage: 100, }, ], }, }, ], cohorts: { '98': { type: 'OR', values: [ { key: 'id', value: 1, type: 'cohort' }, { key: 'nation', operator: 'exact', value: ['UK'], type: 'person', }, ], }, '1': { type: 'AND', values: [{ key: 'other', operator: 'exact', value: ['thing'], type: 'person', negation: true }], }, }, } mockedFetch.mockImplementation( apiImplementation({ localFlags: flags, decideFlags: {}, }) ) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'UK' } }) ).toEqual(false) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # even though 'other' property is not present, the cohort should still match since it's an OR condition expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', nation: 'UK' }, }) ).toEqual(true) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) // # since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', other: 'thing' }, }) ).toEqual(undefined) expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall) mockedFetch.mockClear() expect( await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'USA', other: 'thing2' }, }) ).toEqual(true) expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('gets feature flag with variant overrides', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [ { key: 'email', operator: 'exact', value: 'test@posthog.com', type: 'person', }, ], rollout_percentage: 100, variant: 'second-variant', }, { rollout_percentage: 50, variant: 'first-variant', }, ], 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, }, ], }, }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } }) ).toEqual('second-variant') expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('first-variant') expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // decide not called expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('gets feature flag with clashing variant overrides', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [ { key: 'email', operator: 'exact', value: 'test@posthog.com', type: 'person', }, ], rollout_percentage: 100, variant: 'second-variant', }, // # since second-variant comes first in the list, it will be the one that gets picked { properties: [ { key: 'email', operator: 'exact', value: 'test@posthog.com', type: 'person', }, ], rollout_percentage: 100, variant: 'first-variant', }, { rollout_percentage: 50, variant: 'first-variant', }, ], 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, }, ], }, }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } }) ).toEqual('second-variant') expect( await posthog.getFeatureFlag('beta-feature', 'example_id', { personProperties: { email: 'test@posthog.com' } }) ).toEqual('second-variant') expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('first-variant') expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // decide not called expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('gets feature flag with invalid variant overrides', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [ { key: 'email', operator: 'exact', value: 'test@posthog.com', type: 'person', }, ], rollout_percentage: 100, variant: 'second???', }, { rollout_percentage: 50, variant: 'first???', }, ], 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, }, ], }, }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } }) ).toEqual('third-variant') expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('second-variant') expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // decide not called expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('gets feature flag with multiple variant overrides', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { rollout_percentage: 100, // # The override applies even if the first condition matches all and gives everyone their default group }, { properties: [ { key: 'email', operator: 'exact', value: 'test@posthog.com', type: 'person', }, ], rollout_percentage: 100, variant: 'second-variant', }, { rollout_percentage: 50, variant: 'third-variant', }, ], 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, }, ], }, }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } }) ).toEqual('second-variant') expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('third-variant') expect(await posthog.getFeatureFlag('beta-feature', 'another_id')).toEqual('second-variant') expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // decide not called expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('get feature flag payload based on boolean flag', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'person-flag', active: true, filters: { groups: [ { properties: [ { key: 'region', operator: 'exact', value: ['USA'], type: 'person', }, ], rollout_percentage: null, }, ], payloads: { true: { log: 'all', }, }, }, }, ], } mockedFetch.mockImplementation(apiImplementation({ localFlags: flags })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', personalApiKey: 'TEST_PERSONAL_API_KEY', ...posthogImmediateResolveOptions, }) expect( await posthog.getFeatureFlagPayload('person-flag', 'some-distinct-id', true, { personProperties: { region: 'USA' }, }) ).toEqual({ log: 'all', }) expect( await posthog.getFeatureFlagPayload('person-flag', 'some-distinct-id', undefined, { personProperties: { region: 'USA' }, }) ).toEqual({ log: 'all', }) expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // decide not called expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall) }) it('get feature flag payload on a multivariate', async () => { const flags = { flags: [ { id: 1, name: 'Beta Feature', key: 'beta-feature', active: true, filters: { groups: [ { properties: [ { key: 'email', operator: 'exact', value: 'test@posthog.com', type: 'person', }, ], rollout_percentage: 100, variant: 'second-variant', }, { rollout_percentage: 50, variant: 'first-variant', }, ], multivariate: { variants: [ { key: 'first-variant', name: 'First Variant', rollout_percentage: 50, }, {