├── .github
└── workflows
│ ├── publish.yml
│ └── pull_request.yaml
├── .gitignore
├── .gitmodules
├── .husky
└── pre-commit
├── .npmignore
├── .prettierignore
├── .prettierrc.cjs
├── CONTRIBUTING.md
├── LICENCE
├── README.md
├── flagsmith-engine
├── environments
│ ├── models.ts
│ └── util.ts
├── features
│ ├── constants.ts
│ ├── models.ts
│ └── util.ts
├── identities
│ ├── models.ts
│ ├── traits
│ │ └── models.ts
│ └── util.ts
├── index.ts
├── organisations
│ ├── models.ts
│ └── util.ts
├── projects
│ ├── models.ts
│ └── util.ts
├── segments
│ ├── constants.ts
│ ├── evaluators.ts
│ ├── models.ts
│ └── util.ts
└── utils
│ ├── collections.ts
│ ├── errors.ts
│ ├── hashing
│ └── index.ts
│ └── index.ts
├── index.ts
├── package-lock.json
├── package.json
├── sdk
├── analytics.ts
├── errors.ts
├── index.ts
├── models.ts
├── offline_handlers.ts
├── polling_manager.ts
├── types.ts
└── utils.ts
├── tests
├── engine
│ ├── e2e
│ │ └── engine.test.ts
│ └── unit
│ │ ├── engine.test.ts
│ │ ├── environments
│ │ ├── builder.test.ts
│ │ └── models.test.ts
│ │ ├── features
│ │ └── models.test.ts
│ │ ├── identities
│ │ ├── identities_builders.test.ts
│ │ └── identities_models.test.ts
│ │ ├── organization
│ │ └── models.test.ts
│ │ ├── segments
│ │ ├── segment_evaluators.test.ts
│ │ ├── segments_model.test.ts
│ │ └── util.ts
│ │ ├── utils.ts
│ │ └── utils
│ │ └── utils.test.ts
└── sdk
│ ├── analytics.test.ts
│ ├── data
│ ├── environment.json
│ ├── flags.json
│ ├── identities.json
│ ├── identity-with-transient-traits.json
│ ├── offline-environment.json
│ └── transient-identity.json
│ ├── flagsmith-cache.test.ts
│ ├── flagsmith-environment-flags.test.ts
│ ├── flagsmith-identity-flags.test.ts
│ ├── flagsmith.test.ts
│ ├── offline-handlers.test.ts
│ ├── polling.test.ts
│ └── utils.ts
├── tsconfig.cjs.json
├── tsconfig.esm.json
├── tsconfig.json
└── vitest.config.ts
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish NPM Package
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | package:
10 | runs-on: ubuntu-latest
11 | name: Publish NPM Package
12 |
13 | steps:
14 | - name: Cloning repo
15 | uses: actions/checkout@v3
16 |
17 | - uses: actions/setup-node@v4
18 | with:
19 | node-version: '18.x'
20 | registry-url: 'https://registry.npmjs.org'
21 |
22 | - run: npm ci
23 | - run: npm run deploy
24 | env:
25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
26 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yaml:
--------------------------------------------------------------------------------
1 | name: Unit/Integration Tests
2 |
3 | on:
4 | pull_request:
5 | types:
6 | - opened
7 | - synchronize
8 | - reopened
9 | - ready_for_review
10 | push:
11 | branches:
12 | - main
13 | jobs:
14 | build-and-test:
15 | strategy:
16 | matrix:
17 | node-version: [18.x, 20.x, 22.x]
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v4
21 | with:
22 | submodules: true
23 | - uses: actions/setup-node@v4
24 | with:
25 | node-version: "${{ matrix.node-version }}"
26 | - name: cache node modules
27 | uses: actions/cache@v4
28 | with:
29 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS
30 | key: npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
31 | restore-keys: |
32 | npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
33 | npm-
34 | - run: npm ci
35 | - run: npm test
36 | env:
37 | CI: true
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/*
2 | .vscode/settings.json
3 | .vscode/tasks.json
4 | .vscode/launch.json
5 | .vscode/extensions.json
6 |
7 | .idea/*
8 |
9 | node_modules/
10 | build/
11 | coverage/
12 |
13 | .tool-versions
14 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "tests/engine/engine-tests/engine-test-data"]
2 | path = tests/engine/engine-tests/engine-test-data
3 | url = git@github.com:Flagsmith/engine-test-data.git
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npm run lint
5 | git add ./flagsmith-engine ./sdk ./tests ./index.ts ./.github
6 | npm run test
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | ./.idea
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.json
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | bracketSpacing: true,
3 | printWidth: 100,
4 | singleQuote: true,
5 | tabWidth: 4,
6 | trailingComma: 'none',
7 | useTabs: false,
8 | arrowParens: 'avoid'
9 | };
10 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We're always looking to improve this project, open source contribution is encouraged so long as they adhere to our guidelines.
4 |
5 | # Pull Requests
6 |
7 | The Solid State team will be monitoring for pull requests. When we get one, a member of team will test the work against our internal uses and sign off on the changes. From here, we'll either merge the pull request or provide feedback suggesting the next steps.
8 |
9 | **A couple things to keep in mind:**
10 |
11 | - If you've changed APIs, update the documentation.
12 | - Keep the code style (indents, wrapping) consistent.
13 | - If your PR involves a lot of commits, squash them using `git rebase -i` as this makes it easier for us to review.
14 | - Keep lines under 80 characters.
15 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | Copyright 2022 Bullet Train Ltd. A UK company.
2 |
3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4 |
5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6 |
7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8 |
9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10 |
11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Flagsmith NodeJS Client
4 |
5 | [](https://badge.fury.io/js/flagsmith-nodejs)
6 | [](https://www.jsdelivr.com/package/npm/flagsmith-nodejs)
7 |
8 | The SDK clients for NodeJS [https://www.flagsmith.com/](https://www.flagsmith.com/). Flagsmith allows you to manage feature flags and remote config across multiple projects, environments and organisations.
9 |
10 | ## Adding to your project
11 |
12 | For full documentation visit [https://docs.flagsmith.com/clients/server-side](https://docs.flagsmith.com/clients/server-side).
13 |
14 | ## Contributing
15 |
16 | Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests
17 |
18 | ## Getting Help
19 |
20 | If you encounter a bug or feature request we would like to hear about it. Before you submit an issue please search existing issues in order to prevent duplicates.
21 |
22 | ## Testing
23 |
24 | To run the local tests you need to run following command beforehand:
25 |
26 | ```bash
27 | git submodule add git@github.com:Flagsmith/engine-test-data.git tests/engine/engine-tests/engine-test-data/
28 | ```
29 |
30 | ## Get in touch
31 |
32 | If you have any questions about our projects you can email support@flagsmith.com.
33 |
34 | ## Useful links
35 |
36 | [Website](https://www.flagsmith.com/)
37 |
38 | [Documentation](https://docs.flagsmith.com/)
39 |
--------------------------------------------------------------------------------
/flagsmith-engine/environments/models.ts:
--------------------------------------------------------------------------------
1 | import { FeatureStateModel } from '../features/models.js';
2 | import { IdentityModel } from '../identities/models.js';
3 | import { ProjectModel } from '../projects/models.js';
4 |
5 | export class EnvironmentAPIKeyModel {
6 | id: number;
7 | key: string;
8 | createdAt: number;
9 | name: string;
10 | clientApiKey: string;
11 | expiresAt?: number;
12 | active = true;
13 |
14 | constructor(
15 | id: number,
16 | key: string,
17 | createdAt: number,
18 | name: string,
19 | clientApiKey: string,
20 | expiresAt?: number
21 | ) {
22 | this.id = id;
23 | this.key = key;
24 | this.createdAt = createdAt;
25 | this.name = name;
26 | this.clientApiKey = clientApiKey;
27 | this.expiresAt = expiresAt;
28 | }
29 |
30 | isValid() {
31 | return !!this.active && (!this.expiresAt || this.expiresAt > Date.now());
32 | }
33 | }
34 |
35 | export class EnvironmentModel {
36 | id: number;
37 | apiKey: string;
38 | project: ProjectModel;
39 | featureStates: FeatureStateModel[] = [];
40 | identityOverrides: IdentityModel[] = [];
41 |
42 | constructor(id: number, apiKey: string, project: ProjectModel) {
43 | this.id = id;
44 | this.apiKey = apiKey;
45 | this.project = project;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/flagsmith-engine/environments/util.ts:
--------------------------------------------------------------------------------
1 | import { buildFeatureStateModel } from '../features/util.js';
2 | import { buildIdentityModel } from '../identities/util.js';
3 | import { buildProjectModel } from '../projects/util.js';
4 | import { EnvironmentAPIKeyModel, EnvironmentModel } from './models.js';
5 |
6 | export function buildEnvironmentModel(environmentJSON: any) {
7 | const project = buildProjectModel(environmentJSON.project);
8 | const featureStates = environmentJSON.feature_states.map((fs: any) =>
9 | buildFeatureStateModel(fs)
10 | );
11 | const environmentModel = new EnvironmentModel(
12 | environmentJSON.id,
13 | environmentJSON.api_key,
14 | project
15 | );
16 | environmentModel.featureStates = featureStates;
17 | if (!!environmentJSON.identity_overrides) {
18 | environmentModel.identityOverrides = environmentJSON.identity_overrides.map((identityData: any) =>
19 | buildIdentityModel(identityData)
20 | );
21 | }
22 | return environmentModel;
23 | }
24 |
25 | export function buildEnvironmentAPIKeyModel(apiKeyJSON: any): EnvironmentAPIKeyModel {
26 | const model = new EnvironmentAPIKeyModel(
27 | apiKeyJSON.id,
28 | apiKeyJSON.key,
29 | Date.parse(apiKeyJSON.created_at),
30 | apiKeyJSON.name,
31 | apiKeyJSON.client_api_key
32 | );
33 |
34 | return model;
35 | }
36 |
--------------------------------------------------------------------------------
/flagsmith-engine/features/constants.ts:
--------------------------------------------------------------------------------
1 | export const CONSTANTS = {
2 | STANDARD: 'STANDARD',
3 | MULTIVARIATE: 'MULTIVARIATE'
4 | };
5 |
--------------------------------------------------------------------------------
/flagsmith-engine/features/models.ts:
--------------------------------------------------------------------------------
1 | import { randomUUID as uuidv4 } from "node:crypto";
2 | import { getHashedPercentateForObjIds } from '../utils/hashing/index.js';
3 |
4 | export class FeatureModel {
5 | id: number;
6 | name: string;
7 | type: string;
8 |
9 | constructor(id: number, name: string, type: string) {
10 | this.id = id;
11 | this.name = name;
12 | this.type = type;
13 | }
14 |
15 | eq(other: FeatureModel) {
16 | return !!other && this.id === other.id;
17 | }
18 | }
19 |
20 | export class MultivariateFeatureOptionModel {
21 | value: any;
22 | id: number | undefined;
23 |
24 | constructor(value: any, id?: number) {
25 | this.value = value;
26 | this.id = id;
27 | }
28 | }
29 |
30 | export class MultivariateFeatureStateValueModel {
31 | multivariateFeatureOption: MultivariateFeatureOptionModel;
32 | percentageAllocation: number;
33 | id: number;
34 | mvFsValueUuid: string = uuidv4();
35 |
36 | constructor(
37 | multivariate_feature_option: MultivariateFeatureOptionModel,
38 | percentage_allocation: number,
39 | id: number,
40 | mvFsValueUuid?: string
41 | ) {
42 | this.id = id;
43 | this.percentageAllocation = percentage_allocation;
44 | this.multivariateFeatureOption = multivariate_feature_option;
45 | this.mvFsValueUuid = mvFsValueUuid || this.mvFsValueUuid;
46 | }
47 | }
48 |
49 | export class FeatureStateModel {
50 | feature: FeatureModel;
51 | enabled: boolean;
52 | djangoID: number;
53 | featurestateUUID: string = uuidv4();
54 | featureSegment?: FeatureSegment;
55 | private value: any;
56 | multivariateFeatureStateValues: MultivariateFeatureStateValueModel[] = [];
57 |
58 | constructor(
59 | feature: FeatureModel,
60 | enabled: boolean,
61 | djangoID: number,
62 | value?: any,
63 | featurestateUuid: string = uuidv4()
64 | ) {
65 | this.feature = feature;
66 | this.enabled = enabled;
67 | this.djangoID = djangoID;
68 | this.value = value;
69 | this.featurestateUUID = featurestateUuid;
70 | }
71 |
72 | setValue(value: any) {
73 | this.value = value;
74 | }
75 |
76 | getValue(identityId?: number | string) {
77 | if (!!identityId && this.multivariateFeatureStateValues.length > 0) {
78 | return this.getMultivariateValue(identityId);
79 | }
80 | return this.value;
81 | }
82 |
83 | /*
84 | Returns `True` if `this` is higher segment priority than `other`
85 | (i.e. has lower value for featureSegment.priority)
86 | NOTE:
87 | A segment will be considered higher priority only if:
88 | 1. `other` does not have a feature segment(i.e: it is an environment feature state or it's a
89 | feature state with feature segment but from an old document that does not have `featureSegment.priority`)
90 | but `this` does.
91 | 2. `other` have a feature segment with high priority
92 | */
93 | isHigherSegmentPriority(other: FeatureStateModel): boolean {
94 | if (!other.featureSegment || !this.featureSegment) {
95 | return !!this.featureSegment && !other.featureSegment;
96 | }
97 | return this.featureSegment.priority < other.featureSegment.priority;
98 | }
99 |
100 | getMultivariateValue(identityID: number | string) {
101 | let percentageValue: number | undefined;
102 | let startPercentage = 0;
103 | const sortedF = this.multivariateFeatureStateValues.sort((a, b) => {
104 | return a.id - b.id;
105 | });
106 | for (const myValue of sortedF) {
107 | switch (myValue.percentageAllocation) {
108 | case 0:
109 | continue;
110 | case 100:
111 | return myValue.multivariateFeatureOption.value;
112 | default:
113 | if (percentageValue === undefined) {
114 | percentageValue = getHashedPercentateForObjIds([
115 | this.djangoID || this.featurestateUUID,
116 | identityID
117 | ]);
118 | }
119 | }
120 | const limit = myValue.percentageAllocation + startPercentage;
121 | if (startPercentage <= percentageValue && percentageValue < limit) {
122 | return myValue.multivariateFeatureOption.value;
123 | }
124 | startPercentage = limit;
125 | }
126 | return this.value;
127 | }
128 | }
129 |
130 | export class FeatureSegment {
131 | priority: number;
132 |
133 | constructor(priority: number) {
134 | this.priority = priority;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/flagsmith-engine/features/util.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FeatureModel,
3 | FeatureSegment,
4 | FeatureStateModel,
5 | MultivariateFeatureOptionModel,
6 | MultivariateFeatureStateValueModel
7 | } from './models.js';
8 |
9 | export function buildFeatureModel(featuresModelJSON: any): FeatureModel {
10 | return new FeatureModel(featuresModelJSON.id, featuresModelJSON.name, featuresModelJSON.type);
11 | }
12 |
13 | export function buildFeatureStateModel(featuresStateModelJSON: any): FeatureStateModel {
14 | const featureStateModel = new FeatureStateModel(
15 | buildFeatureModel(featuresStateModelJSON.feature),
16 | featuresStateModelJSON.enabled,
17 | featuresStateModelJSON.django_id,
18 | featuresStateModelJSON.feature_state_value,
19 | featuresStateModelJSON.featurestate_uuid
20 | );
21 |
22 | featureStateModel.featureSegment = featuresStateModelJSON.feature_segment ?
23 | buildFeatureSegment(featuresStateModelJSON.feature_segment) :
24 | undefined;
25 |
26 | const multivariateFeatureStateValues = featuresStateModelJSON.multivariate_feature_state_values
27 | ? featuresStateModelJSON.multivariate_feature_state_values.map((fsv: any) => {
28 | const featureOption = new MultivariateFeatureOptionModel(
29 | fsv.multivariate_feature_option.value,
30 | fsv.multivariate_feature_option.id
31 | );
32 | return new MultivariateFeatureStateValueModel(
33 | featureOption,
34 | fsv.percentage_allocation,
35 | fsv.id,
36 | fsv.mv_fs_value_uuid
37 | );
38 | })
39 | : [];
40 |
41 | featureStateModel.multivariateFeatureStateValues = multivariateFeatureStateValues;
42 |
43 | return featureStateModel;
44 | }
45 |
46 | export function buildFeatureSegment(featureSegmentJSON: any): FeatureSegment {
47 | return new FeatureSegment(featureSegmentJSON.priority);
48 | }
49 |
--------------------------------------------------------------------------------
/flagsmith-engine/identities/models.ts:
--------------------------------------------------------------------------------
1 | import { IdentityFeaturesList } from '../utils/collections.js';
2 | import { TraitModel } from './traits/models.js';
3 |
4 | import { randomUUID as uuidv4 } from 'node:crypto';
5 |
6 | export class IdentityModel {
7 | identifier: string;
8 | environmentApiKey: string;
9 | createdDate?: number;
10 | identityFeatures: IdentityFeaturesList;
11 | identityTraits: TraitModel[];
12 | identityUuid: string;
13 | djangoID: number | undefined;
14 |
15 | constructor(
16 | created_date: string,
17 | identityTraits: TraitModel[],
18 | identityFeatures: IdentityFeaturesList,
19 | environmentApiKey: string,
20 | identifier: string,
21 | identityUuid?: string,
22 | djangoID?: number,
23 | ) {
24 | this.identityUuid = identityUuid || uuidv4();
25 | this.createdDate = Date.parse(created_date) || Date.now();
26 | this.identityTraits = identityTraits;
27 | this.identityFeatures = new IdentityFeaturesList(...identityFeatures);
28 | this.environmentApiKey = environmentApiKey;
29 | this.identifier = identifier;
30 | this.djangoID = djangoID;
31 | }
32 |
33 | get compositeKey() {
34 | return IdentityModel.generateCompositeKey(this.environmentApiKey, this.identifier);
35 | }
36 |
37 | static generateCompositeKey(env_key: string, identifier: string) {
38 | return `${env_key}_${identifier}`;
39 | }
40 |
41 | updateTraits(traits: TraitModel[]) {
42 | const existingTraits: Map = new Map();
43 | for (const trait of this.identityTraits) {
44 | existingTraits.set(trait.traitKey, trait);
45 | }
46 |
47 | for (const trait of traits) {
48 | if (!!trait.traitValue) {
49 | existingTraits.set(trait.traitKey, trait);
50 | } else {
51 | existingTraits.delete(trait.traitKey);
52 | }
53 | }
54 |
55 | this.identityTraits = [];
56 |
57 | for (const [k, v] of existingTraits.entries()) {
58 | this.identityTraits.push(v);
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/flagsmith-engine/identities/traits/models.ts:
--------------------------------------------------------------------------------
1 | export class TraitModel {
2 | traitKey: string;
3 | traitValue: any;
4 | constructor(key: string, value: any) {
5 | this.traitKey = key;
6 | this.traitValue = value;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/flagsmith-engine/identities/util.ts:
--------------------------------------------------------------------------------
1 | import { buildFeatureStateModel } from '../features/util.js';
2 | import { IdentityFeaturesList } from '../utils/collections.js';
3 | import { IdentityModel } from './models.js';
4 | import { TraitModel } from './traits/models.js';
5 |
6 | export function buildTraitModel(traitJSON: any): TraitModel {
7 | return new TraitModel(traitJSON.trait_key, traitJSON.trait_value);
8 | }
9 |
10 | export function buildIdentityModel(identityJSON: any): IdentityModel {
11 | const featureList = identityJSON.identity_features
12 | ? new IdentityFeaturesList(
13 | ...identityJSON.identity_features.map((f: any) => buildFeatureStateModel(f))
14 | )
15 | : [];
16 |
17 | const model = new IdentityModel(
18 | identityJSON.created_date,
19 | identityJSON.identity_traits
20 | ? identityJSON.identity_traits.map((trait: any) => buildTraitModel(trait))
21 | : [],
22 | featureList,
23 | identityJSON.environment_api_key,
24 | identityJSON.identifier,
25 | identityJSON.identity_uuid
26 | );
27 |
28 | model.djangoID = identityJSON.django_id;
29 | return model;
30 | }
31 |
--------------------------------------------------------------------------------
/flagsmith-engine/index.ts:
--------------------------------------------------------------------------------
1 | import { EnvironmentModel } from './environments/models.js';
2 | import { FeatureStateModel } from './features/models.js';
3 | import { IdentityModel } from './identities/models.js';
4 | import { TraitModel } from './identities/traits/models.js';
5 | import { getIdentitySegments } from './segments/evaluators.js';
6 | import { SegmentModel } from './segments/models.js';
7 | import { FeatureStateNotFound } from './utils/errors.js';
8 |
9 | export { EnvironmentModel } from './environments/models.js';
10 | export { FeatureStateModel } from './features/models.js';
11 | export { IdentityModel } from './identities/models.js';
12 | export { TraitModel } from './identities/traits/models.js';
13 | export { SegmentModel } from './segments/models.js';
14 | export { OrganisationModel } from './organisations/models.js';
15 |
16 | function getIdentityFeatureStatesDict(
17 | environment: EnvironmentModel,
18 | identity: IdentityModel,
19 | overrideTraits?: TraitModel[]
20 | ) {
21 | // Get feature states from the environment
22 | const featureStates: { [key: number]: FeatureStateModel } = {};
23 | for (const fs of environment.featureStates) {
24 | featureStates[fs.feature.id] = fs;
25 | }
26 |
27 | // Override with any feature states defined by matching segments
28 | const identitySegments: SegmentModel[] = getIdentitySegments(
29 | environment,
30 | identity,
31 | overrideTraits
32 | );
33 | for (const matchingSegment of identitySegments) {
34 | for (const featureState of matchingSegment.featureStates) {
35 | if (featureStates[featureState.feature.id]) {
36 | if (featureStates[featureState.feature.id].isHigherSegmentPriority(featureState)) {
37 | continue;
38 | }
39 | }
40 | featureStates[featureState.feature.id] = featureState;
41 | }
42 | }
43 |
44 | // Override with any feature states defined directly the identity
45 | for (const fs of identity.identityFeatures) {
46 | if (featureStates[fs.feature.id]) {
47 | featureStates[fs.feature.id] = fs;
48 | }
49 | }
50 | return featureStates;
51 | }
52 |
53 | export function getIdentityFeatureState(
54 | environment: EnvironmentModel,
55 | identity: IdentityModel,
56 | featureName: string,
57 | overrideTraits?: TraitModel[]
58 | ): FeatureStateModel {
59 | const featureStates = getIdentityFeatureStatesDict(environment, identity, overrideTraits);
60 |
61 | const matchingFeature = Object.values(featureStates).filter(
62 | f => f.feature.name === featureName
63 | );
64 |
65 | if (matchingFeature.length === 0) {
66 | throw new FeatureStateNotFound('Feature State Not Found');
67 | }
68 |
69 | return matchingFeature[0];
70 | }
71 |
72 | export function getIdentityFeatureStates(
73 | environment: EnvironmentModel,
74 | identity: IdentityModel,
75 | overrideTraits?: TraitModel[]
76 | ): FeatureStateModel[] {
77 | const featureStates = Object.values(
78 | getIdentityFeatureStatesDict(environment, identity, overrideTraits)
79 | );
80 |
81 | if (environment.project.hideDisabledFlags) {
82 | return featureStates.filter(fs => !!fs.enabled);
83 | }
84 | return featureStates;
85 | }
86 |
87 | export function getEnvironmentFeatureState(environment: EnvironmentModel, featureName: string) {
88 | const featuresStates = environment.featureStates.filter(f => f.feature.name === featureName);
89 |
90 | if (featuresStates.length === 0) {
91 | throw new FeatureStateNotFound('Feature State Not Found');
92 | }
93 |
94 | return featuresStates[0];
95 | }
96 |
97 | export function getEnvironmentFeatureStates(environment: EnvironmentModel): FeatureStateModel[] {
98 | if (environment.project.hideDisabledFlags) {
99 | return environment.featureStates.filter(fs => !!fs.enabled);
100 | }
101 | return environment.featureStates;
102 | }
103 |
--------------------------------------------------------------------------------
/flagsmith-engine/organisations/models.ts:
--------------------------------------------------------------------------------
1 | export class OrganisationModel {
2 | id: number;
3 | name: string;
4 | featureAnalytics: boolean;
5 | stopServingFlags: boolean;
6 | persistTraitData: boolean;
7 |
8 | constructor(
9 | id: number,
10 | name: string,
11 | featureAnalytics: boolean,
12 | stopServingFlags: boolean,
13 | persistTraitData: boolean
14 | ) {
15 | this.id = id;
16 | this.name = name;
17 | this.featureAnalytics = featureAnalytics;
18 | this.stopServingFlags = stopServingFlags;
19 | this.persistTraitData = persistTraitData;
20 | }
21 |
22 | get uniqueSlug() {
23 | return this.id.toString() + '-' + this.name;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/flagsmith-engine/organisations/util.ts:
--------------------------------------------------------------------------------
1 | import { OrganisationModel } from './models.js';
2 |
3 | export function buildOrganizationModel(organizationJSON: any): OrganisationModel {
4 | return new OrganisationModel(
5 | organizationJSON.id,
6 | organizationJSON.name,
7 | organizationJSON.feature_analytics,
8 | organizationJSON.stop_serving_flags,
9 | organizationJSON.persist_trait_data
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/flagsmith-engine/projects/models.ts:
--------------------------------------------------------------------------------
1 | import { OrganisationModel } from '../organisations/models.js';
2 | import { SegmentModel } from '../segments/models.js';
3 |
4 | export class ProjectModel {
5 | id: number;
6 | name: string;
7 | organisation: OrganisationModel;
8 | hideDisabledFlags: boolean;
9 | segments: SegmentModel[] = [];
10 |
11 | constructor(
12 | id: number,
13 | name: string,
14 | hideDisabledFlags: boolean,
15 | organization: OrganisationModel
16 | ) {
17 | this.id = id;
18 | this.name = name;
19 | this.hideDisabledFlags = hideDisabledFlags;
20 | this.organisation = organization;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/flagsmith-engine/projects/util.ts:
--------------------------------------------------------------------------------
1 | import { buildOrganizationModel } from '../organisations/util.js';
2 | import { SegmentModel } from '../segments/models.js';
3 | import { buildSegmentModel } from '../segments/util.js';
4 | import { ProjectModel } from './models.js';
5 |
6 | export function buildProjectModel(projectJSON: any): ProjectModel {
7 | const segments: SegmentModel[] = projectJSON['segments']
8 | ? projectJSON['segments'].map((s: any) => buildSegmentModel(s))
9 | : [];
10 | const model = new ProjectModel(
11 | projectJSON.id,
12 | projectJSON.name,
13 | projectJSON.hide_disabled_flags,
14 | buildOrganizationModel(projectJSON.organisation)
15 | );
16 | model.segments = segments;
17 | return model;
18 | }
19 |
--------------------------------------------------------------------------------
/flagsmith-engine/segments/constants.ts:
--------------------------------------------------------------------------------
1 | // Segment Rules
2 | export const ALL_RULE = 'ALL';
3 | export const ANY_RULE = 'ANY';
4 | export const NONE_RULE = 'NONE';
5 |
6 | export const RULE_TYPES = [ALL_RULE, ANY_RULE, NONE_RULE];
7 |
8 | // Segment Condition Operators
9 | export const EQUAL = 'EQUAL';
10 | export const GREATER_THAN = 'GREATER_THAN';
11 | export const LESS_THAN = 'LESS_THAN';
12 | export const LESS_THAN_INCLUSIVE = 'LESS_THAN_INCLUSIVE';
13 | export const CONTAINS = 'CONTAINS';
14 | export const GREATER_THAN_INCLUSIVE = 'GREATER_THAN_INCLUSIVE';
15 | export const NOT_CONTAINS = 'NOT_CONTAINS';
16 | export const NOT_EQUAL = 'NOT_EQUAL';
17 | export const REGEX = 'REGEX';
18 | export const PERCENTAGE_SPLIT = 'PERCENTAGE_SPLIT';
19 | export const IS_SET = 'IS_SET';
20 | export const IS_NOT_SET = 'IS_NOT_SET';
21 | export const MODULO = 'MODULO';
22 | export const IN = 'IN';
23 |
24 | export const CONDITION_OPERATORS = {
25 | EQUAL,
26 | GREATER_THAN,
27 | LESS_THAN,
28 | LESS_THAN_INCLUSIVE,
29 | CONTAINS,
30 | GREATER_THAN_INCLUSIVE,
31 | NOT_CONTAINS,
32 | NOT_EQUAL,
33 | REGEX,
34 | PERCENTAGE_SPLIT,
35 | IS_SET,
36 | IS_NOT_SET,
37 | MODULO,
38 | IN
39 | };
40 |
--------------------------------------------------------------------------------
/flagsmith-engine/segments/evaluators.ts:
--------------------------------------------------------------------------------
1 | import { EnvironmentModel } from '../environments/models.js';
2 | import { IdentityModel } from '../identities/models.js';
3 | import { TraitModel } from '../identities/traits/models.js';
4 | import { getHashedPercentateForObjIds } from '../utils/hashing/index.js';
5 | import { PERCENTAGE_SPLIT, IS_SET, IS_NOT_SET } from './constants.js';
6 | import { SegmentConditionModel, SegmentModel, SegmentRuleModel } from './models.js';
7 |
8 | export function getIdentitySegments(
9 | environment: EnvironmentModel,
10 | identity: IdentityModel,
11 | overrideTraits?: TraitModel[]
12 | ): SegmentModel[] {
13 | return environment.project.segments.filter(segment =>
14 | evaluateIdentityInSegment(identity, segment, overrideTraits)
15 | );
16 | }
17 |
18 | export function evaluateIdentityInSegment(
19 | identity: IdentityModel,
20 | segment: SegmentModel,
21 | overrideTraits?: TraitModel[]
22 | ): boolean {
23 | return (
24 | segment.rules.length > 0 &&
25 | segment.rules.filter(rule =>
26 | traitsMatchSegmentRule(
27 | overrideTraits || identity.identityTraits,
28 | rule,
29 | segment.id,
30 | identity.djangoID || identity.compositeKey
31 | )
32 | ).length === segment.rules.length
33 | );
34 | }
35 |
36 | function traitsMatchSegmentRule(
37 | identityTraits: TraitModel[],
38 | rule: SegmentRuleModel,
39 | segmentId: number | string,
40 | identityId: number | string
41 | ): boolean {
42 | const matchesConditions =
43 | rule.conditions.length > 0
44 | ? rule.matchingFunction()(
45 | rule.conditions.map(condition =>
46 | traitsMatchSegmentCondition(identityTraits, condition, segmentId, identityId)
47 | )
48 | )
49 | : true;
50 | return (
51 | matchesConditions &&
52 | rule.rules.filter(rule =>
53 | traitsMatchSegmentRule(identityTraits, rule, segmentId, identityId)
54 | ).length === rule.rules.length
55 | );
56 | }
57 |
58 | export function traitsMatchSegmentCondition(
59 | identityTraits: TraitModel[],
60 | condition: SegmentConditionModel,
61 | segmentId: number | string,
62 | identityId: number | string
63 | ): boolean {
64 | if (condition.operator == PERCENTAGE_SPLIT) {
65 | var hashedPercentage = getHashedPercentateForObjIds([segmentId, identityId]);
66 | return hashedPercentage <= parseFloat(String(condition.value));
67 | }
68 | const traits = identityTraits.filter(t => t.traitKey === condition.property_);
69 | const trait = traits.length > 0 ? traits[0] : undefined;
70 | if (condition.operator === IS_SET ) {
71 | return !!trait;
72 | } else if (condition.operator === IS_NOT_SET){
73 | return trait == undefined;
74 | }
75 | return trait ? condition.matchesTraitValue(trait.traitValue) : false;
76 |
77 | }
78 |
--------------------------------------------------------------------------------
/flagsmith-engine/segments/models.ts:
--------------------------------------------------------------------------------
1 | import * as semver from 'semver';
2 |
3 | import { FeatureStateModel } from '../features/models.js';
4 | import { getCastingFunction as getCastingFunction } from '../utils/index.js';
5 | import {
6 | ALL_RULE,
7 | ANY_RULE,
8 | NONE_RULE,
9 | NOT_CONTAINS,
10 | REGEX,
11 | MODULO,
12 | IN,
13 | CONDITION_OPERATORS
14 | } from './constants.js';
15 | import { isSemver } from './util.js';
16 |
17 | export const all = (iterable: Array) => iterable.filter(e => !!e).length === iterable.length;
18 | export const any = (iterable: Array) => iterable.filter(e => !!e).length > 0;
19 |
20 | export const matchingFunctions = {
21 | [CONDITION_OPERATORS.EQUAL]: (thisValue: any, otherValue: any) => thisValue == otherValue,
22 | [CONDITION_OPERATORS.GREATER_THAN]: (thisValue: any, otherValue: any) => otherValue > thisValue,
23 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) =>
24 | otherValue >= thisValue,
25 | [CONDITION_OPERATORS.LESS_THAN]: (thisValue: any, otherValue: any) => thisValue > otherValue,
26 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) =>
27 | thisValue >= otherValue,
28 | [CONDITION_OPERATORS.NOT_EQUAL]: (thisValue: any, otherValue: any) => thisValue != otherValue,
29 | [CONDITION_OPERATORS.CONTAINS]: (thisValue: any, otherValue: any) =>
30 | !!otherValue && otherValue.includes(thisValue),
31 | };
32 |
33 | export const semverMatchingFunction = {
34 | ...matchingFunctions,
35 | [CONDITION_OPERATORS.EQUAL]: (thisValue: any, otherValue: any) => semver.eq(thisValue, otherValue),
36 | [CONDITION_OPERATORS.GREATER_THAN]: (thisValue: any, otherValue: any) => semver.gt(otherValue, thisValue),
37 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) =>
38 | semver.gte(otherValue, thisValue),
39 | [CONDITION_OPERATORS.LESS_THAN]: (thisValue: any, otherValue: any) => semver.gt(thisValue, otherValue),
40 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE]: (thisValue: any, otherValue: any) =>
41 | semver.gte(thisValue, otherValue),
42 | }
43 |
44 | export const getMatchingFunctions = (semver: boolean) => (semver ? semverMatchingFunction : matchingFunctions);
45 |
46 | export class SegmentConditionModel {
47 | EXCEPTION_OPERATOR_METHODS: { [key: string]: string } = {
48 | [NOT_CONTAINS]: 'evaluateNotContains',
49 | [REGEX]: 'evaluateRegex',
50 | [MODULO]: 'evaluateModulo',
51 | [IN]: 'evaluateIn'
52 | };
53 |
54 | operator: string;
55 | value: string | null | undefined;
56 | property_: string | null | undefined;
57 |
58 | constructor(operator: string, value?: string | null | undefined, property?: string | null | undefined) {
59 | this.operator = operator;
60 | this.value = value;
61 | this.property_ = property;
62 | }
63 |
64 | matchesTraitValue(traitValue: any) {
65 | const evaluators: { [key: string]: CallableFunction } = {
66 | evaluateNotContains: (traitValue: any) => {
67 | return typeof traitValue == "string" &&
68 | !!this.value &&
69 | !traitValue.includes(this.value?.toString());
70 | },
71 | evaluateRegex: (traitValue: any) => {
72 | return !!this.value && !!traitValue?.toString().match(new RegExp(this.value));
73 | },
74 | evaluateModulo: (traitValue: any) => {
75 | if (isNaN(parseFloat(traitValue)) || !this.value) {
76 | return false
77 | }
78 | const parts = (this.value).split("|");
79 | const [divisor, reminder] = [parseFloat(parts[0]), parseFloat(parts[1])];
80 | return traitValue % divisor === reminder
81 | },
82 | evaluateIn: (traitValue: any) => {
83 | return this.value?.split(',').includes(traitValue.toString())
84 | },
85 | };
86 |
87 | // TODO: move this logic to the evaluator module
88 | if (this.EXCEPTION_OPERATOR_METHODS[this.operator]) {
89 | const evaluatorFunction = evaluators[this.EXCEPTION_OPERATOR_METHODS[this.operator]];
90 | return evaluatorFunction(traitValue);
91 | }
92 |
93 | const defaultFunction = (x: any, y: any) => false;
94 |
95 | const matchingFunctionSet = getMatchingFunctions(isSemver(this.value));
96 | const matchingFunction = matchingFunctionSet[this.operator] || defaultFunction;
97 |
98 | const traitType = isSemver(this.value) ? 'semver' : typeof traitValue;
99 | const castToTypeOfTraitValue = getCastingFunction(traitType);
100 |
101 | return matchingFunction(castToTypeOfTraitValue(this.value), traitValue);
102 | }
103 | }
104 |
105 | export class SegmentRuleModel {
106 | type: string;
107 | rules: SegmentRuleModel[] = [];
108 | conditions: SegmentConditionModel[] = [];
109 |
110 | constructor(type: string) {
111 | this.type = type;
112 | }
113 |
114 | static none(iterable: Array) {
115 | return iterable.filter(e => !!e).length === 0;
116 | }
117 |
118 | matchingFunction(): CallableFunction {
119 | return {
120 | [ANY_RULE]: any,
121 | [ALL_RULE]: all,
122 | [NONE_RULE]: SegmentRuleModel.none
123 | }[this.type] as CallableFunction;
124 | }
125 | }
126 |
127 | export class SegmentModel {
128 | id: number;
129 | name: string;
130 | rules: SegmentRuleModel[] = [];
131 | featureStates: FeatureStateModel[] = [];
132 |
133 | constructor(id: number, name: string) {
134 | this.id = id;
135 | this.name = name;
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/flagsmith-engine/segments/util.ts:
--------------------------------------------------------------------------------
1 | import { buildFeatureStateModel } from '../features/util.js';
2 | import { SegmentConditionModel, SegmentModel, SegmentRuleModel } from './models.js';
3 |
4 | export function buildSegmentConditionModel(segmentConditionJSON: any): SegmentConditionModel {
5 | return new SegmentConditionModel(
6 | segmentConditionJSON.operator,
7 | segmentConditionJSON.value,
8 | segmentConditionJSON.property_
9 | );
10 | }
11 |
12 | export function buildSegmentRuleModel(ruleModelJSON: any): SegmentRuleModel {
13 | const ruleModel = new SegmentRuleModel(ruleModelJSON.type);
14 |
15 | ruleModel.rules = ruleModelJSON.rules.map((r: any) => buildSegmentRuleModel(r));
16 | ruleModel.conditions = ruleModelJSON.conditions.map((c: any) => buildSegmentConditionModel(c));
17 | return ruleModel;
18 | }
19 |
20 | export function buildSegmentModel(segmentModelJSON: any): SegmentModel {
21 | const model = new SegmentModel(segmentModelJSON.id, segmentModelJSON.name);
22 |
23 | model.featureStates = segmentModelJSON['feature_states'].map((fs: any) =>
24 | buildFeatureStateModel(fs)
25 | );
26 | model.rules = segmentModelJSON['rules'].map((r: any) => buildSegmentRuleModel(r));
27 |
28 | return model;
29 | }
30 |
31 | export function isSemver(value: any) {
32 | return typeof value == 'string' && value.endsWith(':semver');
33 | }
34 |
35 | export function removeSemverSuffix(value: string) {
36 | return value.replace(':semver', '');
37 | }
38 |
--------------------------------------------------------------------------------
/flagsmith-engine/utils/collections.ts:
--------------------------------------------------------------------------------
1 | import { FeatureStateModel } from '../features/models.js';
2 |
3 | export class IdentityFeaturesList extends Array {}
4 |
--------------------------------------------------------------------------------
/flagsmith-engine/utils/errors.ts:
--------------------------------------------------------------------------------
1 | export class FeatureStateNotFound extends Error {}
2 |
--------------------------------------------------------------------------------
/flagsmith-engine/utils/hashing/index.ts:
--------------------------------------------------------------------------------
1 | import {BinaryLike, createHash} from "node:crypto";
2 |
3 | const md5 = (data: BinaryLike) => createHash('md5').update(data).digest('hex')
4 |
5 | const makeRepeated = (arr: Array, repeats: number) =>
6 | Array.from({ length: repeats }, () => arr).flat();
7 |
8 | // https://stackoverflow.com/questions/12532871/how-to-convert-a-very-large-hex-number-to-decimal-in-javascript
9 | /**
10 | * Given a list of object ids, get a floating point number between 0 and 1 based on
11 | * the hash of those ids. This should give the same value every time for any list of ids.
12 | *
13 | * @param {Array} objectIds list of object ids to calculate the has for
14 | * @param {} iterations=1 num times to include each id in the generated string to hash
15 | * @returns number number between 0 (inclusive) and 100 (exclusive)
16 | */
17 | export function getHashedPercentateForObjIds(objectIds: Array, iterations = 1): number {
18 | let toHash = makeRepeated(objectIds, iterations).join(',');
19 | const hashedValue = md5(toHash);
20 | const hashedInt = BigInt('0x' + hashedValue);
21 | const value = (Number((hashedInt % 9999n)) / 9998.0) * 100;
22 |
23 | // we ignore this for it's nearly impossible use case to catch
24 | /* istanbul ignore next */
25 | if (value === 100) {
26 | /* istanbul ignore next */
27 | return getHashedPercentateForObjIds(objectIds, iterations + 1);
28 | }
29 |
30 | return value;
31 | }
32 |
--------------------------------------------------------------------------------
/flagsmith-engine/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { removeSemverSuffix } from "../segments/util.js";
2 |
3 | export function getCastingFunction(traitType: 'boolean' | 'string' | 'number' | 'semver' | any): CallableFunction {
4 | switch (traitType) {
5 | case 'boolean':
6 | return (x: any) => !['False', 'false'].includes(x);
7 | case 'number':
8 | return (x: any) => parseFloat(x);
9 | case 'semver':
10 | return (x: any) => removeSemverSuffix(x);
11 | default:
12 | return (x: any) => String(x);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | AnalyticsProcessor,
3 | AnalyticsProcessorOptions,
4 | FlagsmithAPIError,
5 | FlagsmithClientError,
6 | EnvironmentDataPollingManager,
7 | FlagsmithCache,
8 | DefaultFlag,
9 | Flags,
10 | Flagsmith,
11 | } from './sdk/index.js';
12 |
13 | export {
14 | BaseOfflineHandler,
15 | LocalFileHandler,
16 | } from './sdk/offline_handlers.js';
17 |
18 | export {
19 | FlagsmithConfig
20 | } from './sdk/types.js'
21 |
22 | export {
23 | EnvironmentModel,
24 | FeatureStateModel,
25 | IdentityModel,
26 | TraitModel,
27 | SegmentModel,
28 | OrganisationModel
29 | } from './flagsmith-engine/index.js';
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "flagsmith-nodejs",
3 | "version": "6.0.1",
4 | "description": "Flagsmith lets you manage features flags and remote config across web, mobile and server side applications. Deliver true Continuous Integration. Get builds out faster. Control who has access to new features.",
5 | "main": "./build/cjs/index.js",
6 | "type": "module",
7 | "engines": {
8 | "node": ">=18"
9 | },
10 | "exports": {
11 | "import": "./build/esm/index.js",
12 | "require": "./build/cjs/index.js"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/Flagsmith/flagsmith-nodejs-client"
17 | },
18 | "keywords": [
19 | "nodejs",
20 | "flagsmith",
21 | "feature flags",
22 | "feature toggles",
23 | "remote configuration",
24 | "continuous deployment"
25 | ],
26 | "bugs": {
27 | "url": "https://github.com/Flagsmith/flagsmith-nodejs-client/issues"
28 | },
29 | "homepage": "http://flagsmith.com/",
30 | "author": "Flagsmith",
31 | "contributors": [
32 | {
33 | "name": "Tom Stuart",
34 | "email": "tom@solidstategroup.com"
35 | },
36 | {
37 | "name": "Kyle Johnson",
38 | "email": "kyle.johnson@flagsmith.com",
39 | "url": "https://www.npmjs.com/~kyle-ssg"
40 | },
41 | {
42 | "name": "Luke Fanning",
43 | "email": "luke@solidstategroup.com"
44 | },
45 | {
46 | "name": "Matt Elwell",
47 | "email": "matthew.elwell@solidstategroup.com"
48 | }
49 | ],
50 | "license": "MIT",
51 | "scripts": {
52 | "lint": "prettier --write .",
53 | "test": "vitest --coverage --run",
54 | "test:watch": "vitest",
55 | "test:debug": "vitest --inspect-brk --no-file-parallelism --coverage",
56 | "prebuild": "rm -rf ./build",
57 | "build": "tsc -b tsconfig.cjs.json tsconfig.esm.json && echo '{\"type\": \"commonjs\"}'> build/cjs/package.json",
58 | "deploy": "npm i && npm run build && npm publish",
59 | "deploy:beta": "npm i && npm run build && npm publish --tag beta",
60 | "prepare": "husky install"
61 | },
62 | "dependencies": {
63 | "pino": "^8.8.0",
64 | "semver": "^7.3.7",
65 | "undici-types": "^6.19.8"
66 | },
67 | "devDependencies": {
68 | "@types/node": "^20.16.10",
69 | "@types/semver": "^7.3.9",
70 | "@types/uuid": "^8.3.4",
71 | "@vitest/coverage-v8": "^2.1.2",
72 | "esbuild": "^0.25.0",
73 | "husky": "^7.0.4",
74 | "prettier": "^2.2.1",
75 | "typescript": "^4.9.5",
76 | "undici": "^6.19.8",
77 | "vitest": "^2.1.2"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/sdk/analytics.ts:
--------------------------------------------------------------------------------
1 | import { pino, Logger } from 'pino';
2 | import { Fetch } from "./types.js";
3 | import { FlagsmithConfig } from "./types.js";
4 |
5 | export const ANALYTICS_ENDPOINT = './analytics/flags/';
6 |
7 | /** Duration in seconds to wait before trying to flush collected data after {@link trackFeature} is called. **/
8 | const ANALYTICS_TIMER = 10;
9 |
10 | const DEFAULT_REQUEST_TIMEOUT_MS = 3000
11 |
12 | export interface AnalyticsProcessorOptions {
13 | /** URL of the Flagsmith analytics events API endpoint
14 | * @example https://flagsmith.example.com/api/v1/analytics
15 | */
16 | analyticsUrl?: string;
17 | /** Client-side key of the environment that analytics will be recorded for. **/
18 | environmentKey: string;
19 | /** Duration in milliseconds to wait for API requests to complete before timing out. Defaults to {@link DEFAULT_REQUEST_TIMEOUT_MS}. **/
20 | requestTimeoutMs?: number;
21 | logger?: Logger;
22 | /** Custom {@link fetch} implementation to use for API requests. **/
23 | fetch?: Fetch
24 |
25 | /** @deprecated Use {@link analyticsUrl} instead. **/
26 | baseApiUrl?: string;
27 | }
28 |
29 | /**
30 | * Tracks how often individual features are evaluated whenever {@link trackFeature} is called.
31 | *
32 | * Analytics data is posted after {@link trackFeature} is called and at least {@link ANALYTICS_TIMER} seconds have
33 | * passed since the previous analytics API request was made (if any), or by calling {@link flush}.
34 | *
35 | * Data will stay in memory indefinitely until it can be successfully posted to the API.
36 | * @see https://docs.flagsmith.com/advanced-use/flag-analytics.
37 | */
38 | export class AnalyticsProcessor {
39 | private analyticsUrl: string;
40 | private environmentKey: string;
41 | private lastFlushed: number;
42 | analyticsData: { [key: string]: any };
43 | private requestTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS;
44 | private logger: Logger;
45 | private currentFlush: ReturnType | undefined;
46 | private customFetch: Fetch;
47 |
48 | constructor(data: AnalyticsProcessorOptions) {
49 | this.analyticsUrl = data.analyticsUrl || data.baseApiUrl + ANALYTICS_ENDPOINT;
50 | this.environmentKey = data.environmentKey;
51 | this.lastFlushed = Date.now();
52 | this.analyticsData = {};
53 | this.requestTimeoutMs = data.requestTimeoutMs || this.requestTimeoutMs;
54 | this.logger = data.logger || pino();
55 | this.customFetch = data.fetch ?? fetch;
56 | }
57 | /**
58 | * Try to flush pending collected data to the Flagsmith analytics API.
59 | */
60 | async flush() {
61 | if (this.currentFlush || !Object.keys(this.analyticsData).length) {
62 | return;
63 | }
64 |
65 | try {
66 | this.currentFlush = this.customFetch(this.analyticsUrl, {
67 | method: 'POST',
68 | body: JSON.stringify(this.analyticsData),
69 | signal: AbortSignal.timeout(this.requestTimeoutMs),
70 | headers: {
71 | 'Content-Type': 'application/json',
72 | 'X-Environment-Key': this.environmentKey
73 | }
74 | });
75 | await this.currentFlush;
76 | } catch (error) {
77 | // We don't want failing to write analytics to cause any exceptions in the main
78 | // thread so we just swallow them here.
79 | this.logger.warn('Failed to post analytics to Flagsmith API. Not clearing data, will retry.')
80 | return;
81 | } finally {
82 | this.currentFlush = undefined;
83 | }
84 |
85 | this.analyticsData = {};
86 | this.lastFlushed = Date.now();
87 | }
88 |
89 | /**
90 | * Track a single evaluation event for a feature.
91 | *
92 | * @see FlagsmithConfig.enableAnalytics
93 | */
94 | trackFeature(featureName: string) {
95 | this.analyticsData[featureName] = (this.analyticsData[featureName] || 0) + 1;
96 | if (Date.now() - this.lastFlushed > ANALYTICS_TIMER * 1000) {
97 | this.flush();
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/sdk/errors.ts:
--------------------------------------------------------------------------------
1 | export class FlagsmithClientError extends Error {}
2 | export class FlagsmithAPIError extends Error {}
3 |
--------------------------------------------------------------------------------
/sdk/index.ts:
--------------------------------------------------------------------------------
1 | import { Dispatcher } from 'undici-types';
2 | import { getEnvironmentFeatureStates, getIdentityFeatureStates } from '../flagsmith-engine/index.js';
3 | import { EnvironmentModel } from '../flagsmith-engine/index.js';
4 | import { buildEnvironmentModel } from '../flagsmith-engine/environments/util.js';
5 | import { IdentityModel } from '../flagsmith-engine/index.js';
6 | import { TraitModel } from '../flagsmith-engine/index.js';
7 |
8 | import {ANALYTICS_ENDPOINT, AnalyticsProcessor} from './analytics.js';
9 | import { BaseOfflineHandler } from './offline_handlers.js';
10 | import { FlagsmithAPIError } from './errors.js';
11 |
12 | import { DefaultFlag, Flags } from './models.js';
13 | import { EnvironmentDataPollingManager } from './polling_manager.js';
14 | import { Deferred, generateIdentitiesData, retryFetch } from './utils.js';
15 | import { SegmentModel } from '../flagsmith-engine/index.js';
16 | import { getIdentitySegments } from '../flagsmith-engine/segments/evaluators.js';
17 | import { Fetch, FlagsmithCache, FlagsmithConfig, FlagsmithTraitValue, ITraitConfig } from './types.js';
18 | import { pino, Logger } from 'pino';
19 |
20 | export { AnalyticsProcessor, AnalyticsProcessorOptions } from './analytics.js';
21 | export { FlagsmithAPIError, FlagsmithClientError } from './errors.js';
22 |
23 | export { DefaultFlag, Flags } from './models.js';
24 | export { EnvironmentDataPollingManager } from './polling_manager.js';
25 | export { FlagsmithCache, FlagsmithConfig } from './types.js';
26 |
27 | const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/';
28 | const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;
29 |
30 | /**
31 | * A client for evaluating Flagsmith feature flags.
32 | *
33 | * Flags are evaluated remotely by the Flagsmith API over HTTP by default.
34 | * To evaluate flags locally, create the client using {@link FlagsmithConfig.enableLocalEvaluation} and a server-side SDK key.
35 | *
36 | * @example
37 | * import { Flagsmith, Flags, DefaultFlag } from 'flagsmith-nodejs'
38 | *
39 | * const flagsmith = new Flagsmith({
40 | * environmentKey: 'your_sdk_key',
41 | * defaultFlagHandler: (flagKey: string) => { new DefaultFlag(...) },
42 | * });
43 | *
44 | * // Fetch the current environment flags
45 | * const environmentFlags: Flags = flagsmith.getEnvironmentFlags()
46 | * const isFooEnabled: boolean = environmentFlags.isFeatureEnabled('foo')
47 | *
48 | * // Evaluate flags for any identity
49 | * const identityFlags: Flags = flagsmith.getIdentityFlags('my_user_123', {'vip': true})
50 | * const bannerVariation: string = identityFlags.getFeatureValue('banner_flag')
51 | *
52 | * @see FlagsmithConfig
53 | */
54 | export class Flagsmith {
55 | environmentKey?: string = undefined;
56 | apiUrl?: string = undefined;
57 | analyticsUrl?: string = undefined;
58 | customHeaders?: { [key: string]: any };
59 | agent?: Dispatcher;
60 | requestTimeoutMs?: number;
61 | enableLocalEvaluation?: boolean = false;
62 | environmentRefreshIntervalSeconds: number = 60;
63 | retries?: number;
64 | enableAnalytics: boolean = false;
65 | defaultFlagHandler?: (featureName: string) => DefaultFlag;
66 |
67 | environmentFlagsUrl?: string;
68 | identitiesUrl?: string;
69 | environmentUrl?: string;
70 |
71 | environmentDataPollingManager?: EnvironmentDataPollingManager;
72 | private environment?: EnvironmentModel;
73 | offlineMode: boolean = false;
74 | offlineHandler?: BaseOfflineHandler = undefined;
75 |
76 | identitiesWithOverridesByIdentifier?: Map;
77 |
78 | private cache?: FlagsmithCache;
79 | private onEnvironmentChange: (error: Error | null, result?: EnvironmentModel) => void;
80 | private analyticsProcessor?: AnalyticsProcessor;
81 | private logger: Logger;
82 | private customFetch: Fetch;
83 | private readonly requestRetryDelayMilliseconds: number;
84 |
85 | /**
86 | * Creates a new {@link Flagsmith} client.
87 | *
88 | * If using local evaluation, the environment will be fetched lazily when needed by any method. Polling the
89 | * environment for updates will start after {@link environmentRefreshIntervalSeconds} once the client is created.
90 | * @param data The {@link FlagsmithConfig} options for this client.
91 | */
92 | constructor(data: FlagsmithConfig) {
93 | this.agent = data.agent;
94 | this.customFetch = data.fetch ?? fetch;
95 | this.environmentKey = data.environmentKey;
96 | this.apiUrl = data.apiUrl || DEFAULT_API_URL;
97 | this.customHeaders = data.customHeaders;
98 | this.requestTimeoutMs =
99 | 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS);
100 | this.requestRetryDelayMilliseconds = data.requestRetryDelayMilliseconds ?? 1000;
101 | this.enableLocalEvaluation = data.enableLocalEvaluation;
102 | this.environmentRefreshIntervalSeconds =
103 | data.environmentRefreshIntervalSeconds || this.environmentRefreshIntervalSeconds;
104 | this.retries = data.retries;
105 | this.enableAnalytics = data.enableAnalytics || false;
106 | this.defaultFlagHandler = data.defaultFlagHandler;
107 |
108 | this.onEnvironmentChange = (error, result) => data.onEnvironmentChange?.(error, result);
109 | this.logger = data.logger || pino();
110 | this.offlineMode = data.offlineMode || false;
111 | this.offlineHandler = data.offlineHandler;
112 |
113 | // argument validation
114 | if (this.offlineMode && !this.offlineHandler) {
115 | throw new Error('ValueError: offlineHandler must be provided to use offline mode.');
116 | } else if (this.defaultFlagHandler && this.offlineHandler) {
117 | throw new Error('ValueError: Cannot use both defaultFlagHandler and offlineHandler.');
118 | }
119 |
120 | if (!!data.cache) {
121 | this.cache = data.cache;
122 | }
123 |
124 | if (!this.offlineMode) {
125 | if (!this.environmentKey) {
126 | throw new Error('ValueError: environmentKey is required.');
127 | }
128 |
129 | const apiUrl = data.apiUrl || DEFAULT_API_URL;
130 | this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
131 | this.analyticsUrl = this.analyticsUrl || new URL(ANALYTICS_ENDPOINT, new Request(this.apiUrl).url).href
132 | this.environmentFlagsUrl = `${this.apiUrl}flags/`;
133 | this.identitiesUrl = `${this.apiUrl}identities/`;
134 | this.environmentUrl = `${this.apiUrl}environment-document/`;
135 |
136 | if (this.enableLocalEvaluation) {
137 | if (!this.environmentKey.startsWith('ser.')) {
138 | throw new Error('Using local evaluation requires a server-side environment key');
139 | }
140 | if (this.environmentRefreshIntervalSeconds > 0){
141 | this.environmentDataPollingManager = new EnvironmentDataPollingManager(
142 | this,
143 | this.environmentRefreshIntervalSeconds,
144 | this.logger,
145 | );
146 | this.environmentDataPollingManager.start();
147 | }
148 | }
149 |
150 | if (data.enableAnalytics) {
151 | this.analyticsProcessor = new AnalyticsProcessor({
152 | environmentKey: this.environmentKey,
153 | analyticsUrl: this.analyticsUrl,
154 | requestTimeoutMs: this.requestTimeoutMs,
155 | logger: this.logger,
156 | })
157 | }
158 | }
159 | }
160 | /**
161 | * Get all the default for flags for the current environment.
162 | *
163 | * @returns Flags object holding all the flags for the current environment.
164 | */
165 | async getEnvironmentFlags(): Promise {
166 | const cachedItem = !!this.cache && (await this.cache.get(`flags`));
167 | if (!!cachedItem) {
168 | return cachedItem;
169 | }
170 | try {
171 | if (this.enableLocalEvaluation || this.offlineMode) {
172 | return await this.getEnvironmentFlagsFromDocument();
173 | }
174 | return await this.getEnvironmentFlagsFromApi();
175 | } catch (error) {
176 | if (!this.defaultFlagHandler) {
177 | throw new Error('getEnvironmentFlags failed and no default flag handler was provided', { cause: error });
178 | }
179 | this.logger.error(error, 'getEnvironmentFlags failed');
180 | return new Flags({
181 | flags: {},
182 | defaultFlagHandler: this.defaultFlagHandler
183 | });
184 | }
185 | }
186 |
187 | /**
188 | * Get all the flags for the current environment for a given identity. Will also
189 | upsert all traits to the Flagsmith API for future evaluations. Providing a
190 | trait with a value of None will remove the trait from the identity if it exists.
191 | *
192 | * @param {string} identifier a unique identifier for the identity in the current
193 | environment, e.g. email address, username, uuid
194 | * @param {{[key:string]:any | ITraitConfig}} traits? a dictionary of traits to add / update on the identity in
195 | Flagsmith, e.g. {"num_orders": 10} or {age: {value: 30, transient: true}}
196 | * @returns Flags object holding all the flags for the given identity.
197 | */
198 | async getIdentityFlags(
199 | identifier: string,
200 | traits?: { [key: string]: FlagsmithTraitValue | ITraitConfig },
201 | transient: boolean = false
202 | ): Promise {
203 | if (!identifier) {
204 | throw new Error('`identifier` argument is missing or invalid.');
205 | }
206 |
207 | const cachedItem = !!this.cache && (await this.cache.get(`flags-${identifier}`));
208 | if (!!cachedItem) {
209 | return cachedItem;
210 | }
211 | traits = traits || {};
212 | try {
213 | if (this.enableLocalEvaluation || this.offlineMode) {
214 | return await this.getIdentityFlagsFromDocument(identifier, traits || {});
215 | }
216 | return await this.getIdentityFlagsFromApi(identifier, traits, transient);
217 | } catch (error) {
218 | if (!this.defaultFlagHandler) {
219 | throw new Error('getIdentityFlags failed and no default flag handler was provided', { cause: error })
220 | }
221 | this.logger.error(error, 'getIdentityFlags failed');
222 | return new Flags({
223 | flags: {},
224 | defaultFlagHandler: this.defaultFlagHandler
225 | });
226 | }
227 | }
228 |
229 | /**
230 | * Get the segments for the current environment for a given identity. Will also
231 | upsert all traits to the Flagsmith API for future evaluations. Providing a
232 | trait with a value of None will remove the trait from the identity if it exists.
233 | *
234 | * @param {string} identifier a unique identifier for the identity in the current
235 | environment, e.g. email address, username, uuid
236 | * @param {{[key:string]:any}} traits? a dictionary of traits to add / update on the identity in
237 | Flagsmith, e.g. {"num_orders": 10}
238 | * @returns Segments that the given identity belongs to.
239 | */
240 | async getIdentitySegments(
241 | identifier: string,
242 | traits?: { [key: string]: any }
243 | ): Promise {
244 | if (!identifier) {
245 | throw new Error('`identifier` argument is missing or invalid.');
246 | }
247 | if (!this.enableLocalEvaluation) {
248 | this.logger.error('This function is only permitted with local evaluation.');
249 | return Promise.resolve([]);
250 | }
251 |
252 | traits = traits || {};
253 | const environment = await this.getEnvironment();
254 | const identityModel = this.getIdentityModel(
255 | environment,
256 | identifier,
257 | Object.keys(traits || {}).map(key => ({
258 | key,
259 | value: traits?.[key]
260 | }))
261 | );
262 |
263 | return getIdentitySegments(environment, identityModel);
264 | }
265 |
266 | private async fetchEnvironment(): Promise {
267 | const deferred = new Deferred();
268 | this.environmentPromise = deferred.promise;
269 | try {
270 | const environment = await this.getEnvironmentFromApi();
271 | this.environment = environment;
272 | if (environment.identityOverrides?.length) {
273 | this.identitiesWithOverridesByIdentifier = new Map(
274 | environment.identityOverrides.map(identity => [identity.identifier, identity])
275 | );
276 | }
277 | deferred.resolve(environment);
278 | return deferred.promise;
279 | } catch (error) {
280 | deferred.reject(error);
281 | return deferred.promise;
282 | } finally {
283 | this.environmentPromise = undefined;
284 | }
285 | }
286 |
287 | /**
288 | * Fetch the latest environment state from the Flagsmith API to use for local flag evaluation.
289 | *
290 | * If the environment is currently being fetched, calling this method will not cause additional fetches.
291 | */
292 | async updateEnvironment(): Promise {
293 | try {
294 | if (this.environmentPromise) {
295 | await this.environmentPromise
296 | return
297 | }
298 | const environment = await this.fetchEnvironment();
299 | this.onEnvironmentChange(null, environment);
300 | } catch (e) {
301 | this.logger.error(e, 'updateEnvironment failed');
302 | this.onEnvironmentChange(e as Error);
303 | }
304 | }
305 |
306 | async close() {
307 | this.environmentDataPollingManager?.stop();
308 | }
309 |
310 | private async getJSONResponse(
311 | url: string,
312 | method: string,
313 | body?: { [key: string]: any }
314 | ): Promise {
315 | const headers: { [key: string]: any } = { 'Content-Type': 'application/json' };
316 | if (this.environmentKey) {
317 | headers['X-Environment-Key'] = this.environmentKey as string;
318 | }
319 |
320 | if (this.customHeaders) {
321 | for (const [k, v] of Object.entries(this.customHeaders)) {
322 | headers[k] = v;
323 | }
324 | }
325 |
326 | const data = await retryFetch(
327 | url,
328 | {
329 | dispatcher: this.agent,
330 | method: method,
331 | body: JSON.stringify(body),
332 | headers: headers
333 | },
334 | this.retries,
335 | this.requestTimeoutMs,
336 | this.requestRetryDelayMilliseconds,
337 | this.customFetch,
338 | );
339 |
340 | if (data.status !== 200) {
341 | throw new FlagsmithAPIError(
342 | `Invalid request made to Flagsmith API. Response status code: ${data.status}`
343 | );
344 | }
345 |
346 | return data.json();
347 | }
348 |
349 | /**
350 | * This promise ensures that the environment is retrieved before attempting to locally evaluate.
351 | */
352 | private environmentPromise?: Promise;
353 |
354 | /**
355 | * Returns the current environment, fetching it from the API if needed.
356 | *
357 | * Calling this method concurrently while the environment is being fetched will not cause additional requests.
358 | */
359 | async getEnvironment(): Promise {
360 | if (this.offlineHandler) {
361 | return this.offlineHandler.getEnvironment();
362 | }
363 | if (this.environment) {
364 | return this.environment;
365 | }
366 | if (!this.environmentPromise) {
367 | this.environmentPromise = this.fetchEnvironment();
368 | }
369 | return this.environmentPromise;
370 | }
371 |
372 | private async getEnvironmentFromApi() {
373 | if (!this.environmentUrl) {
374 | throw new Error('`apiUrl` argument is missing or invalid.');
375 | }
376 | const environment_data = await this.getJSONResponse(this.environmentUrl, 'GET');
377 | return buildEnvironmentModel(environment_data);
378 | }
379 |
380 | private async getEnvironmentFlagsFromDocument(): Promise {
381 | const environment = await this.getEnvironment();
382 | const flags = Flags.fromFeatureStateModels({
383 | featureStates: getEnvironmentFeatureStates(environment),
384 | analyticsProcessor: this.analyticsProcessor,
385 | defaultFlagHandler: this.defaultFlagHandler
386 | });
387 | if (!!this.cache) {
388 | await this.cache.set('flags', flags);
389 | }
390 | return flags;
391 | }
392 |
393 | private async getIdentityFlagsFromDocument(
394 | identifier: string,
395 | traits: { [key: string]: any }
396 | ): Promise {
397 | const environment = await this.getEnvironment();
398 | const identityModel = this.getIdentityModel(
399 | environment,
400 | identifier,
401 | Object.keys(traits).map(key => ({
402 | key,
403 | value: traits[key]
404 | }))
405 | );
406 |
407 | const featureStates = getIdentityFeatureStates(environment, identityModel);
408 |
409 | const flags = Flags.fromFeatureStateModels({
410 | featureStates: featureStates,
411 | analyticsProcessor: this.analyticsProcessor,
412 | defaultFlagHandler: this.defaultFlagHandler,
413 | identityID: identityModel.djangoID || identityModel.compositeKey
414 | });
415 |
416 | if (!!this.cache) {
417 | await this.cache.set(`flags-${identifier}`, flags);
418 | }
419 |
420 | return flags;
421 | }
422 |
423 | private async getEnvironmentFlagsFromApi() {
424 | if (!this.environmentFlagsUrl) {
425 | throw new Error('`apiUrl` argument is missing or invalid.');
426 | }
427 | const apiFlags = await this.getJSONResponse(this.environmentFlagsUrl, 'GET');
428 | const flags = Flags.fromAPIFlags({
429 | apiFlags: apiFlags,
430 | analyticsProcessor: this.analyticsProcessor,
431 | defaultFlagHandler: this.defaultFlagHandler
432 | });
433 | if (!!this.cache) {
434 | await this.cache.set('flags', flags);
435 | }
436 | return flags;
437 | }
438 |
439 | private async getIdentityFlagsFromApi(
440 | identifier: string,
441 | traits: { [key: string]: FlagsmithTraitValue | ITraitConfig },
442 | transient: boolean = false
443 | ) {
444 | if (!this.identitiesUrl) {
445 | throw new Error('`apiUrl` argument is missing or invalid.');
446 | }
447 | const data = generateIdentitiesData(identifier, traits, transient);
448 | const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data);
449 | const flags = Flags.fromAPIFlags({
450 | apiFlags: jsonResponse['flags'],
451 | analyticsProcessor: this.analyticsProcessor,
452 | defaultFlagHandler: this.defaultFlagHandler
453 | });
454 | if (!!this.cache) {
455 | await this.cache.set(`flags-${identifier}`, flags);
456 | }
457 | return flags;
458 | }
459 |
460 | private getIdentityModel(
461 | environment: EnvironmentModel,
462 | identifier: string,
463 | traits: { key: string; value: any }[]
464 | ) {
465 | const traitModels = traits.map(trait => new TraitModel(trait.key, trait.value));
466 | let identityWithOverrides =
467 | this.identitiesWithOverridesByIdentifier?.get(identifier);
468 | if (identityWithOverrides) {
469 | identityWithOverrides.updateTraits(traitModels);
470 | return identityWithOverrides;
471 | }
472 | return new IdentityModel('0', traitModels, [], environment.apiKey, identifier);
473 | }
474 | }
475 |
476 | export default Flagsmith;
477 |
--------------------------------------------------------------------------------
/sdk/models.ts:
--------------------------------------------------------------------------------
1 | import { FeatureStateModel } from '../flagsmith-engine/features/models.js';
2 | import { AnalyticsProcessor } from './analytics.js';
3 |
4 | type FlagValue = string | number | boolean | undefined;
5 |
6 | export class BaseFlag {
7 | enabled: boolean;
8 | value: FlagValue;
9 | isDefault: boolean;
10 |
11 | constructor(value: FlagValue, enabled: boolean, isDefault: boolean) {
12 | this.value = value;
13 | this.enabled = enabled;
14 | this.isDefault = isDefault;
15 | }
16 | }
17 |
18 | export class DefaultFlag extends BaseFlag {
19 | constructor(value: FlagValue, enabled: boolean) {
20 | super(value, enabled, true);
21 | }
22 | }
23 |
24 | export class Flag extends BaseFlag {
25 | featureId: number;
26 | featureName: string;
27 |
28 | constructor(params: {
29 | value: FlagValue;
30 | enabled: boolean;
31 | isDefault?: boolean;
32 | featureId: number;
33 | featureName: string;
34 | }) {
35 | super(params.value, params.enabled, !!params.isDefault);
36 | this.featureId = params.featureId;
37 | this.featureName = params.featureName;
38 | }
39 |
40 | static fromFeatureStateModel(
41 | fsm: FeatureStateModel,
42 | identityId: number | string | undefined
43 | ): Flag {
44 | return new Flag({
45 | value: fsm.getValue(identityId),
46 | enabled: fsm.enabled,
47 | featureId: fsm.feature.id,
48 | featureName: fsm.feature.name
49 | });
50 | }
51 |
52 | static fromAPIFlag(flagData: any): Flag {
53 | return new Flag({
54 | enabled: flagData['enabled'],
55 | value: flagData['feature_state_value'] ?? flagData['value'],
56 | featureId: flagData['feature']['id'],
57 | featureName: flagData['feature']['name']
58 | });
59 | }
60 | }
61 |
62 | export class Flags {
63 | flags: { [key: string]: Flag } = {};
64 | defaultFlagHandler?: (featureName: string) => DefaultFlag;
65 | analyticsProcessor?: AnalyticsProcessor;
66 |
67 | constructor(data: {
68 | flags: { [key: string]: Flag };
69 | defaultFlagHandler?: (v: string) => DefaultFlag;
70 | analyticsProcessor?: AnalyticsProcessor;
71 | }) {
72 | this.flags = data.flags;
73 | this.defaultFlagHandler = data.defaultFlagHandler;
74 | this.analyticsProcessor = data.analyticsProcessor;
75 | }
76 |
77 | static fromFeatureStateModels(data: {
78 | featureStates: FeatureStateModel[];
79 | analyticsProcessor?: AnalyticsProcessor;
80 | defaultFlagHandler?: (f: string) => DefaultFlag;
81 | identityID?: string | number;
82 | }): Flags {
83 | const flags: { [key: string]: Flag } = {};
84 | for (const fs of data.featureStates) {
85 | flags[fs.feature.name] = Flag.fromFeatureStateModel(fs, data.identityID);
86 | }
87 | return new Flags({
88 | flags: flags,
89 | defaultFlagHandler: data.defaultFlagHandler,
90 | analyticsProcessor: data.analyticsProcessor
91 | });
92 | }
93 |
94 | static fromAPIFlags(data: {
95 | apiFlags: { [key: string]: any }[];
96 | analyticsProcessor?: AnalyticsProcessor;
97 | defaultFlagHandler?: (v: string) => DefaultFlag;
98 | }): Flags {
99 | const flags: { [key: string]: Flag } = {};
100 |
101 | for (const flagData of data.apiFlags) {
102 | flags[flagData['feature']['name']] = Flag.fromAPIFlag(flagData);
103 | }
104 |
105 | return new Flags({
106 | flags: flags,
107 | defaultFlagHandler: data.defaultFlagHandler,
108 | analyticsProcessor: data.analyticsProcessor
109 | });
110 | }
111 |
112 | allFlags(): Flag[] {
113 | return Object.values(this.flags);
114 | }
115 |
116 | getFlag(featureName: string): BaseFlag {
117 | const flag = this.flags[featureName];
118 |
119 | if (!flag) {
120 | if (this.defaultFlagHandler) {
121 | return this.defaultFlagHandler(featureName);
122 | }
123 |
124 | return { enabled: false, isDefault: true, value: undefined };
125 | }
126 |
127 | if (this.analyticsProcessor && flag.featureId) {
128 | this.analyticsProcessor.trackFeature(flag.featureName);
129 | }
130 |
131 | return flag;
132 | }
133 |
134 | getFeatureValue(featureName: string): FlagValue {
135 | return this.getFlag(featureName).value;
136 | }
137 |
138 | isFeatureEnabled(featureName: string): boolean {
139 | return this.getFlag(featureName).enabled;
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/sdk/offline_handlers.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import { buildEnvironmentModel } from '../flagsmith-engine/environments/util.js';
3 | import { EnvironmentModel } from '../flagsmith-engine/environments/models.js';
4 |
5 | export class BaseOfflineHandler {
6 | getEnvironment() : EnvironmentModel {
7 | throw new Error('Not implemented');
8 | }
9 | }
10 |
11 | export class LocalFileHandler extends BaseOfflineHandler {
12 | environment: EnvironmentModel;
13 | constructor(environment_document_path: string) {
14 | super();
15 | const environment_document = fs.readFileSync(environment_document_path, 'utf8');
16 | this.environment = buildEnvironmentModel(JSON.parse(environment_document));
17 | }
18 |
19 | getEnvironment(): EnvironmentModel {
20 | return this.environment;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/sdk/polling_manager.ts:
--------------------------------------------------------------------------------
1 | import Flagsmith from './index.js';
2 | import { Logger } from 'pino';
3 |
4 | export class EnvironmentDataPollingManager {
5 | private interval?: NodeJS.Timeout;
6 | private main: Flagsmith;
7 | private refreshIntervalSeconds: number;
8 | private logger: Logger;
9 |
10 | constructor(main: Flagsmith, refreshIntervalSeconds: number, logger: Logger) {
11 | this.main = main;
12 | this.refreshIntervalSeconds = refreshIntervalSeconds;
13 | this.logger = logger;
14 | }
15 |
16 | start() {
17 | const updateEnvironment = () => {
18 | if (this.interval) clearInterval(this.interval);
19 | this.interval = setInterval(async () => {
20 | try {
21 | await this.main.updateEnvironment();
22 | } catch (error) {
23 | this.logger.error(error, 'failed to poll environment');
24 | }
25 | }, this.refreshIntervalSeconds * 1000);
26 | };
27 | updateEnvironment();
28 | }
29 |
30 | stop() {
31 | if (!this.interval) {
32 | return;
33 | }
34 | clearInterval(this.interval);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/sdk/types.ts:
--------------------------------------------------------------------------------
1 | import { DefaultFlag, Flags } from './models.js';
2 | import { EnvironmentModel } from '../flagsmith-engine/index.js';
3 | import { Dispatcher } from 'undici-types';
4 | import { Logger } from 'pino';
5 | import { BaseOfflineHandler } from './offline_handlers.js';
6 | import { Flagsmith } from './index.js'
7 |
8 | export type IFlagsmithValue = T;
9 |
10 |
11 | /**
12 | * Stores and retrieves {@link Flags} from a cache.
13 | */
14 | export interface FlagsmithCache {
15 | /**
16 | * Retrieve the cached {@link Flags} for the given environment or identity, or `undefined` if no cached value exists.
17 | * @param key An environment ID or identity identifier, which is used as the cache key.
18 | */
19 | get(key: string): Promise;
20 |
21 | /**
22 | * Persist an environment or identity's {@link Flags} in the cache.
23 | * @param key An environment ID or identity identifier, which is used as the cache key.
24 | * @param value The {@link Flags} to be stored in the cache.
25 | */
26 | set(key: string, value: Flags): Promise;
27 | }
28 |
29 | export type Fetch = typeof fetch
30 |
31 | /**
32 | * The configuration options for a {@link Flagsmith} client.
33 | */
34 | export interface FlagsmithConfig {
35 | /**
36 | * The environment's client-side or server-side key.
37 | */
38 | environmentKey?: string;
39 | /**
40 | * The Flagsmith API URL. Set this if you are not using Flagsmith's public service, i.e. https://app.flagsmith.com.
41 | *
42 | * @default https://edge.api.flagsmith.com/api/v1/
43 | */
44 | apiUrl?: string;
45 | /**
46 | * A custom {@link Dispatcher} to use when making HTTP requests.
47 | */
48 | agent?: Dispatcher;
49 | /**
50 | * A custom {@link fetch} implementation to use when making HTTP requests.
51 | */
52 | fetch?: Fetch;
53 | /**
54 | * Custom headers to use in all HTTP requests.
55 | */
56 | customHeaders?: HeadersInit
57 | /**
58 | * The network request timeout duration, in seconds.
59 | *
60 | * @default 10
61 | */
62 | requestTimeoutSeconds?: number;
63 | /**
64 | * The amount of time, in milliseconds, to wait before retrying failed network requests.
65 | */
66 | requestRetryDelayMilliseconds?: number;
67 | /**
68 | * If enabled, flags are evaluated locally using the environment state cached in memory.
69 | *
70 | * The client will lazily fetch the environment from the Flagsmith API, and poll it every {@link environmentRefreshIntervalSeconds}.
71 | */
72 | enableLocalEvaluation?: boolean;
73 | /**
74 | * The time, in seconds, to wait before refreshing the cached environment state.
75 | * @default 60
76 | */
77 | environmentRefreshIntervalSeconds?: number;
78 | /**
79 | * How many times to retry any failed network request before giving up.
80 | * @default 3
81 | */
82 | retries?: number;
83 | /**
84 | * If enabled, the client will keep track of any flags evaluated using {@link Flags.isFeatureEnabled},
85 | * {@link Flags.getFeatureValue} or {@link Flags.getFlag}, and periodically flush this data to the Flagsmith API.
86 | */
87 | enableAnalytics?: boolean;
88 | /**
89 | * Used to return fallback values for flags when evaluation fails for any reason. If not provided and flag
90 | * evaluation fails, an error will be thrown intsead.
91 | *
92 | * @param flagKey The key of the flag that failed to evaluate.
93 | *
94 | * @example
95 | * // All flags disabled and with no value by default
96 | * const defaultHandler = () => new DefaultFlag(undefined, false)
97 | *
98 | * // Enable only VIP flags by default
99 | * const vipDefaultHandler = (key: string) => new Default(undefined, key.startsWith('vip_'))
100 | */
101 | defaultFlagHandler?: (flagKey: string) => DefaultFlag;
102 | cache?: FlagsmithCache;
103 | /**
104 | * A callback function to invoke whenever the cached environment is updated.
105 | * @param error The error that occurred when the environment state failed to update, if any.
106 | * @param result The updated environment state, if no error was thrown.
107 | */
108 | onEnvironmentChange?: (error: Error | null, result?: EnvironmentModel) => void;
109 | logger?: Logger;
110 | /**
111 | * If enabled, the client will work offline and not make any network requests. Requires {@link offlineHandler}.
112 | */
113 | offlineMode?: boolean;
114 | /**
115 | * If {@link offlineMode} is enabled, this handler is used to calculate the values of all flags.
116 | */
117 | offlineHandler?: BaseOfflineHandler;
118 | }
119 |
120 | export interface ITraitConfig {
121 | value: FlagsmithTraitValue;
122 | transient?: boolean;
123 | }
124 |
125 | export declare type FlagsmithTraitValue = IFlagsmithValue;
126 |
--------------------------------------------------------------------------------
/sdk/utils.ts:
--------------------------------------------------------------------------------
1 | import {Fetch, FlagsmithTraitValue, ITraitConfig} from './types.js';
2 | import {Dispatcher} from "undici-types";
3 |
4 | type Traits = { [key: string]: ITraitConfig | FlagsmithTraitValue };
5 |
6 | export function isTraitConfig(
7 | traitValue: ITraitConfig | FlagsmithTraitValue
8 | ): traitValue is ITraitConfig {
9 | return !!traitValue && typeof traitValue == 'object' && traitValue.value !== undefined;
10 | }
11 |
12 | export function generateIdentitiesData(identifier: string, traits: Traits, transient: boolean) {
13 | const traitsGenerated = Object.entries(traits).map(([key, value]) => {
14 | if (isTraitConfig(value)) {
15 | return {
16 | trait_key: key,
17 | trait_value: value?.value,
18 | transient: value?.transient,
19 | };
20 | } else {
21 | return {
22 | trait_key: key,
23 | trait_value: value,
24 | };
25 | }
26 | });
27 | if (transient) {
28 | return {
29 | identifier: identifier,
30 | traits: traitsGenerated,
31 | transient: true
32 | };
33 | }
34 | return {
35 | identifier: identifier,
36 | traits: traitsGenerated
37 | };
38 | }
39 |
40 | export const delay = (ms: number) =>
41 | new Promise(resolve => setTimeout(() => resolve(undefined), ms));
42 |
43 | export const retryFetch = (
44 | url: string,
45 | // built-in RequestInit type doesn't have dispatcher/agent
46 | fetchOptions: RequestInit & { dispatcher?: Dispatcher },
47 | retries: number = 3,
48 | timeoutMs: number = 10, // set an overall timeout for this function
49 | retryDelayMs: number = 1000,
50 | customFetch: Fetch,
51 | ): Promise => {
52 | const retryWrapper = async (n: number): Promise => {
53 | try {
54 | return await customFetch(url, {
55 | ...fetchOptions,
56 | signal: AbortSignal.timeout(timeoutMs)
57 | });
58 | } catch (e) {
59 | if (n > 0) {
60 | await delay(retryDelayMs);
61 | return await retryWrapper(n - 1);
62 | } else {
63 | throw e;
64 | }
65 | }
66 | };
67 | return retryWrapper(retries);
68 | };
69 |
70 | /**
71 | * A deferred promise can be resolved or rejected outside its creation scope.
72 | *
73 | * @template T The type of the value that the deferred promise will resolve to.
74 | *
75 | * @example
76 | * const deferred = new Deferred()
77 | *
78 | * // Pass the promise somewhere
79 | * performAsyncOperation(deferred.promise)
80 | *
81 | * // Resolve it when ready from anywhere
82 | * deferred.resolve("Operation completed")
83 | * deferred.failed("Error")
84 | */
85 | export class Deferred {
86 | public readonly promise: Promise;
87 | private resolvePromise!: (value: T | PromiseLike) => void;
88 | private rejectPromise!: (reason?: unknown) => void;
89 |
90 | constructor(initial?: T) {
91 | this.promise = new Promise((resolve, reject) => {
92 | this.resolvePromise = resolve;
93 | this.rejectPromise = reject;
94 | });
95 | }
96 |
97 | public resolve(value: T | PromiseLike): void {
98 | this.resolvePromise(value);
99 | }
100 |
101 | public reject(reason?: unknown): void {
102 | this.rejectPromise(reason);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/tests/engine/e2e/engine.test.ts:
--------------------------------------------------------------------------------
1 | import { getIdentityFeatureStates } from '../../../flagsmith-engine/index.js';
2 | import { EnvironmentModel } from '../../../flagsmith-engine/environments/models.js';
3 | import { buildEnvironmentModel } from '../../../flagsmith-engine/environments/util.js';
4 | import { IdentityModel } from '../../../flagsmith-engine/identities/models.js';
5 | import { buildIdentityModel } from '../../../flagsmith-engine/identities/util.js';
6 | import * as testData from '../engine-tests/engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json'
7 |
8 | function extractTestCases(
9 | data: any
10 | ): {
11 | environment: EnvironmentModel;
12 | identity: IdentityModel;
13 | response: any;
14 | }[] {
15 | const environmentModel = buildEnvironmentModel(data['environment']);
16 | const test_data = data['identities_and_responses'].map((test_case: any) => {
17 | const identity = buildIdentityModel(test_case['identity']);
18 |
19 | return {
20 | environment: environmentModel,
21 | identity: identity,
22 | response: test_case['response']
23 | };
24 | });
25 | return test_data;
26 | }
27 |
28 | test('Test Engine', () => {
29 | const testCases = extractTestCases(testData);
30 | for (const testCase of testCases) {
31 | const engine_response = getIdentityFeatureStates(testCase.environment, testCase.identity);
32 | const sortedEngineFlags = engine_response.sort((a, b) =>
33 | a.feature.name > b.feature.name ? 1 : -1
34 | );
35 | const sortedAPIFlags = testCase.response['flags'].sort((a: any, b: any) =>
36 | a.feature.name > b.feature.name ? 1 : -1
37 | );
38 |
39 | expect(sortedEngineFlags.length).toBe(sortedAPIFlags.length);
40 |
41 | for (let i = 0; i < sortedEngineFlags.length; i++) {
42 | expect(sortedEngineFlags[i].getValue(testCase.identity.djangoID)).toBe(
43 | sortedAPIFlags[i]['feature_state_value']
44 | );
45 | expect(sortedEngineFlags[i].enabled).toBe(sortedAPIFlags[i]['enabled']);
46 | }
47 | }
48 | });
49 |
--------------------------------------------------------------------------------
/tests/engine/unit/engine.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getEnvironmentFeatureState,
3 | getEnvironmentFeatureStates,
4 | getIdentityFeatureState,
5 | getIdentityFeatureStates
6 | } from '../../../flagsmith-engine/index.js';
7 | import { CONSTANTS } from '../../../flagsmith-engine/features/constants.js';
8 | import { FeatureModel, FeatureStateModel } from '../../../flagsmith-engine/features/models.js';
9 | import { TraitModel } from '../../../flagsmith-engine/identities/traits/models.js';
10 | import {
11 | environment,
12 | environmentWithSegmentOverride,
13 | feature1,
14 | getEnvironmentFeatureStateForFeature,
15 | getEnvironmentFeatureStateForFeatureByName,
16 | identity,
17 | identityInSegment,
18 | segmentConditionProperty,
19 | segmentConditionStringValue
20 | } from './utils.js';
21 |
22 | test('test_identity_get_feature_state_without_any_override', () => {
23 | const feature_state = getIdentityFeatureState(environment(), identity(), feature1().name);
24 |
25 | expect(feature_state.feature).toStrictEqual(feature1());
26 | });
27 |
28 | test('test_identity_get_feature_state_without_any_override_no_fs', () => {
29 | expect(() => {
30 | getIdentityFeatureState(environment(), identity(), 'nonExistentName');
31 | }).toThrowError('Feature State Not Found');
32 | });
33 |
34 | test('test_identity_get_all_feature_states_no_segments', () => {
35 | const env = environment();
36 | const ident = identity();
37 | const overridden_feature = new FeatureModel(3, 'overridden_feature', CONSTANTS.STANDARD);
38 |
39 | env.featureStates.push(new FeatureStateModel(overridden_feature, false, 3));
40 |
41 | ident.identityFeatures = [new FeatureStateModel(overridden_feature, true, 4)];
42 |
43 | const featureStates = getIdentityFeatureStates(env, ident);
44 |
45 | expect(featureStates.length).toBe(3);
46 | for (const featuresState of featureStates) {
47 | const environmentFeatureState = getEnvironmentFeatureStateForFeature(
48 | env,
49 | featuresState.feature
50 | );
51 | const expected =
52 | environmentFeatureState?.feature == overridden_feature
53 | ? true
54 | : environmentFeatureState?.enabled;
55 | expect(featuresState.enabled).toBe(expected);
56 | }
57 | });
58 |
59 | test('test_identity_get_all_feature_states_with_traits', () => {
60 | const trait_models = new TraitModel(segmentConditionProperty, segmentConditionStringValue);
61 |
62 | const featureStates = getIdentityFeatureStates(
63 | environmentWithSegmentOverride(),
64 | identityInSegment(),
65 | [trait_models]
66 | );
67 | expect(featureStates[0].getValue()).toBe('segment_override');
68 | });
69 |
70 | test('test_identity_get_all_feature_states_with_traits_hideDisabledFlags', () => {
71 | const trait_models = new TraitModel(segmentConditionProperty, segmentConditionStringValue);
72 |
73 | const env = environmentWithSegmentOverride();
74 | env.project.hideDisabledFlags = true;
75 |
76 | const featureStates = getIdentityFeatureStates(
77 | env,
78 | identityInSegment(),
79 | [trait_models]
80 | );
81 | expect(featureStates.length).toBe(0);
82 | });
83 |
84 | test('test_environment_get_all_feature_states', () => {
85 | const env = environment();
86 | const featureStates = getEnvironmentFeatureStates(env);
87 |
88 | expect(featureStates).toBe(env.featureStates);
89 | });
90 |
91 | test('test_environment_get_feature_states_hides_disabled_flags_if_enabled', () => {
92 | const env = environment();
93 |
94 | env.project.hideDisabledFlags = true;
95 |
96 | const featureStates = getEnvironmentFeatureStates(env);
97 |
98 | expect(featureStates).not.toBe(env.featureStates);
99 | for (const fs of featureStates) {
100 | expect(fs.enabled).toBe(true);
101 | }
102 | });
103 |
104 | test('test_environment_get_feature_state', () => {
105 | const env = environment();
106 | const feature = feature1();
107 | const featureState = getEnvironmentFeatureState(env, feature.name);
108 |
109 | expect(featureState.feature).toStrictEqual(feature);
110 | });
111 |
112 | test('test_environment_get_feature_state_raises_feature_state_not_found', () => {
113 | expect(() => {
114 | getEnvironmentFeatureState(environment(), 'not_a_feature_name');
115 | }).toThrowError('Feature State Not Found');
116 | });
117 |
--------------------------------------------------------------------------------
/tests/engine/unit/environments/builder.test.ts:
--------------------------------------------------------------------------------
1 | import { EnvironmentModel } from '../../../../flagsmith-engine/environments/models';
2 | import {
3 | buildEnvironmentAPIKeyModel,
4 | buildEnvironmentModel
5 | } from '../../../../flagsmith-engine/environments/util';
6 | import { CONSTANTS } from '../../../../flagsmith-engine/features/constants';
7 | import {
8 | FeatureStateModel,
9 | MultivariateFeatureStateValueModel
10 | } from '../../../../flagsmith-engine/features/models';
11 | import { getEnvironmentFeatureStateForFeatureByName } from '../utils';
12 |
13 | test('test_get_flags_for_environment_returns_feature_states_for_environment_dictionary', () => {
14 | const stringValue = 'foo';
15 | const featureWithStringValueName = 'feature_with_string_value';
16 |
17 | const environmentDict = {
18 | id: 1,
19 | api_key: 'api-key',
20 | project: {
21 | id: 1,
22 | name: 'test project',
23 | organisation: {
24 | id: 1,
25 | name: 'Test Org',
26 | stop_serving_flags: false,
27 | persist_trait_data: true,
28 | feature_analytics: true
29 | },
30 | hide_disabled_flags: false
31 | },
32 | feature_states: [
33 | {
34 | id: 1,
35 | enabled: true,
36 | feature_state_value: undefined,
37 | feature: { id: 1, name: 'enabled_feature', type: CONSTANTS.STANDARD }
38 | },
39 | {
40 | id: 2,
41 | enabled: false,
42 | feature_state_value: undefined,
43 | feature: { id: 2, name: 'disabled_feature', type: CONSTANTS.STANDARD }
44 | },
45 | {
46 | id: 3,
47 | enabled: true,
48 | feature_state_value: stringValue,
49 | feature: {
50 | id: 3,
51 | name: featureWithStringValueName,
52 | type: CONSTANTS.STANDARD
53 | }
54 | }
55 | ]
56 | };
57 |
58 | const environmentModel = buildEnvironmentModel(environmentDict);
59 |
60 | expect(environmentModel).toBeInstanceOf(EnvironmentModel);
61 | expect(environmentModel.featureStates.length).toBe(3);
62 | for (const fs of environmentModel.featureStates) {
63 | expect(fs).toBeInstanceOf(FeatureStateModel);
64 | }
65 | const receivedValue = getEnvironmentFeatureStateForFeatureByName(
66 | environmentModel,
67 | featureWithStringValueName
68 | )?.getValue();
69 | expect(receivedValue).toBe(stringValue);
70 | });
71 |
72 | test('test_build_environment_model_with_multivariate_flag', () => {
73 | const variate1Value = 'value-1';
74 | const variate2Value = 'value-2';
75 |
76 | const environmentJSON = {
77 | id: 1,
78 | api_key: 'api-key',
79 | project: {
80 | id: 1,
81 | name: 'test project',
82 | organisation: {
83 | id: 1,
84 | name: 'Test Org',
85 | stop_serving_flags: false,
86 | persist_trait_data: true,
87 | feature_analytics: true
88 | },
89 | hide_disabled_flags: false
90 | },
91 | feature_states: [
92 | {
93 | id: 1,
94 | enabled: true,
95 | feature_state_value: undefined,
96 | feature: {
97 | id: 1,
98 | name: 'enabled_feature',
99 | type: CONSTANTS.STANDARD
100 | },
101 | multivariate_feature_state_values: [
102 | {
103 | id: 1,
104 | percentage_allocation: 10.0,
105 | multivariate_feature_option: {
106 | value: variate1Value
107 | }
108 | },
109 | {
110 | id: 2,
111 | percentage_allocation: 10.0,
112 | multivariate_feature_option: {
113 | value: variate2Value,
114 | id: 2
115 | }
116 | }
117 | ]
118 | }
119 | ]
120 | };
121 |
122 | const environmentModel = buildEnvironmentModel(environmentJSON);
123 |
124 | expect(environmentModel).toBeInstanceOf(EnvironmentModel);
125 | expect(environmentJSON.feature_states.length).toBe(1);
126 |
127 | const fs = environmentModel.featureStates[0];
128 |
129 | for (const mvfs of fs.multivariateFeatureStateValues) {
130 | expect(mvfs).toBeInstanceOf(MultivariateFeatureStateValueModel);
131 | }
132 | });
133 |
134 | test('test_build_environment_api_key_model', () => {
135 | const environmentKeyJSON = {
136 | key: 'ser.7duQYrsasJXqdGsdaagyfU',
137 | active: true,
138 | created_at: '2022-02-07T04:58:25.969438+00:00',
139 | client_api_key: 'RQchaCQ2mYicSCAwKoAg2E',
140 | id: 10,
141 | name: 'api key 2',
142 | expires_at: undefined
143 | };
144 |
145 | const environmentAPIKeyModel = buildEnvironmentAPIKeyModel(environmentKeyJSON);
146 |
147 | expect(environmentAPIKeyModel.key).toBe(environmentKeyJSON['key']);
148 | });
149 |
--------------------------------------------------------------------------------
/tests/engine/unit/environments/models.test.ts:
--------------------------------------------------------------------------------
1 | import { EnvironmentAPIKeyModel } from '../../../../flagsmith-engine/environments/models';
2 |
3 | test('test_environment_api_key_model_is_valid_is_true_for_non_expired_active_key', () => {
4 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel(
5 | 1,
6 | 'ser.random_key',
7 | Date.now(),
8 | 'test_key',
9 | 'test_key'
10 | );
11 | expect(environmentAPIKeyModel.isValid()).toBe(true);
12 | });
13 |
14 | test('test_environment_api_key_model_is_valid_is_true_for_non_expired_active_key_with_expired_date_in_future', () => {
15 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel(
16 | 1,
17 | 'ser.random_key',
18 | Date.now(),
19 | 'test_key',
20 | 'test_key',
21 | Date.now() + 1000 * 60 * 60 * 24 * 2
22 | );
23 | expect(environmentAPIKeyModel.isValid()).toBe(true);
24 | });
25 |
26 | test('test_environment_api_key_model_is_valid_is_false_for_expired_active_key', () => {
27 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel(
28 | 1,
29 | 'ser.random_key',
30 | Date.now() - 1000 * 60 * 60 * 24 * 2,
31 | 'test_key',
32 | 'test_key',
33 | Date.now()
34 | );
35 | expect(environmentAPIKeyModel.isValid()).toBe(false);
36 | });
37 |
38 | test('test_environment_api_key_model_is_valid_is_false_for_non_expired_inactive_key', () => {
39 | const environmentAPIKeyModel = new EnvironmentAPIKeyModel(
40 | 1,
41 | 'ser.random_key',
42 | Date.now(),
43 | 'test_key',
44 | 'test_key'
45 | );
46 |
47 | environmentAPIKeyModel.active = false;
48 | expect(environmentAPIKeyModel.isValid()).toBe(false);
49 | });
50 |
--------------------------------------------------------------------------------
/tests/engine/unit/features/models.test.ts:
--------------------------------------------------------------------------------
1 | import { CONSTANTS } from '../../../../flagsmith-engine/features/constants';
2 | import {
3 | FeatureModel,
4 | FeatureStateModel,
5 | MultivariateFeatureOptionModel,
6 | MultivariateFeatureStateValueModel
7 | } from '../../../../flagsmith-engine/features/models';
8 | import { feature1 } from '../utils';
9 |
10 | test('test_compare_feature_model', () => {
11 | const fm1 = new FeatureModel(1, 'a', 'test');
12 | const fm2 = new FeatureModel(1, 'a', 'test');
13 | expect(fm1.eq(fm2)).toBe(true);
14 | });
15 |
16 | test('test_initializing_feature_state_creates_default_feature_state_uuid', () => {
17 | const featureState = new FeatureStateModel(feature1(), true, 1);
18 | expect(featureState.featurestateUUID).toBeDefined();
19 | });
20 |
21 | test('test_initializing_multivariate_feature_state_value_creates_default_uuid', () => {
22 | const mvFeatureOption = new MultivariateFeatureOptionModel('value');
23 | const mvFsValueModel = new MultivariateFeatureStateValueModel(mvFeatureOption, 10, 1);
24 |
25 | expect(mvFsValueModel.mvFsValueUuid).toBeDefined();
26 | });
27 |
28 | test('test_feature_state_get_value_no_mv_values', () => {
29 | const value = 'foo';
30 | const featureState = new FeatureStateModel(feature1(), true, 1);
31 |
32 | featureState.setValue(value);
33 |
34 | expect(featureState.getValue()).toBe(value);
35 | expect(featureState.getValue(1)).toBe(value);
36 | });
37 |
38 | test('test_feature_state_get_value_mv_values', () => {
39 | const mvFeatureControlValue = 'control';
40 | const mvFeatureValue1 = 'foo';
41 | const mvFeatureValue2 = 'bar';
42 |
43 | const cases = [
44 | [10, mvFeatureValue1],
45 | [40, mvFeatureValue2],
46 | [70, mvFeatureControlValue]
47 | ];
48 |
49 | for (const testCase of cases) {
50 | const myFeature = new FeatureModel(1, 'mv_feature', CONSTANTS.STANDARD);
51 |
52 | const mvFeatureOption1 = new MultivariateFeatureOptionModel(mvFeatureValue1, 1);
53 | const mvFeatureOption2 = new MultivariateFeatureOptionModel(mvFeatureValue2, 2);
54 |
55 | const mvFeatureStateValue1 = new MultivariateFeatureStateValueModel(
56 | mvFeatureOption1,
57 | 30,
58 | 1
59 | );
60 | const mvFeatureStateValue2 = new MultivariateFeatureStateValueModel(
61 | mvFeatureOption2,
62 | 30,
63 | 2
64 | );
65 |
66 | const mvFeatureState = new FeatureStateModel(myFeature, true, 1);
67 | mvFeatureState.multivariateFeatureStateValues = [
68 | mvFeatureStateValue1,
69 | mvFeatureStateValue2,
70 | ];
71 |
72 | mvFeatureState.setValue(mvFeatureControlValue);
73 |
74 | expect(mvFeatureState.getValue("test")).toBe(mvFeatureValue2);
75 | }
76 | });
77 |
--------------------------------------------------------------------------------
/tests/engine/unit/identities/identities_builders.test.ts:
--------------------------------------------------------------------------------
1 | import { FeatureStateModel } from '../../../../flagsmith-engine/features/models';
2 | import { IdentityModel } from '../../../../flagsmith-engine/identities/models';
3 | import { buildIdentityModel } from '../../../../flagsmith-engine/identities/util';
4 |
5 | test('test_build_identity_model_from_dictionary_no_feature_states', () => {
6 | const identity = {
7 | id: 1,
8 | identifier: 'test-identity',
9 | environment_api_key: 'api-key',
10 | created_date: '2021-08-22T06:25:23.406995Z',
11 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }]
12 | };
13 |
14 | const identityModel = buildIdentityModel(identity);
15 |
16 | expect(identityModel.identityFeatures?.length).toBe(0);
17 | expect(identityModel.identityTraits.length).toBe(1);
18 | });
19 |
20 | test('test_build_identity_model_from_dictionary_uses_identity_feature_list_for_identity_features', () => {
21 | const identity_dict = {
22 | id: 1,
23 | identifier: 'test-identity',
24 | environment_api_key: 'api-key',
25 | created_date: '2021-08-22T06:25:23.406995Z',
26 | identity_features: [
27 | {
28 | id: 1,
29 | feature: {
30 | id: 1,
31 | name: 'test_feature',
32 | type: 'STANDARD'
33 | },
34 | enabled: true,
35 | feature_state_value: 'some-value'
36 | }
37 | ]
38 | };
39 |
40 | const identityModel = buildIdentityModel(identity_dict);
41 |
42 | expect(identityModel.identityFeatures?.length).toBe(1);
43 | });
44 |
45 | test('test_build_identity_model_from_dictionary_uses_identity_feature_list_for_identity_features', () => {
46 | const identity_dict = {
47 | id: 1,
48 | identifier: 'test-identity',
49 | environment_api_key: 'api-key',
50 | created_date: '2021-08-22T06:25:23.406995Z',
51 | };
52 |
53 | const identityModel = buildIdentityModel(identity_dict);
54 |
55 | expect(identityModel.identityFeatures?.length).toBe(0);
56 | });
57 |
58 | test('test_build_build_identity_model_from_dict_creates_identity_uuid', () => {
59 | const identity_model = buildIdentityModel({
60 | identifier: 'test_user',
61 | environment_api_key: 'some_key'
62 | });
63 | expect(identity_model.identityUuid).not.toBe(undefined);
64 | });
65 |
66 | test('test_build_identity_model_from_dictionary_with_feature_states', () => {
67 | const identity_dict = {
68 | id: 1,
69 | identifier: 'test-identity',
70 | environment_api_key: 'api-key',
71 | created_date: '2021-08-22T06:25:23.406995Z',
72 | identity_features: [
73 | {
74 | id: 1,
75 | feature: {
76 | id: 1,
77 | name: 'test_feature',
78 | type: 'STANDARD'
79 | },
80 | enabled: true,
81 | feature_state_value: 'some-value'
82 | }
83 | ]
84 | };
85 |
86 | const identityModel = buildIdentityModel(identity_dict);
87 |
88 | expect(identityModel).toBeInstanceOf(IdentityModel);
89 | expect(identityModel.identityFeatures?.length).toBe(1);
90 | expect(identityModel?.identityFeatures![0]).toBeInstanceOf(FeatureStateModel);
91 | });
92 |
93 | test('test_identity_dict_created_using_model_can_convert_back_to_model', () => {
94 | const identityModel = new IdentityModel('some_key', [], [], '', '');
95 |
96 | const identityJSON = JSON.parse(JSON.stringify(identityModel));
97 | expect(buildIdentityModel(identityJSON)).toBeInstanceOf(IdentityModel);
98 | });
99 |
--------------------------------------------------------------------------------
/tests/engine/unit/identities/identities_models.test.ts:
--------------------------------------------------------------------------------
1 | import { FeatureStateModel } from '../../../../flagsmith-engine/features/models';
2 | import { IdentityModel } from '../../../../flagsmith-engine/identities/models';
3 | import { TraitModel } from '../../../../flagsmith-engine/identities/traits/models';
4 | import { buildIdentityModel } from '../../../../flagsmith-engine/identities/util';
5 | import { feature1, identityInSegment } from '../utils';
6 |
7 | test('test_composite_key', () => {
8 | const identity = {
9 | id: 1,
10 | identifier: 'test-identity',
11 | environment_api_key: 'api-key',
12 | created_date: '2021-08-22T06:25:23.406995Z',
13 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }]
14 | };
15 |
16 | const identityModel = buildIdentityModel(identity);
17 |
18 | expect(identityModel.compositeKey).toBe('api-key_test-identity');
19 | });
20 |
21 | test('test_identiy_model_creates_default_identity_uuid', () => {
22 | const identity = {
23 | id: 1,
24 | identifier: 'test-identity',
25 | environment_api_key: 'api-key',
26 | created_date: '2021-08-22T06:25:23.406995Z',
27 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }]
28 | };
29 |
30 | const identityModel = buildIdentityModel(identity);
31 |
32 | expect(identityModel.identityUuid).toBeDefined();
33 | });
34 |
35 | test('test_generate_composite_key', () => {
36 | const identity = {
37 | id: 1,
38 | identifier: 'test-identity',
39 | environment_api_key: 'api-key',
40 | created_date: '2021-08-22T06:25:23.406995Z',
41 | identity_traits: [{ trait_key: 'trait_key', trait_value: 'trait_value' }]
42 | };
43 |
44 | const identityModel = buildIdentityModel(identity);
45 |
46 | expect(IdentityModel.generateCompositeKey('api-key', 'test-identity')).toBe(
47 | 'api-key_test-identity'
48 | );
49 | });
50 |
51 | test('test_update_traits_remove_traits_with_none_value', () => {
52 | const ident = identityInSegment();
53 |
54 | const trait_key = ident.identityTraits[0].traitKey;
55 | const trait_to_remove = new TraitModel(trait_key, undefined);
56 |
57 | ident.updateTraits([trait_to_remove]);
58 |
59 | expect(ident.identityTraits.length).toBe(0);
60 | });
61 |
62 | test('test_update_identity_traits_updates_trait_value', () => {
63 | const identity = identityInSegment();
64 |
65 | const traitKey = identity.identityTraits[0].traitKey;
66 | const traitValue = 'updated_trait_value';
67 | const traitToUpdate = new TraitModel(traitKey, traitValue);
68 |
69 | identity.updateTraits([traitToUpdate]);
70 |
71 | expect(identity.identityTraits.length).toBe(1);
72 | expect(identity.identityTraits[0]).toBe(traitToUpdate);
73 | });
74 |
75 | test('test_update_traits_adds_new_traits', () => {
76 | const identity = identityInSegment();
77 |
78 | const newTrait = new TraitModel('new_key', 'foobar');
79 |
80 | identity.updateTraits([newTrait]);
81 |
82 | expect(identity.identityTraits.length).toBe(2);
83 | expect(identity.identityTraits).toContain(newTrait);
84 | });
85 |
86 | test('test_append_feature_state', () => {
87 | const ident = identityInSegment();
88 |
89 | const fs1 = new FeatureStateModel(feature1(), false, 1);
90 |
91 | ident.identityFeatures.push(fs1);
92 |
93 | expect(ident.identityFeatures).toContain(fs1);
94 | });
95 |
--------------------------------------------------------------------------------
/tests/engine/unit/organization/models.test.ts:
--------------------------------------------------------------------------------
1 | import { buildOrganizationModel } from '../../../../flagsmith-engine/organisations/util';
2 |
3 | test('Test builder', () => {
4 | const model = buildOrganizationModel({
5 | persist_trait_data: true,
6 | name: 'Flagsmith',
7 | feature_analytics: false,
8 | stop_serving_flags: false,
9 | id: 13
10 | });
11 | expect(model.uniqueSlug).toBe('13-Flagsmith');
12 | });
13 |
--------------------------------------------------------------------------------
/tests/engine/unit/segments/segment_evaluators.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ALL_RULE,
3 | CONDITION_OPERATORS,
4 | PERCENTAGE_SPLIT,
5 | } from '../../../../flagsmith-engine/segments/constants.js';
6 | import {SegmentConditionModel} from '../../../../flagsmith-engine/segments/models.js';
7 | import {traitsMatchSegmentCondition, evaluateIdentityInSegment} from "../../../../flagsmith-engine/segments/evaluators.js";
8 | import {TraitModel, IdentityModel} from "../../../../flagsmith-engine/index.js";
9 | import {environment} from "../utils.js";
10 | import { buildSegmentModel } from '../../../../flagsmith-engine/segments/util.js';
11 | import { getHashedPercentateForObjIds } from '../../../../flagsmith-engine/utils/hashing/index.js';
12 |
13 |
14 | // todo: work out how to implement this in a test function or before hook
15 | vi.mock('../../../../flagsmith-engine/utils/hashing', () => ({
16 | getHashedPercentateForObjIds: vi.fn(() => 1)
17 | }));
18 |
19 |
20 | let traitExistenceTestCases: [string, string | null | undefined, string | null | undefined, TraitModel [],boolean][] = [
21 | [CONDITION_OPERATORS.IS_SET,'foo', null,[] , false],
22 | [CONDITION_OPERATORS.IS_SET, 'foo',undefined , [new TraitModel('foo','bar')], true],
23 | [CONDITION_OPERATORS.IS_SET, 'foo',undefined , [new TraitModel('foo','bar'), new TraitModel('fooBaz','baz')], true],
24 | [CONDITION_OPERATORS.IS_NOT_SET, 'foo', undefined, [], true],
25 | [CONDITION_OPERATORS.IS_NOT_SET, 'foo', null, [new TraitModel('foo','bar')], false],
26 | [CONDITION_OPERATORS.IS_NOT_SET, 'foo', null, [new TraitModel('foo','bar'), new TraitModel('fooBaz','baz')], false]
27 | ];
28 |
29 | test('test_traits_match_segment_condition_for_trait_existence_operators', () => {
30 | for (const testCase of traitExistenceTestCases) {
31 | const [operator, conditionProperty, conditionValue, traits, expectedResult] = testCase
32 | let segmentModel = new SegmentConditionModel(operator, conditionValue, conditionProperty)
33 | expect(
34 | traitsMatchSegmentCondition (traits, segmentModel, 'any','any')
35 | ).toBe(expectedResult);
36 | }
37 | });
38 |
39 |
40 | test('evaluateIdentityInSegment uses django ID for hashed percentage when present', () => {
41 | var identityModel = new IdentityModel(Date.now().toString(), [], [], environment().apiKey, 'identity_1', undefined, 1);
42 | const segmentDefinition = {
43 | id: 1,
44 | name: 'percentage_split_segment',
45 | rules: [
46 | {
47 | type: ALL_RULE,
48 | conditions: [
49 | {
50 | operator: PERCENTAGE_SPLIT,
51 | property_: null,
52 | value: "10"
53 | }
54 | ],
55 | rules: []
56 | }
57 | ],
58 | feature_states: []
59 | };
60 | const segmentModel = buildSegmentModel(segmentDefinition);
61 |
62 | var result = evaluateIdentityInSegment(identityModel, segmentModel);
63 |
64 | expect(result).toBe(true);
65 | expect(getHashedPercentateForObjIds).toHaveBeenCalledTimes(1)
66 | expect(getHashedPercentateForObjIds).toHaveBeenCalledWith([segmentModel.id, identityModel.djangoID])
67 | });
68 |
--------------------------------------------------------------------------------
/tests/engine/unit/segments/segments_model.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ALL_RULE,
3 | ANY_RULE,
4 | CONDITION_OPERATORS,
5 | NONE_RULE
6 | } from '../../../../flagsmith-engine/segments/constants';
7 | import {
8 | all,
9 | any,
10 | SegmentConditionModel,
11 | SegmentRuleModel
12 | } from '../../../../flagsmith-engine/segments/models';
13 |
14 | const conditionMatchCases: [string, string | number | boolean | null, string, boolean][] = [
15 | [CONDITION_OPERATORS.EQUAL, 'bar', 'bar', true],
16 | [CONDITION_OPERATORS.EQUAL, 'bar', 'baz', false],
17 | [CONDITION_OPERATORS.EQUAL, 1, '1', true],
18 | [CONDITION_OPERATORS.EQUAL, 1, '2', false],
19 | [CONDITION_OPERATORS.EQUAL, true, 'true', true],
20 | [CONDITION_OPERATORS.EQUAL, false, 'false', true],
21 | [CONDITION_OPERATORS.EQUAL, false, 'true', false],
22 | [CONDITION_OPERATORS.EQUAL, true, 'false', false],
23 | [CONDITION_OPERATORS.EQUAL, 1.23, '1.23', true],
24 | [CONDITION_OPERATORS.EQUAL, 1.23, '4.56', false],
25 | [CONDITION_OPERATORS.GREATER_THAN, 2, '1', true],
26 | [CONDITION_OPERATORS.GREATER_THAN, 1, '1', false],
27 | [CONDITION_OPERATORS.GREATER_THAN, 0, '1', false],
28 | [CONDITION_OPERATORS.GREATER_THAN, 2.1, '2.0', true],
29 | [CONDITION_OPERATORS.GREATER_THAN, 2.1, '2.1', false],
30 | [CONDITION_OPERATORS.GREATER_THAN, 2.0, '2.1', false],
31 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2, '1', true],
32 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 1, '1', true],
33 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 0, '1', false],
34 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2.1, '2.0', true],
35 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2.1, '2.1', true],
36 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, 2.0, '2.1', false],
37 | [CONDITION_OPERATORS.LESS_THAN, 1, '2', true],
38 | [CONDITION_OPERATORS.LESS_THAN, 1, '1', false],
39 | [CONDITION_OPERATORS.LESS_THAN, 1, '0', false],
40 | [CONDITION_OPERATORS.LESS_THAN, 2.0, '2.1', true],
41 | [CONDITION_OPERATORS.LESS_THAN, 2.1, '2.1', false],
42 | [CONDITION_OPERATORS.LESS_THAN, 2.1, '2.0', false],
43 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 1, '2', true],
44 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 1, '1', true],
45 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 1, '0', false],
46 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 2.0, '2.1', true],
47 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 2.1, '2.1', true],
48 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, 2.1, '2.0', false],
49 | [CONDITION_OPERATORS.NOT_EQUAL, 'bar', 'baz', true],
50 | [CONDITION_OPERATORS.NOT_EQUAL, 'bar', 'bar', false],
51 | [CONDITION_OPERATORS.NOT_EQUAL, 1, '2', true],
52 | [CONDITION_OPERATORS.NOT_EQUAL, 1, '1', false],
53 | [CONDITION_OPERATORS.NOT_EQUAL, true, 'false', true],
54 | [CONDITION_OPERATORS.NOT_EQUAL, false, 'true', true],
55 | [CONDITION_OPERATORS.NOT_EQUAL, false, 'false', false],
56 | [CONDITION_OPERATORS.NOT_EQUAL, true, 'true', false],
57 | [CONDITION_OPERATORS.CONTAINS, 'bar', 'b', true],
58 | [CONDITION_OPERATORS.CONTAINS, 'bar', 'bar', true],
59 | [CONDITION_OPERATORS.CONTAINS, 'bar', 'baz', false],
60 | [CONDITION_OPERATORS.CONTAINS, null, 'foo', false],
61 | [CONDITION_OPERATORS.NOT_CONTAINS, 'bar', 'b', false],
62 | [CONDITION_OPERATORS.NOT_CONTAINS, 'bar', 'bar', false],
63 | [CONDITION_OPERATORS.NOT_CONTAINS, 'bar', 'baz', true],
64 | [CONDITION_OPERATORS.NOT_CONTAINS, null, 'foo', false],
65 | [CONDITION_OPERATORS.REGEX, 'foo', '[a-z]+', true],
66 | [CONDITION_OPERATORS.REGEX, 'FOO', '[a-z]+', false],
67 | [CONDITION_OPERATORS.REGEX, null, '[a-z]+', false],
68 | [CONDITION_OPERATORS.EQUAL, "1.0.0", "1.0.0:semver", true],
69 | [CONDITION_OPERATORS.EQUAL, "1.0.0", "1.0.0:semver", true],
70 | [CONDITION_OPERATORS.EQUAL, "1.0.0", "1.0.1:semver", false],
71 | [CONDITION_OPERATORS.NOT_EQUAL, "1.0.0", "1.0.0:semver", false],
72 | [CONDITION_OPERATORS.NOT_EQUAL, "1.0.0", "1.0.1:semver", true],
73 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.1", "1.0.0:semver", true],
74 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.0", "1.0.0-beta:semver", true],
75 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.1", "1.2.0:semver", false],
76 | [CONDITION_OPERATORS.GREATER_THAN, "1.0.1", "1.0.1:semver", false],
77 | [CONDITION_OPERATORS.GREATER_THAN, "1.2.4", "1.2.3-pre.2+build.4:semver", true],
78 | [CONDITION_OPERATORS.LESS_THAN, "1.0.0", "1.0.1:semver", true],
79 | [CONDITION_OPERATORS.LESS_THAN, "1.0.0", "1.0.0:semver", false],
80 | [CONDITION_OPERATORS.LESS_THAN, "1.0.1", "1.0.0:semver", false],
81 | [CONDITION_OPERATORS.LESS_THAN, "1.0.0-rc.2", "1.0.0-rc.3:semver", true],
82 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, "1.0.1", "1.0.0:semver", true],
83 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, "1.0.1", "1.2.0:semver", false],
84 | [CONDITION_OPERATORS.GREATER_THAN_INCLUSIVE, "1.0.1", "1.0.1:semver", true],
85 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, "1.0.0", "1.0.1:semver", true],
86 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, "1.0.0", "1.0.0:semver", true],
87 | [CONDITION_OPERATORS.LESS_THAN_INCLUSIVE, "1.0.1", "1.0.0:semver", false],
88 | [CONDITION_OPERATORS.MODULO, 1, "2|0", false],
89 | [CONDITION_OPERATORS.MODULO, 2, "2|0", true],
90 | [CONDITION_OPERATORS.MODULO, 3, "2|0", false],
91 | [CONDITION_OPERATORS.MODULO, 34.2, "4|3", false],
92 | [CONDITION_OPERATORS.MODULO, 35.0, "4|3", true],
93 | [CONDITION_OPERATORS.MODULO, "foo", "4|3", false],
94 | [CONDITION_OPERATORS.MODULO, 35.0, "foo|bar", false],
95 | [CONDITION_OPERATORS.IN, "foo", "", false],
96 | [CONDITION_OPERATORS.IN, "foo", "foo, bar", true],
97 | [CONDITION_OPERATORS.IN, "foo", "foo", true],
98 | [CONDITION_OPERATORS.IN, 1, "1,2,3,4", true],
99 | [CONDITION_OPERATORS.IN, 1, "", false],
100 | [CONDITION_OPERATORS.IN, 1, "1", true],
101 | ['BAD_OP', 'a', 'a', false]
102 | ];
103 |
104 | test('test_segment_condition_matches_trait_value', () => {
105 | for (const testCase of conditionMatchCases) {
106 | const [operator, traitValue, conditionValue, expectedResult] = testCase
107 | expect(
108 | new SegmentConditionModel(operator, conditionValue, 'foo').matchesTraitValue(
109 | traitValue
110 | )
111 | ).toBe(expectedResult);
112 | }
113 | });
114 |
115 | test('test_segment_rule_none', () => {
116 | const testCases: [boolean[], boolean][] = [
117 | [[], true],
118 | [[false], true],
119 | [[false, false], true],
120 | [[false, true], false],
121 | [[true, true], false]
122 | ];
123 |
124 | for (const testCase of testCases) {
125 | expect(SegmentRuleModel.none(testCase[0])).toBe(testCase[1]);
126 | }
127 | });
128 |
129 | test('test_segment_rule_matching_function', () => {
130 | const testCases: [string, CallableFunction][] = [
131 | [ALL_RULE, all],
132 | [ANY_RULE, any],
133 | [NONE_RULE, SegmentRuleModel.none]
134 | ];
135 |
136 | for (const testCase of testCases) {
137 | expect(new SegmentRuleModel(testCase[0]).matchingFunction()).toBe(testCase[1]);
138 | }
139 | });
140 |
--------------------------------------------------------------------------------
/tests/engine/unit/segments/util.ts:
--------------------------------------------------------------------------------
1 | import { ALL_RULE, ANY_RULE, EQUAL } from '../../../../flagsmith-engine/segments/constants';
2 | import { SegmentModel } from '../../../../flagsmith-engine/segments/models';
3 | import { buildSegmentModel } from '../../../../flagsmith-engine/segments/util';
4 |
5 | export const traitKey1 = 'email';
6 | export const traitValue1 = 'user@example.com';
7 |
8 | export const traitKey2 = 'num_purchase';
9 | export const traitValue2 = '12';
10 |
11 | export const traitKey_3 = 'date_joined';
12 | export const traitValue3 = '2021-01-01';
13 |
14 | export const emptySegment = new SegmentModel(1, 'empty_segment');
15 |
16 | export const segmentSingleCondition = buildSegmentModel({
17 | id: 2,
18 | name: 'segment_one_condition',
19 | rules: [
20 | {
21 | type: ALL_RULE,
22 | conditions: [
23 | {
24 | operator: EQUAL,
25 | property_: traitKey1,
26 | value: traitValue1
27 | }
28 | ]
29 | }
30 | ]
31 | });
32 |
33 | export const segmentMultipleConditionsAll = buildSegmentModel({
34 | id: 3,
35 | name: 'segment_multiple_conditions_all',
36 | rules: [
37 | {
38 | type: ALL_RULE,
39 | conditions: [
40 | {
41 | operator: EQUAL,
42 | property_: traitKey1,
43 | value: traitValue1
44 | },
45 | {
46 | operator: EQUAL,
47 | property_: traitKey2,
48 | value: traitValue2
49 | }
50 | ]
51 | }
52 | ]
53 | });
54 |
55 | export const segmentMultipleConditionsAny = buildSegmentModel({
56 | id: 4,
57 | name: 'segment_multiple_conditions_any',
58 | rules: [
59 | {
60 | type: ANY_RULE,
61 | conditions: [
62 | {
63 | operator: EQUAL,
64 | property_: traitKey1,
65 | value: traitValue1
66 | },
67 | {
68 | operator: EQUAL,
69 | property_: traitKey2,
70 | value: traitValue2
71 | }
72 | ]
73 | }
74 | ]
75 | });
76 |
77 | export const segmentNestedRules = buildSegmentModel({
78 | id: 5,
79 | name: 'segment_nested_rules_all',
80 | rules: [
81 | {
82 | type: ALL_RULE,
83 | rules: [
84 | {
85 | type: ALL_RULE,
86 | conditions: [
87 | {
88 | operator: EQUAL,
89 | property_: traitKey1,
90 | value: traitValue1
91 | },
92 | {
93 | operator: EQUAL,
94 | property_: traitKey2,
95 | value: traitValue2
96 | }
97 | ]
98 | },
99 | {
100 | type: ALL_RULE,
101 | conditions: [
102 | {
103 | operator: EQUAL,
104 | property_: traitKey_3,
105 | value: traitValue3
106 | }
107 | ]
108 | }
109 | ]
110 | }
111 | ]
112 | });
113 |
114 | export const segmentConditionsAndNestedRules = buildSegmentModel({
115 | id: 6,
116 | name: 'segment_multiple_conditions_all_and_nested_rules',
117 | rules: [
118 | {
119 | type: ALL_RULE,
120 | conditions: [
121 | {
122 | operator: EQUAL,
123 | property_: traitKey1,
124 | value: traitValue1
125 | }
126 | ],
127 | rules: [
128 | {
129 | type: ALL_RULE,
130 | conditions: [
131 | {
132 | operator: EQUAL,
133 | property_: traitKey2,
134 | value: traitValue2
135 | }
136 | ]
137 | },
138 | {
139 | type: ALL_RULE,
140 | conditions: [
141 | {
142 | operator: EQUAL,
143 | property_: traitKey_3,
144 | value: traitValue3
145 | }
146 | ]
147 | }
148 | ]
149 | }
150 | ]
151 | });
152 |
--------------------------------------------------------------------------------
/tests/engine/unit/utils.ts:
--------------------------------------------------------------------------------
1 | import { EnvironmentModel } from '../../../flagsmith-engine/environments/models';
2 | import { CONSTANTS } from '../../../flagsmith-engine/features/constants';
3 | import { FeatureModel, FeatureStateModel } from '../../../flagsmith-engine/features/models';
4 | import { IdentityModel } from '../../../flagsmith-engine/identities/models';
5 | import { TraitModel } from '../../../flagsmith-engine/identities/traits/models';
6 | import { OrganisationModel } from '../../../flagsmith-engine/organisations/models';
7 | import { ProjectModel } from '../../../flagsmith-engine/projects/models';
8 | import { ALL_RULE, EQUAL } from '../../../flagsmith-engine/segments/constants';
9 | import {
10 | SegmentConditionModel,
11 | SegmentModel,
12 | SegmentRuleModel
13 | } from '../../../flagsmith-engine/segments/models';
14 |
15 | export const segmentConditionProperty = 'foo';
16 | export const segmentConditionStringValue = 'bar';
17 |
18 | export function segmentCondition() {
19 | return new SegmentConditionModel(EQUAL, segmentConditionStringValue, segmentConditionProperty);
20 | }
21 |
22 | export function traitMatchingSegment() {
23 | return new TraitModel(segmentCondition().property_ as string, segmentCondition().value);
24 | }
25 |
26 | export function organisation() {
27 | return new OrganisationModel(1, 'test Org', true, false, true);
28 | }
29 |
30 | export function segmentRule() {
31 | const rule = new SegmentRuleModel(ALL_RULE);
32 | rule.conditions = [segmentCondition()];
33 | return rule;
34 | }
35 |
36 | export function segment() {
37 | const segment = new SegmentModel(1, 'test name');
38 | segment.rules = [segmentRule()];
39 | return segment;
40 | }
41 |
42 | export function project() {
43 | const project = new ProjectModel(1, 'test project', false, organisation());
44 | project.segments = [segment()];
45 | return project;
46 | }
47 |
48 | export function feature1() {
49 | return new FeatureModel(1, 'feature_1', CONSTANTS.STANDARD);
50 | }
51 |
52 | export function feature2() {
53 | return new FeatureModel(2, 'feature_2', CONSTANTS.STANDARD);
54 | }
55 |
56 | export function environment() {
57 | const env = new EnvironmentModel(1, 'api-key', project());
58 |
59 | env.featureStates = [
60 | new FeatureStateModel(feature1(), true, 1),
61 | new FeatureStateModel(feature2(), false, 2)
62 | ];
63 |
64 | return env;
65 | }
66 |
67 | export function identity() {
68 | return new IdentityModel(Date.now().toString(), [], [], environment().apiKey, 'identity_1');
69 | }
70 |
71 | export function identityInSegment() {
72 | const identity = new IdentityModel(
73 | Date.now().toString(),
74 | [],
75 | [],
76 | environment().apiKey,
77 | 'identity_2'
78 | );
79 |
80 | identity.identityTraits = [traitMatchingSegment()];
81 |
82 | return identity;
83 | }
84 |
85 | export function getEnvironmentFeatureStateForFeatureByName(
86 | environment: EnvironmentModel,
87 | feature_name: string
88 | ): FeatureStateModel | undefined {
89 | const features = environment.featureStates.filter(fs => fs.feature.name === feature_name);
90 | return features[0];
91 | }
92 |
93 | export function getEnvironmentFeatureStateForFeature(
94 | environment: EnvironmentModel,
95 | feature: FeatureModel
96 | ): FeatureStateModel | undefined {
97 | const f = environment.featureStates.find(f => f.feature === feature);
98 | return f;
99 | }
100 |
101 | export function segmentOverrideFs() {
102 | const fs = new FeatureStateModel(feature1(), false, 4);
103 | fs.setValue('segment_override');
104 | return fs;
105 | }
106 |
107 | export function environmentWithSegmentOverride(): EnvironmentModel {
108 | const env = environment();
109 | const segm = segment();
110 |
111 | segm.featureStates.push(segmentOverrideFs());
112 | env.project.segments.push(segm);
113 | return env;
114 | }
115 |
--------------------------------------------------------------------------------
/tests/engine/unit/utils/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { randomUUID as uuidv4 } from 'node:crypto';
2 | import { getHashedPercentateForObjIds } from '../../../../flagsmith-engine/utils/hashing/index.js';
3 |
4 | describe('getHashedPercentageForObjIds', () => {
5 | it.each([
6 | [[12, 93]],
7 | [[uuidv4(), 99]],
8 | [[99, uuidv4()]],
9 | [[uuidv4(), uuidv4()]]
10 | ])('returns x where 0 <= x < 100', (objIds: (string|number)[]) => {
11 | let result = getHashedPercentateForObjIds(objIds);
12 | expect(result).toBeLessThan(100);
13 | expect(result).toBeGreaterThanOrEqual(0);
14 | });
15 |
16 | it.each([
17 | [[12, 93]],
18 | [[uuidv4(), 99]],
19 | [[99, uuidv4()]],
20 | [[uuidv4(), uuidv4()]]
21 | ])('returns the same value each time', (objIds: (string|number)[]) => {
22 | let resultOne = getHashedPercentateForObjIds(objIds);
23 | let resultTwo = getHashedPercentateForObjIds(objIds);
24 | expect(resultOne).toEqual(resultTwo);
25 | })
26 |
27 | it('is unique for different object ids', () => {
28 | let resultOne = getHashedPercentateForObjIds([14, 106]);
29 | let resultTwo = getHashedPercentateForObjIds([53, 200]);
30 | expect(resultOne).not.toEqual(resultTwo);
31 | })
32 |
33 | it('is evenly distributed', () => {
34 | // copied from python test here:
35 | // https://github.com/Flagsmith/flagsmith-engine/blob/main/tests/unit/utils/test_utils_hashing.py#L56
36 | const testSample = 500;
37 | const numTestBuckets = 50;
38 | const testBucketSize = Math.floor(testSample / numTestBuckets)
39 | const errorFactor = 0.1
40 |
41 | // Given
42 | let objectIdPairs = Array.from(Array(testSample).keys()).flatMap(d => Array.from(Array(testSample).keys()).map(e => [d, e].flat()))
43 |
44 | // When
45 | let values = objectIdPairs.map((objIds) => getHashedPercentateForObjIds(objIds));
46 |
47 | // Then
48 | for (let i = 0; i++; i < numTestBuckets) {
49 | let bucketStart = i * testBucketSize;
50 | let bucketEnd = (i + 1) * testBucketSize;
51 | let bucketValueLimit = Math.min(
52 | (i + 1) / numTestBuckets + errorFactor + ((i + 1) / numTestBuckets),
53 | 1
54 | )
55 |
56 | for (let i = bucketStart; i++; i < bucketEnd) {
57 | expect(values[i]).toBeLessThanOrEqual(bucketValueLimit);
58 | }
59 | }
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/tests/sdk/analytics.test.ts:
--------------------------------------------------------------------------------
1 | import {analyticsProcessor, fetch} from './utils.js';
2 |
3 | test('test_analytics_processor_track_feature_updates_analytics_data', () => {
4 | const aP = analyticsProcessor();
5 | aP.trackFeature("myFeature");
6 | expect(aP.analyticsData["myFeature"]).toBe(1);
7 |
8 | aP.trackFeature("myFeature");
9 | expect(aP.analyticsData["myFeature"]).toBe(2);
10 | });
11 |
12 | test('test_analytics_processor_flush_clears_analytics_data', async () => {
13 | const aP = analyticsProcessor();
14 | aP.trackFeature("myFeature");
15 | await aP.flush();
16 | expect(aP.analyticsData).toStrictEqual({});
17 | });
18 |
19 | test('test_analytics_processor_flush_post_request_data_match_ananlytics_data', async () => {
20 | const aP = analyticsProcessor();
21 | aP.trackFeature("myFeature1");
22 | aP.trackFeature("myFeature2");
23 | await aP.flush();
24 | expect(fetch).toHaveBeenCalledTimes(1);
25 | expect(fetch).toHaveBeenCalledWith('http://testUrl/analytics/flags/', expect.objectContaining({
26 | body: '{"myFeature1":1,"myFeature2":1}',
27 | headers: { 'Content-Type': 'application/json', 'X-Environment-Key': 'test-key' },
28 | method: 'POST',
29 | }));
30 | });
31 |
32 | vi.useFakeTimers()
33 | test('test_analytics_processor_flush_post_request_data_match_ananlytics_data_test', async () => {
34 | const aP = analyticsProcessor();
35 | aP.trackFeature("myFeature1");
36 | setTimeout(() => {
37 | aP.trackFeature("myFeature2");
38 | expect(fetch).toHaveBeenCalledTimes(1);
39 | }, 15000);
40 | vi.runOnlyPendingTimers();
41 | });
42 |
43 | test('test_analytics_processor_flush_early_exit_if_analytics_data_is_empty', async () => {
44 | const aP = analyticsProcessor();
45 | await aP.flush();
46 | expect(fetch).not.toHaveBeenCalled();
47 | });
48 |
49 |
50 | test('errors in fetch sending analytics data are swallowed', async () => {
51 | // Given
52 | // we mock the fetch function to throw and error to mimick a network failure
53 | fetch.mockRejectedValue('some error');
54 |
55 | // and create the processor and track a feature so there is some analytics data
56 | const processor = analyticsProcessor();
57 | processor.trackFeature('myFeature');
58 |
59 | // When
60 | // we flush the data to trigger the call to fetch
61 | await processor.flush();
62 |
63 | // Then
64 | // we expect that fetch was called but the exception was handled
65 | expect(fetch).toHaveBeenCalled();
66 | })
67 |
68 | test('analytics is only flushed once even if requested concurrently', async () => {
69 | const processor = analyticsProcessor();
70 | processor.trackFeature('myFeature');
71 | fetch.mockImplementation(() => {
72 | return new Promise((resolve, _) => {
73 | setTimeout(resolve, 1000)
74 | })
75 | });
76 | const flushes = Promise.all([
77 | processor.flush(),
78 | processor.flush(),
79 | ])
80 | vi.runOnlyPendingTimers();
81 | await flushes;
82 | expect(fetch).toHaveBeenCalledTimes(1)
83 | })
84 |
--------------------------------------------------------------------------------
/tests/sdk/data/environment.json:
--------------------------------------------------------------------------------
1 | {
2 | "api_key": "B62qaMZNwfiqT76p38ggrQ",
3 | "project": {
4 | "name": "Test project",
5 | "organisation": {
6 | "feature_analytics": false,
7 | "name": "Test Org",
8 | "id": 1,
9 | "persist_trait_data": true,
10 | "stop_serving_flags": false
11 | },
12 | "id": 1,
13 | "hide_disabled_flags": false,
14 | "segments": [
15 | {
16 | "name": "regular_segment",
17 | "feature_states": [
18 | {
19 | "feature_state_value": "segment_override",
20 | "featurestate_uuid": "dd77a1ab-08cf-4743-8a3b-19e730444a14",
21 | "multivariate_feature_state_values": [],
22 | "django_id": 81027,
23 | "feature": {
24 | "name": "some_feature",
25 | "type": "STANDARD",
26 | "id": 1
27 | },
28 | "enabled": false
29 | }
30 | ],
31 | "id": 1,
32 | "rules": [
33 | {
34 | "type": "ALL",
35 | "conditions": [],
36 | "rules": [
37 | {
38 | "type": "ANY",
39 | "conditions": [
40 | {
41 | "value": "40",
42 | "property_": "age",
43 | "operator": "LESS_THAN"
44 | }
45 | ],
46 | "rules": []
47 | }
48 | ]
49 | }
50 | ]
51 | }
52 | ]
53 | },
54 | "segment_overrides": [],
55 | "id": 1,
56 | "feature_states": [
57 | {
58 | "multivariate_feature_state_values": [],
59 | "feature_state_value": "some-value",
60 | "id": 1,
61 | "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51",
62 | "feature": {
63 | "name": "some_feature",
64 | "type": "STANDARD",
65 | "id": 1
66 | },
67 | "feature_segment": null,
68 | "enabled": true
69 | },
70 | {
71 | "multivariate_feature_state_values": [
72 | {
73 | "percentage_allocation": 100,
74 | "multivariate_feature_option": {
75 | "value": "bar",
76 | "id": 1
77 | },
78 | "mv_fs_value_uuid": "42d5cdf9-8ec9-4b8d-a3ca-fd43c64d5f05",
79 | "id": 1
80 | }
81 | ],
82 | "feature_state_value": "foo",
83 | "feature": {
84 | "name": "mv_feature",
85 | "type": "MULTIVARIATE",
86 | "id": 2
87 | },
88 | "feature_segment": null,
89 | "featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a",
90 | "enabled": false
91 | }
92 | ],
93 | "identity_overrides": [
94 | {
95 | "identifier": "overridden-id",
96 | "identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01",
97 | "created_date": "2019-08-27T14:53:45.698555Z",
98 | "updated_at": "2023-07-14 16:12:00.000000",
99 | "environment_api_key": "B62qaMZNwfiqT76p38ggrQ",
100 | "identity_features": [
101 | {
102 | "id": 1,
103 | "feature": {
104 | "id": 1,
105 | "name": "some_feature",
106 | "type": "STANDARD"
107 | },
108 | "featurestate_uuid": "1bddb9a5-7e59-42c6-9be9-625fa369749f",
109 | "feature_state_value": "some-overridden-value",
110 | "enabled": false,
111 | "environment": 1,
112 | "identity": null,
113 | "feature_segment": null
114 | }
115 | ]
116 | }
117 | ]
118 | }
--------------------------------------------------------------------------------
/tests/sdk/data/flags.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "feature": {
5 | "id": 1,
6 | "name": "some_feature",
7 | "created_date": "2019-08-27T14:53:45.698555Z",
8 | "initial_value": null,
9 | "description": null,
10 | "default_enabled": false,
11 | "type": "STANDARD",
12 | "project": 1
13 | },
14 | "feature_state_value": "some-value",
15 | "enabled": true,
16 | "environment": 1,
17 | "identity": null,
18 | "feature_segment": null
19 | }
20 | ]
--------------------------------------------------------------------------------
/tests/sdk/data/identities.json:
--------------------------------------------------------------------------------
1 | {
2 | "traits": [
3 | {
4 | "id": 1,
5 | "trait_key": "some_trait",
6 | "trait_value": "some_value"
7 | }
8 | ],
9 | "flags": [
10 | {
11 | "id": 1,
12 | "feature": {
13 | "id": 1,
14 | "name": "some_feature",
15 | "created_date": "2019-08-27T14:53:45.698555Z",
16 | "initial_value": null,
17 | "description": null,
18 | "default_enabled": false,
19 | "type": "STANDARD",
20 | "project": 1
21 | },
22 | "feature_state_value": "some-value",
23 | "enabled": true,
24 | "environment": 1,
25 | "identity": null,
26 | "feature_segment": null
27 | }
28 | ]
29 | }
--------------------------------------------------------------------------------
/tests/sdk/data/identity-with-transient-traits.json:
--------------------------------------------------------------------------------
1 | {
2 | "traits": [
3 | {
4 | "id": 1,
5 | "trait_key": "some_trait",
6 | "trait_value": "some_value"
7 | },
8 | {
9 | "id": 2,
10 | "trait_key": "transient_key",
11 | "trait_value": "transient_value",
12 | "transient": true
13 | },
14 | {
15 | "id": 3,
16 | "trait_key": "explicitly_non_transient_trait",
17 | "trait_value": "non_transient_value",
18 | "transient": false
19 | }
20 | ],
21 | "flags": [
22 | {
23 | "id": 1,
24 | "feature": {
25 | "id": 1,
26 | "name": "some_feature",
27 | "created_date": "2019-08-27T14:53:45.698555Z",
28 | "initial_value": null,
29 | "description": null,
30 | "default_enabled": false,
31 | "type": "STANDARD",
32 | "project": 1
33 | },
34 | "feature_state_value": "some-identity-with-transient-trait-value",
35 | "enabled": true,
36 | "environment": 1,
37 | "identity": null,
38 | "feature_segment": null
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/tests/sdk/data/offline-environment.json:
--------------------------------------------------------------------------------
1 | {
2 | "api_key": "B62qaMZNwfiqT76p38ggrQ",
3 | "project": {
4 | "name": "Test project",
5 | "organisation": {
6 | "feature_analytics": false,
7 | "name": "Test Org",
8 | "id": 1,
9 | "persist_trait_data": true,
10 | "stop_serving_flags": false
11 | },
12 | "id": 1,
13 | "hide_disabled_flags": false,
14 | "segments": [
15 | {
16 | "name": "regular_segment",
17 | "feature_states": [
18 | {
19 | "feature_state_value": "segment_override",
20 | "multivariate_feature_state_values": [],
21 | "django_id": 81027,
22 | "feature": {
23 | "name": "some_feature",
24 | "type": "STANDARD",
25 | "id": 1
26 | },
27 | "enabled": false
28 | }
29 | ],
30 | "id": 1,
31 | "rules": [
32 | {
33 | "type": "ALL",
34 | "conditions": [],
35 | "rules": [
36 | {
37 | "type": "ANY",
38 | "conditions": [
39 | {
40 | "value": "40",
41 | "property_": "age",
42 | "operator": "LESS_THAN"
43 | }
44 | ],
45 | "rules": []
46 | }
47 | ]
48 | }
49 | ]
50 | }
51 | ]
52 | },
53 | "segment_overrides": [],
54 | "id": 1,
55 | "feature_states": [
56 | {
57 | "multivariate_feature_state_values": [],
58 | "feature_state_value": "offline-value",
59 | "id": 1,
60 | "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51",
61 | "feature": {
62 | "name": "some_feature",
63 | "type": "STANDARD",
64 | "id": 1
65 | },
66 | "feature_segment": null,
67 | "enabled": true
68 | },
69 | {
70 | "multivariate_feature_state_values": [
71 | {
72 | "percentage_allocation": 100,
73 | "multivariate_feature_option": {
74 | "value": "bar",
75 | "id": 1
76 | },
77 | "mv_fs_value_uuid": "42d5cdf9-8ec9-4b8d-a3ca-fd43c64d5f05",
78 | "id": 1
79 | }
80 | ],
81 | "feature_state_value": "foo",
82 | "feature": {
83 | "name": "mv_feature",
84 | "type": "MULTIVARIATE",
85 | "id": 2
86 | },
87 | "feature_segment": null,
88 | "featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a",
89 | "enabled": false
90 | }
91 | ]
92 | }
93 |
--------------------------------------------------------------------------------
/tests/sdk/data/transient-identity.json:
--------------------------------------------------------------------------------
1 | {
2 | "traits": [
3 | {
4 | "id": 1,
5 | "trait_key": "some_trait",
6 | "trait_value": "some_value"
7 | }
8 | ],
9 | "flags": [
10 | {
11 | "id": 1,
12 | "feature": {
13 | "id": 1,
14 | "name": "some_feature",
15 | "created_date": "2019-08-27T14:53:45.698555Z",
16 | "initial_value": null,
17 | "description": null,
18 | "default_enabled": false,
19 | "type": "STANDARD",
20 | "project": 1
21 | },
22 | "feature_state_value": "some-transient-identity-value",
23 | "enabled": false,
24 | "environment": 1,
25 | "identity": null,
26 | "feature_segment": null
27 | }
28 | ]
29 | }
--------------------------------------------------------------------------------
/tests/sdk/flagsmith-cache.test.ts:
--------------------------------------------------------------------------------
1 | import { fetch, environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON, TestCache } from './utils.js';
2 |
3 | test('test_empty_cache_not_read_but_populated', async () => {
4 | const cache = new TestCache();
5 | const set = vi.spyOn(cache, 'set');
6 |
7 | const flg = flagsmith({ cache });
8 | const allFlags = (await flg.getEnvironmentFlags()).allFlags();
9 |
10 | expect(set).toBeCalled();
11 | expect(await cache.get('flags')).toBeTruthy();
12 |
13 | expect(fetch).toBeCalledTimes(1);
14 | expect(allFlags[0].enabled).toBe(true);
15 | expect(allFlags[0].value).toBe('some-value');
16 | expect(allFlags[0].featureName).toBe('some_feature');
17 | });
18 |
19 | test('test_api_not_called_when_cache_present', async () => {
20 | const cache = new TestCache();
21 | const set = vi.spyOn(cache, 'set');
22 |
23 | const flg = flagsmith({ cache });
24 | await (await flg.getEnvironmentFlags()).allFlags();
25 | const allFlags = await (await flg.getEnvironmentFlags()).allFlags();
26 |
27 | expect(set).toBeCalled();
28 | expect(await cache.get('flags')).toBeTruthy();
29 |
30 | expect(fetch).toBeCalledTimes(1);
31 | expect(allFlags[0].enabled).toBe(true);
32 | expect(allFlags[0].value).toBe('some-value');
33 | expect(allFlags[0].featureName).toBe('some_feature');
34 | });
35 |
36 | test('test_api_called_twice_when_no_cache', async () => {
37 | fetch.mockImplementation(() => Promise.resolve(new Response(flagsJSON)));
38 |
39 | const flg = flagsmith();
40 | await (await flg.getEnvironmentFlags()).allFlags();
41 |
42 | const allFlags = await (await flg.getEnvironmentFlags()).allFlags();
43 |
44 | expect(fetch).toBeCalledTimes(2);
45 | expect(allFlags[0].enabled).toBe(true);
46 | expect(allFlags[0].value).toBe('some-value');
47 | expect(allFlags[0].featureName).toBe('some_feature');
48 | });
49 |
50 | test('test_get_environment_flags_uses_local_environment_when_available', async () => {
51 | const cache = new TestCache();
52 | const set = vi.spyOn(cache, 'set');
53 |
54 | const flg = flagsmith({ cache, environmentKey: 'ser.key', enableLocalEvaluation: true });
55 | const model = environmentModel(JSON.parse(environmentJSON));
56 | const getEnvironment = vi.spyOn(flg, 'getEnvironment')
57 | getEnvironment.mockResolvedValue(model)
58 |
59 | const allFlags = (await flg.getEnvironmentFlags()).allFlags();
60 |
61 | expect(set).toBeCalled();
62 | expect(fetch).toBeCalledTimes(0);
63 | expect(getEnvironment).toBeCalledTimes(1);
64 | expect(allFlags[0].enabled).toBe(model.featureStates[0].enabled);
65 | expect(allFlags[0].value).toBe(model.featureStates[0].getValue());
66 | expect(allFlags[0].featureName).toBe(model.featureStates[0].feature.name);
67 | });
68 |
69 | test('test_cache_used_for_identity_flags', async () => {
70 | const cache = new TestCache();
71 | const set = vi.spyOn(cache, 'set');
72 |
73 | const identifier = 'identifier';
74 | const traits = { some_trait: 'some_value' };
75 | const flg = flagsmith({ cache });
76 |
77 | (await flg.getIdentityFlags(identifier, traits)).allFlags();
78 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags();
79 |
80 | expect(set).toBeCalled();
81 | expect(await cache.get('flags-identifier')).toBeTruthy();
82 |
83 | expect(fetch).toBeCalledTimes(1);
84 |
85 | expect(identityFlags[0].enabled).toBe(true);
86 | expect(identityFlags[0].value).toBe('some-value');
87 | expect(identityFlags[0].featureName).toBe('some_feature');
88 | });
89 |
90 | test('test_cache_used_for_identity_flags_local_evaluation', async () => {
91 | const cache = new TestCache();
92 | const set = vi.spyOn(cache, 'set');
93 |
94 | const identifier = 'identifier';
95 | const traits = { some_trait: 'some_value' };
96 | const flg = flagsmith({
97 | cache,
98 | environmentKey: 'ser.key',
99 | enableLocalEvaluation: true,
100 | });
101 |
102 | (await flg.getIdentityFlags(identifier, traits)).allFlags();
103 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags();
104 |
105 | expect(set).toBeCalled();
106 | expect(await cache.get('flags-identifier')).toBeTruthy();
107 |
108 | expect(fetch).toBeCalledTimes(1);
109 |
110 | expect(identityFlags[0].enabled).toBe(true);
111 | expect(identityFlags[0].value).toBe('some-value');
112 | expect(identityFlags[0].featureName).toBe('some_feature');
113 | });
114 |
--------------------------------------------------------------------------------
/tests/sdk/flagsmith-environment-flags.test.ts:
--------------------------------------------------------------------------------
1 | import Flagsmith from '../../sdk/index.js';
2 | import { environmentJSON, environmentModel, flagsJSON, flagsmith, fetch } from './utils.js';
3 | import { DefaultFlag } from '../../sdk/models.js';
4 |
5 | vi.mock('../../sdk/polling_manager');
6 |
7 | test('test_get_environment_flags_calls_api_when_no_local_environment', async () => {
8 | const flg = flagsmith();
9 | const allFlags = await (await flg.getEnvironmentFlags()).allFlags();
10 |
11 | expect(fetch).toBeCalledTimes(1);
12 | expect(allFlags[0].enabled).toBe(true);
13 | expect(allFlags[0].value).toBe('some-value');
14 | expect(allFlags[0].featureName).toBe('some_feature');
15 | });
16 |
17 | test('test_default_flag_is_used_when_no_environment_flags_returned', async () => {
18 | fetch.mockResolvedValue(new Response(JSON.stringify([])));
19 |
20 | const defaultFlag = new DefaultFlag('some-default-value', true);
21 |
22 | const defaultFlagHandler = (featureName: string) => defaultFlag;
23 |
24 | const flg = new Flagsmith({
25 | environmentKey: 'key',
26 | defaultFlagHandler: defaultFlagHandler,
27 | customHeaders: {
28 | 'X-Test-Header': '1',
29 | }
30 | });
31 |
32 | const flags = await flg.getEnvironmentFlags();
33 | const flag = flags.getFlag('some_feature');
34 | expect(flag.isDefault).toBe(true);
35 | expect(flag.enabled).toBe(defaultFlag.enabled);
36 | expect(flag.value).toBe(defaultFlag.value);
37 | });
38 |
39 | test('test_analytics_processor_tracks_flags', async () => {
40 | const defaultFlag = new DefaultFlag('some-default-value', true);
41 |
42 | const defaultFlagHandler = (featureName: string) => defaultFlag;
43 |
44 | const flg = flagsmith({
45 | environmentKey: 'key',
46 | defaultFlagHandler: defaultFlagHandler,
47 | enableAnalytics: true,
48 | });
49 |
50 | const flags = await flg.getEnvironmentFlags();
51 | const flag = flags.getFlag('some_feature');
52 |
53 | expect(flag.isDefault).toBe(false);
54 | expect(flag.enabled).toBe(true);
55 | expect(flag.value).toBe('some-value');
56 | });
57 |
58 | test('test_getFeatureValue', async () => {
59 | const defaultFlag = new DefaultFlag('some-default-value', true);
60 |
61 | const defaultFlagHandler = (featureName: string) => defaultFlag;
62 |
63 | const flg = flagsmith({
64 | environmentKey: 'key',
65 | defaultFlagHandler: defaultFlagHandler,
66 | enableAnalytics: true,
67 | });
68 |
69 | const flags = await flg.getEnvironmentFlags();
70 | const featureValue = flags.getFeatureValue('some_feature');
71 |
72 | expect(featureValue).toBe('some-value');
73 | });
74 |
75 | test('test_throws_when_no_default_flag_handler_after_multiple_API_errors', async () => {
76 | fetch.mockRejectedValue('Error during fetching the API response');
77 |
78 | const flg = flagsmith({
79 | environmentKey: 'key',
80 | });
81 |
82 | await expect(async () => {
83 | const flags = await flg.getEnvironmentFlags();
84 | const flag = flags.getFlag('some_feature');
85 | }).rejects.toThrow('getEnvironmentFlags failed and no default flag handler was provided');
86 | });
87 |
88 | test('test_non_200_response_raises_flagsmith_api_error', async () => {
89 | const errorResponse403 = new Response('403 Forbidden', {
90 | status: 403
91 | });
92 | fetch.mockResolvedValue(errorResponse403);
93 |
94 | const flg = new Flagsmith({
95 | environmentKey: 'some'
96 | });
97 |
98 | await expect(flg.getEnvironmentFlags()).rejects.toThrow();
99 | });
100 | test('test_default_flag_is_not_used_when_environment_flags_returned', async () => {
101 | const defaultFlag = new DefaultFlag('some-default-value', true);
102 |
103 | const defaultFlagHandler = (featureName: string) => defaultFlag;
104 |
105 | const flg = flagsmith({
106 | environmentKey: 'key',
107 | defaultFlagHandler: defaultFlagHandler
108 | });
109 |
110 | const flags = await flg.getEnvironmentFlags();
111 | const flag = flags.getFlag('some_feature');
112 |
113 | expect(flag.isDefault).toBe(false);
114 | expect(flag.value).not.toBe(defaultFlag.value);
115 | expect(flag.value).toBe('some-value');
116 | });
117 |
118 | test('test_default_flag_is_used_when_bad_api_response_happens', async () => {
119 | fetch.mockResolvedValue(new Response('bad-data'));
120 |
121 | const defaultFlag = new DefaultFlag('some-default-value', true);
122 |
123 | const defaultFlagHandler = (featureName: string) => defaultFlag;
124 |
125 | const flg = new Flagsmith({
126 | environmentKey: 'key',
127 | defaultFlagHandler: defaultFlagHandler
128 | });
129 |
130 | const flags = await flg.getEnvironmentFlags();
131 | const flag = flags.getFlag('some_feature');
132 |
133 | expect(flag.isDefault).toBe(true);
134 | expect(flag.value).toBe(defaultFlag.value);
135 | });
136 |
137 | test('test_local_evaluation', async () => {
138 | const defaultFlag = new DefaultFlag('some-default-value', true);
139 |
140 | const defaultFlagHandler = (featureName: string) => defaultFlag;
141 |
142 | const flg = flagsmith({
143 | environmentKey: 'ser.key',
144 | enableLocalEvaluation: true,
145 | defaultFlagHandler: defaultFlagHandler
146 | });
147 |
148 | const flags = await flg.getEnvironmentFlags();
149 | const flag = flags.getFlag('some_feature');
150 |
151 | expect(flag.isDefault).toBe(false);
152 | expect(flag.value).not.toBe(defaultFlag.value);
153 | expect(flag.value).toBe('some-value');
154 | });
155 |
--------------------------------------------------------------------------------
/tests/sdk/flagsmith-identity-flags.test.ts:
--------------------------------------------------------------------------------
1 | import Flagsmith from '../../sdk/index.js';
2 | import {
3 | fetch,
4 | environmentJSON,
5 | flagsmith,
6 | identitiesJSON,
7 | identityWithTransientTraitsJSON,
8 | transientIdentityJSON,
9 | badFetch
10 | } from './utils.js';
11 | import { DefaultFlag } from '../../sdk/models.js';
12 |
13 | vi.mock('../../sdk/polling_manager');
14 |
15 | test('test_get_identity_flags_calls_api_when_no_local_environment_no_traits', async () => {
16 | const identifier = 'identifier';
17 |
18 | const flg = flagsmith();
19 |
20 | const identityFlags = (await flg.getIdentityFlags(identifier)).allFlags();
21 |
22 | expect(identityFlags[0].enabled).toBe(true);
23 | expect(identityFlags[0].value).toBe('some-value');
24 | expect(identityFlags[0].featureName).toBe('some_feature');
25 | });
26 |
27 | test('test_get_identity_flags_uses_environment_when_local_environment_no_traits', async () => {
28 | const identifier = 'identifier';
29 |
30 | const flg = flagsmith({
31 | environmentKey: 'ser.key',
32 | enableLocalEvaluation: true,
33 | });
34 |
35 |
36 | const identityFlags = (await flg.getIdentityFlags(identifier)).allFlags();
37 |
38 | expect(identityFlags[0].enabled).toBe(true);
39 | expect(identityFlags[0].value).toBe('some-value');
40 | expect(identityFlags[0].featureName).toBe('some_feature');
41 | });
42 |
43 | test('test_get_identity_flags_calls_api_when_no_local_environment_with_traits', async () => {
44 | const identifier = 'identifier';
45 | const traits = { some_trait: 'some_value' };
46 | const flg = flagsmith();
47 |
48 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags();
49 |
50 | expect(identityFlags[0].enabled).toBe(true);
51 | expect(identityFlags[0].value).toBe('some-value');
52 | expect(identityFlags[0].featureName).toBe('some_feature');
53 | });
54 |
55 | test('test_default_flag_is_not_used_when_identity_flags_returned', async () => {
56 | const defaultFlag = new DefaultFlag('some-default-value', true);
57 |
58 | const defaultFlagHandler = (featureName: string) => defaultFlag;
59 |
60 | const flg = flagsmith({
61 | environmentKey: 'key',
62 | defaultFlagHandler: defaultFlagHandler
63 | });
64 |
65 | const flags = await flg.getIdentityFlags('identifier');
66 | const flag = flags.getFlag('some_feature');
67 |
68 | expect(flag.isDefault).toBe(false);
69 | expect(flag.value).not.toBe(defaultFlag.value);
70 | expect(flag.value).toBe('some-value');
71 | });
72 |
73 | test('test_default_flag_is_used_when_no_identity_flags_returned', async () => {
74 | fetch.mockResolvedValue(new Response(JSON.stringify({ flags: [], traits: [] })));
75 |
76 | const defaultFlag = new DefaultFlag('some-default-value', true);
77 | const defaultFlagHandler = (featureName: string) => defaultFlag;
78 |
79 | const flg = new Flagsmith({
80 | environmentKey: 'key',
81 | defaultFlagHandler: defaultFlagHandler
82 | });
83 |
84 | const flags = await flg.getIdentityFlags('identifier');
85 | const flag = flags.getFlag('some_feature');
86 |
87 | expect(flag.isDefault).toBe(true);
88 | expect(flag.value).toBe(defaultFlag.value);
89 | expect(flag.enabled).toBe(defaultFlag.enabled);
90 | });
91 |
92 | test('test_default_flag_is_used_when_no_identity_flags_returned_due_to_error', async () => {
93 | fetch.mockResolvedValue(new Response('bad data'))
94 |
95 | const defaultFlag = new DefaultFlag('some-default-value', true);
96 | const defaultFlagHandler = (featureName: string) => defaultFlag;
97 |
98 | const flg = new Flagsmith({
99 | environmentKey: 'key',
100 | defaultFlagHandler: defaultFlagHandler
101 | });
102 |
103 | const flags = await flg.getIdentityFlags('identifier');
104 | const flag = flags.getFlag('some_feature');
105 |
106 | expect(flag.isDefault).toBe(true);
107 | expect(flag.value).toBe(defaultFlag.value);
108 | expect(flag.enabled).toBe(defaultFlag.enabled);
109 | });
110 |
111 | test('test_default_flag_is_used_when_no_identity_flags_returned_and_no_custom_default_flag_handler', async () => {
112 | fetch.mockResolvedValue(new Response(JSON.stringify({ flags: [], traits: [] })))
113 |
114 | const flg = flagsmith({
115 | environmentKey: 'key',
116 | });
117 |
118 | const flags = await flg.getIdentityFlags('identifier');
119 | const flag = flags.getFlag('some_feature');
120 |
121 | expect(flag.isDefault).toBe(true);
122 | expect(flag.value).toBe(undefined);
123 | expect(flag.enabled).toBe(false);
124 | });
125 |
126 | test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled', async () => {
127 | fetch.mockResolvedValue(new Response(environmentJSON));
128 | const identifier = 'identifier';
129 |
130 | const flg = flagsmith({
131 | environmentKey: 'ser.key',
132 | enableLocalEvaluation: true,
133 | });
134 |
135 | const identityFlags = (await flg.getIdentityFlags(identifier))
136 |
137 | expect(identityFlags.getFeatureValue('mv_feature')).toBe('bar');
138 | expect(identityFlags.isFeatureEnabled('mv_feature')).toBe(false);
139 | });
140 |
141 |
142 | test('test_transient_identity', async () => {
143 | fetch.mockResolvedValue(new Response(transientIdentityJSON));
144 | const identifier = 'transient_identifier';
145 | const traits = { some_trait: 'some_value' };
146 | const traitsInRequest = [{trait_key:Object.keys(traits)[0],trait_value:traits.some_trait}]
147 | const transient = true;
148 | const flg = flagsmith();
149 | const identityFlags = (await flg.getIdentityFlags(identifier, traits, transient)).allFlags();
150 |
151 | expect(fetch).toHaveBeenCalledWith(
152 | `https://edge.api.flagsmith.com/api/v1/identities/`,
153 | expect.objectContaining({
154 | method: 'POST',
155 | headers: { 'Content-Type': 'application/json', 'X-Environment-Key': 'sometestfakekey' },
156 | body: JSON.stringify({identifier, traits: traitsInRequest, transient })
157 | }
158 | ));
159 |
160 | expect(identityFlags[0].enabled).toBe(false);
161 | expect(identityFlags[0].value).toBe('some-transient-identity-value');
162 | expect(identityFlags[0].featureName).toBe('some_feature');
163 | });
164 |
165 |
166 | test('test_identity_with_transient_traits', async () => {
167 | fetch.mockResolvedValue(new Response(identityWithTransientTraitsJSON));
168 | const identifier = 'transient_trait_identifier';
169 | const traits = {
170 | some_trait: 'some_value',
171 | another_trait: {value: 'another_value', transient: true},
172 | explicitly_non_transient_trait: {value: 'non_transient_value', transient: false}
173 | }
174 | const traitsInRequest = [
175 | {
176 | trait_key:Object.keys(traits)[0],
177 | trait_value:traits.some_trait,
178 | },
179 | {
180 | trait_key:Object.keys(traits)[1],
181 | trait_value:traits.another_trait.value,
182 | transient: true,
183 | },
184 | {
185 | trait_key:Object.keys(traits)[2],
186 | trait_value:traits.explicitly_non_transient_trait.value,
187 | transient: false,
188 | },
189 | ]
190 | const flg = flagsmith();
191 |
192 | const identityFlags = (await flg.getIdentityFlags(identifier, traits)).allFlags();
193 | expect(fetch).toHaveBeenCalledWith(
194 | `https://edge.api.flagsmith.com/api/v1/identities/`,
195 | expect.objectContaining({
196 | method: 'POST',
197 | headers: { 'Content-Type': 'application/json', 'X-Environment-Key': 'sometestfakekey' },
198 | body: JSON.stringify({identifier, traits: traitsInRequest})
199 | })
200 | );
201 | expect(identityFlags[0].enabled).toBe(true);
202 | expect(identityFlags[0].value).toBe('some-identity-with-transient-trait-value');
203 | expect(identityFlags[0].featureName).toBe('some_feature');
204 | });
205 |
206 | test('getIdentityFlags fails if API call failed and no default flag handler was provided', async () => {
207 | const flg = flagsmith({
208 | fetch: badFetch,
209 | })
210 | await expect(flg.getIdentityFlags('user'))
211 | .rejects
212 | .toThrow('getIdentityFlags failed and no default flag handler was provided')
213 | })
214 |
--------------------------------------------------------------------------------
/tests/sdk/flagsmith.test.ts:
--------------------------------------------------------------------------------
1 | import Flagsmith from '../../sdk/index.js';
2 | import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js';
3 | import {
4 | environmentJSON,
5 | environmentModel,
6 | flagsmith,
7 | fetch,
8 | offlineEnvironmentJSON,
9 | badFetch
10 | } from './utils.js';
11 | import { DefaultFlag, Flags } from '../../sdk/models.js';
12 | import { delay } from '../../sdk/utils.js';
13 | import { EnvironmentModel } from '../../flagsmith-engine/environments/models.js';
14 | import { BaseOfflineHandler } from '../../sdk/offline_handlers.js';
15 | import { Agent } from 'undici';
16 |
17 | vi.mock('../../sdk/polling_manager');
18 | test('test_flagsmith_starts_polling_manager_on_init_if_enabled', () => {
19 | new Flagsmith({
20 | environmentKey: 'ser.key',
21 | enableLocalEvaluation: true
22 | });
23 | expect(EnvironmentDataPollingManager).toBeCalled();
24 | });
25 |
26 | test('test_flagsmith_local_evaluation_key_required', () => {
27 | expect(() => {
28 | new Flagsmith({
29 | environmentKey: 'bad.key',
30 | enableLocalEvaluation: true
31 | });
32 | }).toThrow('Using local evaluation requires a server-side environment key')
33 | });
34 |
35 | test('test_update_environment_sets_environment', async () => {
36 | const flg = flagsmith({
37 | environmentKey: 'ser.key',
38 | });
39 | const model = environmentModel(JSON.parse(environmentJSON));
40 | expect(await flg.getEnvironment()).toStrictEqual(model);
41 | });
42 |
43 | test('test_set_agent_options', async () => {
44 | const agent = new Agent({})
45 |
46 | fetch.mockImplementationOnce((url, options) => {
47 | //@ts-ignore I give up
48 | if (options.dispatcher !== agent) {
49 | throw new Error("Agent has not been set on retry fetch")
50 | }
51 | return Promise.resolve(new Response(environmentJSON))
52 | });
53 |
54 | const flg = flagsmith({
55 | agent
56 | });
57 |
58 | await flg.updateEnvironment();
59 | });
60 |
61 | test('test_get_identity_segments', async () => {
62 | const flg = flagsmith({
63 | environmentKey: 'ser.key',
64 | enableLocalEvaluation: true
65 | });
66 | const segments = await flg.getIdentitySegments('user', { age: 21 });
67 | expect(segments[0].name).toEqual('regular_segment');
68 | const segments2 = await flg.getIdentitySegments('user', { age: 41 });
69 | expect(segments2.length).toEqual(0);
70 | });
71 |
72 |
73 | test('test_get_identity_segments_empty_without_local_eval', async () => {
74 | const flg = new Flagsmith({
75 | environmentKey: 'ser.key',
76 | enableLocalEvaluation: false
77 | });
78 | const segments = await flg.getIdentitySegments('user', { age: 21 });
79 | expect(segments.length).toBe(0);
80 | });
81 |
82 | test('test_update_environment_uses_req_when_inited', async () => {
83 | const flg = flagsmith({
84 | environmentKey: 'ser.key',
85 | enableLocalEvaluation: true,
86 | });
87 |
88 | delay(400);
89 |
90 | expect(async () => {
91 | await flg.updateEnvironment();
92 | }).not.toThrow();
93 | });
94 |
95 | test('test_isFeatureEnabled_environment', async () => {
96 | const defaultFlag = new DefaultFlag('some-default-value', true);
97 |
98 | const defaultFlagHandler = (featureName: string) => defaultFlag;
99 |
100 | const flg = new Flagsmith({
101 | environmentKey: 'key',
102 | defaultFlagHandler: defaultFlagHandler,
103 | enableAnalytics: true,
104 | });
105 |
106 | const flags = await flg.getEnvironmentFlags();
107 | const featureValue = flags.isFeatureEnabled('some_feature');
108 |
109 | expect(featureValue).toBe(true);
110 | });
111 |
112 | test('test_fetch_recovers_after_single_API_error', async () => {
113 | fetch.mockRejectedValueOnce('Error during fetching the API response')
114 | const flg = flagsmith({
115 | environmentKey: 'key',
116 | });
117 |
118 | const flags = await flg.getEnvironmentFlags();
119 | const flag = flags.getFlag('some_feature');
120 | expect(flag.isDefault).toBe(false);
121 | expect(flag.enabled).toBe(true);
122 | expect(flag.value).toBe('some-value');
123 | });
124 |
125 | test.each([
126 | [false, 'key'],
127 | [true, 'ser.key']
128 | ])(
129 | 'default flag handler is used when API is unavailable (local evaluation = %s)',
130 | async (enableLocalEvaluation, environmentKey) => {
131 | const flg = flagsmith({
132 | enableLocalEvaluation,
133 | environmentKey,
134 | defaultFlagHandler: () => new DefaultFlag('some-default-value', true),
135 | fetch: badFetch,
136 | });
137 | const flags = await flg.getEnvironmentFlags();
138 | const flag = flags.getFlag('some_feature');
139 | expect(flag.isDefault).toBe(true);
140 | expect(flag.enabled).toBe(true);
141 | expect(flag.value).toBe('some-default-value');
142 | }
143 | );
144 |
145 | test('default flag handler used when timeout occurs', async () => {
146 | fetch.mockImplementation(async (...args) => {
147 | const forever = new Promise(() => {})
148 | await forever
149 | throw new Error('waited forever')
150 | });
151 |
152 | const defaultFlag = new DefaultFlag('some-default-value', true);
153 |
154 | const defaultFlagHandler = () => defaultFlag;
155 |
156 | const flg = flagsmith({
157 | environmentKey: 'key',
158 | defaultFlagHandler: defaultFlagHandler,
159 | requestTimeoutSeconds: 0.0001,
160 | });
161 |
162 | const flags = await flg.getEnvironmentFlags();
163 | const flag = flags.getFlag('some_feature');
164 | expect(flag.isDefault).toBe(true);
165 | expect(flag.enabled).toBe(defaultFlag.enabled);
166 | expect(flag.value).toBe(defaultFlag.value);
167 | })
168 |
169 | test('request timeout uses default if not provided', async () => {
170 |
171 | const flg = new Flagsmith({
172 | environmentKey: 'key',
173 | });
174 |
175 | expect(flg.requestTimeoutMs).toBe(10000);
176 | })
177 |
178 | test('test_throws_when_no_identityFlags_returned_due_to_error', async () => {
179 | const flg = flagsmith({
180 | environmentKey: 'key',
181 | fetch: badFetch,
182 | });
183 |
184 | await expect(async () => await flg.getIdentityFlags('identifier'))
185 | .rejects
186 | .toThrow();
187 | });
188 |
189 | test('test onEnvironmentChange is called when provided', async () => {
190 | const callback = vi.fn()
191 |
192 | const flg = new Flagsmith({
193 | environmentKey: 'ser.key',
194 | enableLocalEvaluation: true,
195 | onEnvironmentChange: callback,
196 | });
197 |
198 | fetch.mockRejectedValueOnce(new Error('API error'));
199 | await flg.updateEnvironment().catch(() => {
200 | // Expected rejection
201 | });
202 |
203 | expect(callback).toBeCalled();
204 | });
205 |
206 | test('test onEnvironmentChange is called after error', async () => {
207 | const callback = vi.fn();
208 | const flg = new Flagsmith({
209 | environmentKey: 'ser.key',
210 | enableLocalEvaluation: true,
211 | onEnvironmentChange: callback,
212 | fetch: badFetch,
213 | });
214 | await flg.updateEnvironment();
215 | expect(callback).toHaveBeenCalled();
216 | });
217 |
218 | test('getIdentityFlags throws error if identifier is empty string', async () => {
219 | const flg = flagsmith({
220 | environmentKey: 'key',
221 | });
222 |
223 | await expect(flg.getIdentityFlags('')).rejects.toThrow('`identifier` argument is missing or invalid.');
224 | })
225 |
226 | test('getIdentitySegments throws error if identifier is empty string', async () => {
227 | const flg = flagsmith({
228 | environmentKey: 'key',
229 | });
230 |
231 | await expect(flg.getIdentitySegments('')).rejects.toThrow(
232 | '`identifier` argument is missing or invalid.'
233 | );
234 | });
235 |
236 | test('offline_mode', async () => {
237 | // Given
238 | const environment: EnvironmentModel = environmentModel(JSON.parse(offlineEnvironmentJSON));
239 |
240 | class DummyOfflineHandler extends BaseOfflineHandler {
241 | getEnvironment(): EnvironmentModel {
242 | return environment;
243 | }
244 | }
245 |
246 | // When
247 | const flg = flagsmith({ offlineMode: true, offlineHandler: new DummyOfflineHandler() });
248 |
249 | // Then
250 | // we can request the flags from the client successfully
251 | const environmentFlags: Flags = await flg.getEnvironmentFlags();
252 | let flag = environmentFlags.getFlag('some_feature');
253 | expect(flag.isDefault).toBe(false);
254 | expect(flag.enabled).toBe(true);
255 | expect(flag.value).toBe('offline-value');
256 |
257 |
258 | const identityFlags: Flags = await flg.getIdentityFlags("identity");
259 | flag = identityFlags.getFlag('some_feature');
260 | expect(flag.isDefault).toBe(false);
261 | expect(flag.enabled).toBe(true);
262 | expect(flag.value).toBe('offline-value');
263 | });
264 |
265 |
266 | test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async () => {
267 | // Given
268 | const environment: EnvironmentModel = environmentModel(JSON.parse(offlineEnvironmentJSON));
269 | const api_url = 'http://some.flagsmith.com/api/v1/';
270 | const mock_offline_handler = new BaseOfflineHandler();
271 |
272 | vi.spyOn(mock_offline_handler, 'getEnvironment').mockReturnValue(environment);
273 |
274 | const flg = flagsmith({
275 | environmentKey: 'some-key',
276 | apiUrl: api_url,
277 | offlineHandler: mock_offline_handler,
278 | offlineMode: true
279 | });
280 |
281 | vi.spyOn(flg, 'getEnvironmentFlags');
282 | vi.spyOn(flg, 'getIdentityFlags');
283 |
284 |
285 | flg.environmentFlagsUrl = 'http://some.flagsmith.com/api/v1/environment-flags';
286 | flg.identitiesUrl = 'http://some.flagsmith.com/api/v1/identities';
287 |
288 | // Mock a 500 Internal Server Error response
289 | const errorResponse = new Response(null, {
290 | status: 500,
291 | statusText: 'Internal Server Error',
292 | });
293 |
294 | fetch.mockResolvedValue(errorResponse);
295 |
296 | // When
297 | const environmentFlags: Flags = await flg.getEnvironmentFlags();
298 | expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1);
299 | const identityFlags: Flags = await flg.getIdentityFlags('identity', {});
300 |
301 | // Then
302 | expect(flg.getEnvironmentFlags).toHaveBeenCalled();
303 | expect(flg.getIdentityFlags).toHaveBeenCalled();
304 |
305 | expect(environmentFlags.isFeatureEnabled('some_feature')).toBe(true);
306 | expect(environmentFlags.getFeatureValue('some_feature')).toBe('offline-value');
307 |
308 | expect(identityFlags.isFeatureEnabled('some_feature')).toBe(true);
309 | expect(identityFlags.getFeatureValue('some_feature')).toBe('offline-value');
310 | });
311 |
312 | test('cannot use offline mode without offline handler', () => {
313 | // When and Then
314 | expect(() => new Flagsmith({ offlineMode: true, offlineHandler: undefined })).toThrowError(
315 | 'ValueError: offlineHandler must be provided to use offline mode.'
316 | );
317 | });
318 |
319 | test('cannot use both default handler and offline handler', () => {
320 | // When and Then
321 | expect(() => flagsmith({
322 | offlineHandler: new BaseOfflineHandler(),
323 | defaultFlagHandler: () => new DefaultFlag('foo', true)
324 | })).toThrowError('ValueError: Cannot use both defaultFlagHandler and offlineHandler.');
325 | });
326 |
327 | test('cannot create Flagsmith client in remote evaluation without API key', () => {
328 | // When and Then
329 | expect(() => new Flagsmith({ environmentKey: '' })).toThrowError('ValueError: environmentKey is required.');
330 | });
331 |
332 |
333 | test('test_localEvaluation_true__identity_overrides_evaluated', async () => {
334 | const flg = flagsmith({
335 | environmentKey: 'ser.key',
336 | enableLocalEvaluation: true
337 | });
338 |
339 | await flg.updateEnvironment()
340 | const flags = await flg.getIdentityFlags('overridden-id');
341 | expect(flags.getFeatureValue('some_feature')).toEqual('some-overridden-value');
342 | });
343 |
344 | test('getIdentityFlags succeeds if initial fetch failed then succeeded', async () => {
345 | const defaultFlagHandler = vi.fn(() => new DefaultFlag('mock-default-value', true));
346 |
347 | fetch.mockRejectedValue(new Error('Initial API error'));
348 | const flg = flagsmith({
349 | environmentKey: 'ser.key',
350 | enableLocalEvaluation: true,
351 | defaultFlagHandler
352 | });
353 |
354 | const defaultFlags = await flg.getIdentityFlags('test-user');
355 | expect(defaultFlags.isFeatureEnabled('mock-default-value')).toBe(true);
356 | expect(defaultFlagHandler).toHaveBeenCalled();
357 |
358 | fetch.mockResolvedValue(new Response(environmentJSON));
359 | await flg.getEnvironment();
360 | const flags2 = await flg.getIdentityFlags('test-user');
361 | expect(flags2.isFeatureEnabled('some_feature')).toBe(true);
362 | });
363 |
--------------------------------------------------------------------------------
/tests/sdk/offline-handlers.test.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import { LocalFileHandler } from '../../sdk/offline_handlers.js';
3 | import { EnvironmentModel } from '../../flagsmith-engine/index.js';
4 |
5 | import * as offlineEnvironment from "./data/offline-environment.json";
6 |
7 | vi.mock('fs')
8 |
9 | const offlineEnvironmentString = JSON.stringify(offlineEnvironment)
10 |
11 | test('local file handler', () => {
12 | const environmentDocumentFilePath = '/some/path/environment.json';
13 |
14 | // Mock the fs.readFileSync function to return environmentJson
15 |
16 | const readFileSyncMock = vi.spyOn(fs, 'readFileSync');
17 | readFileSyncMock.mockImplementation(() => offlineEnvironmentString);
18 |
19 | // Given
20 | const localFileHandler = new LocalFileHandler(environmentDocumentFilePath);
21 |
22 | // When
23 | const environmentModel = localFileHandler.getEnvironment();
24 |
25 | // Then
26 | expect(environmentModel).toBeInstanceOf(EnvironmentModel);
27 | expect(environmentModel.apiKey).toBe('B62qaMZNwfiqT76p38ggrQ');
28 | expect(readFileSyncMock).toHaveBeenCalledWith(environmentDocumentFilePath, 'utf8');
29 |
30 | // Restore the original implementation of fs.readFileSync
31 | readFileSyncMock.mockRestore();
32 | });
33 |
--------------------------------------------------------------------------------
/tests/sdk/polling.test.ts:
--------------------------------------------------------------------------------
1 | import Flagsmith from '../../sdk/index.js';
2 | import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js';
3 | import { delay } from '../../sdk/utils.js';
4 | vi.mock('../../sdk');
5 |
6 | test('test_polling_manager_correctly_stops_if_never_started', async () => {
7 | const flagsmith = new Flagsmith({
8 | environmentKey: 'key'
9 | });
10 |
11 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1);
12 | pollingManager.stop();
13 | expect(flagsmith.updateEnvironment).not.toHaveBeenCalled();
14 | });
15 |
16 | test('test_polling_manager_calls_update_environment_on_start', async () => {
17 | const flagsmith = new Flagsmith({
18 | environmentKey: 'key'
19 | });
20 |
21 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1);
22 | pollingManager.start();
23 | await delay(500);
24 | pollingManager.stop();
25 | expect(flagsmith.updateEnvironment).toHaveBeenCalled();
26 | });
27 |
28 | test('test_polling_manager_handles_double_start', async () => {
29 | const flagsmith = new Flagsmith({
30 | environmentKey: 'key'
31 | });
32 |
33 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1);
34 | pollingManager.start();
35 | await delay(100);
36 | pollingManager.start();
37 | await delay(500);
38 | pollingManager.stop();
39 | expect(flagsmith.updateEnvironment).toHaveBeenCalled();
40 | });
41 |
42 |
43 | test('test_polling_manager_calls_update_environment_on_each_refresh', async () => {
44 | const flagsmith = new Flagsmith({
45 | environmentKey: 'key'
46 | });
47 |
48 | const pollingManager = new EnvironmentDataPollingManager(flagsmith, 0.1);
49 | pollingManager.start();
50 | await delay(450);
51 | pollingManager.stop();
52 | expect(flagsmith.updateEnvironment).toHaveBeenCalledTimes(4);
53 | });
54 |
--------------------------------------------------------------------------------
/tests/sdk/utils.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from 'fs';
2 | import { buildEnvironmentModel } from '../../flagsmith-engine/environments/util.js';
3 | import { AnalyticsProcessor } from '../../sdk/analytics.js';
4 | import Flagsmith, {FlagsmithConfig} from '../../sdk/index.js';
5 | import { Fetch, FlagsmithCache } from '../../sdk/types.js';
6 | import { Flags } from '../../sdk/models.js';
7 |
8 | const DATA_DIR = __dirname + '/data/';
9 |
10 | export class TestCache implements FlagsmithCache {
11 | cache: Record = {};
12 |
13 | async get(name: string): Promise {
14 | return this.cache[name];
15 | }
16 |
17 | async set(name: string, value: Flags) {
18 | this.cache[name] = value;
19 | }
20 | }
21 |
22 | export const fetch = vi.fn((url: string, options?: RequestInit) => {
23 | const headers = options?.headers as Record;
24 | if (!headers) throw new Error('missing request headers')
25 | const env = headers['X-Environment-Key'];
26 | if (!env) return Promise.resolve(new Response('missing x-environment-key header', { status: 404 }));
27 | if (url.includes('/environment-document')) {
28 | if (env.startsWith('ser.')) {
29 | return Promise.resolve(new Response(environmentJSON, { status: 200 }))
30 | }
31 | return Promise.resolve(new Response('environment-document called without a server-side key', { status: 401 }))
32 | }
33 | if (url.includes("/flags")) {
34 | return Promise.resolve(new Response(flagsJSON, { status: 200 }))
35 | }
36 | if (url.includes("/identities")) {
37 | return Promise.resolve(new Response(identitiesJSON, { status: 200 }))
38 | }
39 | return Promise.resolve(new Response('unknown url ' + url, { status: 404 }))
40 | });
41 |
42 | export const badFetch: Fetch = () => { throw new Error('fetch failed')}
43 |
44 | export function analyticsProcessor() {
45 | return new AnalyticsProcessor({
46 | environmentKey: 'test-key',
47 | analyticsUrl: 'http://testUrl/analytics/flags/',
48 | fetch: (url, options) => fetch(url.toString(), options),
49 | });
50 | }
51 |
52 | export function apiKey(): string {
53 | return 'sometestfakekey';
54 | }
55 |
56 | export function flagsmith(params: FlagsmithConfig = {}) {
57 | return new Flagsmith({
58 | environmentKey: apiKey(),
59 | environmentRefreshIntervalSeconds: 0,
60 | requestRetryDelayMilliseconds: 0,
61 | fetch: (url, options) => fetch(url.toString(), options),
62 | ...params,
63 | });
64 | }
65 |
66 | export const environmentJSON = readFileSync(DATA_DIR + 'environment.json', 'utf-8');
67 |
68 | export const offlineEnvironmentJSON = readFileSync(DATA_DIR + 'offline-environment.json', 'utf-8')
69 |
70 | export function environmentModel(environmentJSON: any) {
71 | return buildEnvironmentModel(environmentJSON);
72 | }
73 |
74 | export const flagsJSON = readFileSync(DATA_DIR + 'flags.json', 'utf-8')
75 |
76 | export const identitiesJSON = readFileSync(DATA_DIR + 'identities.json', 'utf-8')
77 |
78 | export const transientIdentityJSON = readFileSync(DATA_DIR + 'transient-identity.json', 'utf-8')
79 |
80 | export const identityWithTransientTraitsJSON = readFileSync(DATA_DIR + 'identity-with-transient-traits.json', 'utf-8')
81 |
--------------------------------------------------------------------------------
/tsconfig.cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./build/cjs",
5 | "module": "CommonJS"
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./build/esm",
5 | "module": "ESNext"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "./flagsmith-engine",
4 | "./sdk",
5 | "./index.ts"
6 | ],
7 | "compilerOptions": {
8 | "outDir": "./build",
9 | "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
10 | "downlevelIteration": true, // Used to allow typescript to interpret Object.entries
11 | /* Modules */
12 | "module": "ESNext",
13 | "moduleResolution": "Node16",
14 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
15 | "declaration": true,
16 | "strict": true, /* Enable all strict type-checking options. */
17 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
18 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */
19 | "resolveJsonModule": true,
20 | "types": [
21 | "vitest/globals"
22 | ]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | restoreMocks: true,
7 | coverage: {
8 | reporter: ['text'],
9 | exclude: [
10 | 'build/**'
11 | ],
12 | include: [
13 | 'sdk/**',
14 | 'flagsmith-engine/**',
15 | ]
16 | }
17 | },
18 | })
19 |
--------------------------------------------------------------------------------