#
tokens: 44013/50000 2/327 files (page 8/9)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 8 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .editorconfig
├── .gitattributes
├── .github
│   └── workflows
│       ├── package-ci.yml
│       └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│   ├── extensions.json
│   └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│   ├── browser-sdk
│   │   ├── .prettierignore
│   │   ├── eslint.config.js
│   │   ├── example
│   │   │   ├── feedback
│   │   │   │   ├── feedback.html
│   │   │   │   └── Feedback.jsx
│   │   │   └── typescript
│   │   │       ├── app.ts
│   │   │       └── index.html
│   │   ├── FEEDBACK.md
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── playwright.config.ts
│   │   ├── postcss.config.js
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── client.ts
│   │   │   ├── config.ts
│   │   │   ├── context.ts
│   │   │   ├── feedback
│   │   │   │   ├── feedback.ts
│   │   │   │   ├── prompts.ts
│   │   │   │   ├── promptStorage.ts
│   │   │   │   └── ui
│   │   │   │       ├── Button.css
│   │   │   │       ├── Button.tsx
│   │   │   │       ├── config
│   │   │   │       │   └── defaultTranslations.tsx
│   │   │   │       ├── css.d.ts
│   │   │   │       ├── FeedbackDialog.css
│   │   │   │       ├── FeedbackDialog.tsx
│   │   │   │       ├── FeedbackForm.css
│   │   │   │       ├── FeedbackForm.tsx
│   │   │   │       ├── hooks
│   │   │   │       │   └── useTimer.ts
│   │   │   │       ├── index.css
│   │   │   │       ├── index.ts
│   │   │   │       ├── Plug.tsx
│   │   │   │       ├── RadialProgress.css
│   │   │   │       ├── RadialProgress.tsx
│   │   │   │       ├── StarRating.css
│   │   │   │       ├── StarRating.tsx
│   │   │   │       └── types.ts
│   │   │   ├── flag
│   │   │   │   ├── flagCache.ts
│   │   │   │   └── flags.ts
│   │   │   ├── hooksManager.ts
│   │   │   ├── httpClient.ts
│   │   │   ├── index.ts
│   │   │   ├── logger.ts
│   │   │   ├── rateLimiter.ts
│   │   │   ├── sse.ts
│   │   │   ├── toolbar
│   │   │   │   ├── Flags.css
│   │   │   │   ├── Flags.tsx
│   │   │   │   ├── index.css
│   │   │   │   ├── index.ts
│   │   │   │   ├── Switch.css
│   │   │   │   ├── Switch.tsx
│   │   │   │   ├── Toolbar.css
│   │   │   │   └── Toolbar.tsx
│   │   │   └── ui
│   │   │       ├── constants.ts
│   │   │       ├── Dialog.css
│   │   │       ├── Dialog.tsx
│   │   │       ├── icons
│   │   │       │   ├── Check.tsx
│   │   │       │   ├── CheckCircle.tsx
│   │   │       │   ├── Close.tsx
│   │   │       │   ├── Dissatisfied.tsx
│   │   │       │   ├── Logo.tsx
│   │   │       │   ├── Neutral.tsx
│   │   │       │   ├── Satisfied.tsx
│   │   │       │   ├── VeryDissatisfied.tsx
│   │   │       │   └── VerySatisfied.tsx
│   │   │       ├── packages
│   │   │       │   └── floating-ui-preact-dom
│   │   │       │       ├── arrow.ts
│   │   │       │       ├── index.ts
│   │   │       │       ├── README.md
│   │   │       │       ├── types.ts
│   │   │       │       ├── useFloating.ts
│   │   │       │       └── utils
│   │   │       │           ├── deepEqual.ts
│   │   │       │           ├── getDPR.ts
│   │   │       │           ├── roundByDPR.ts
│   │   │       │           └── useLatestRef.ts
│   │   │       ├── types.ts
│   │   │       └── utils.ts
│   │   ├── test
│   │   │   ├── client.test.ts
│   │   │   ├── e2e
│   │   │   │   ├── acceptance.browser.spec.ts
│   │   │   │   ├── empty.html
│   │   │   │   ├── feedback-widget.browser.spec.ts
│   │   │   │   └── give-feedback-button.html
│   │   │   ├── flagCache.test.ts
│   │   │   ├── flags.test.ts
│   │   │   ├── hooksManager.test.ts
│   │   │   ├── httpClient.test.ts
│   │   │   ├── init.test.ts
│   │   │   ├── mocks
│   │   │   │   ├── handlers.ts
│   │   │   │   └── server.ts
│   │   │   ├── prompts.test.ts
│   │   │   ├── promptStorage.test.ts
│   │   │   ├── rateLimiter.test.ts
│   │   │   ├── sse.test.ts
│   │   │   ├── testLogger.ts
│   │   │   └── usage.test.ts
│   │   ├── tsconfig.build.json
│   │   ├── tsconfig.eslint.json
│   │   ├── tsconfig.json
│   │   ├── typedoc.json
│   │   ├── vite.config.mjs
│   │   ├── vite.e2e.config.js
│   │   └── vitest.setup.ts
│   ├── cli
│   │   ├── .prettierignore
│   │   ├── commands
│   │   │   ├── apps.ts
│   │   │   ├── auth.ts
│   │   │   ├── flags.ts
│   │   │   ├── init.ts
│   │   │   ├── mcp.ts
│   │   │   ├── new.ts
│   │   │   └── rules.ts
│   │   ├── eslint.config.js
│   │   ├── index.ts
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── schema.json
│   │   ├── services
│   │   │   ├── bootstrap.ts
│   │   │   ├── flags.ts
│   │   │   ├── mcp.ts
│   │   │   └── rules.ts
│   │   ├── stores
│   │   │   ├── auth.ts
│   │   │   └── config.ts
│   │   ├── test
│   │   │   └── json.test.ts
│   │   ├── tsconfig.eslint.json
│   │   ├── tsconfig.json
│   │   ├── utils
│   │   │   ├── auth.ts
│   │   │   ├── commander.ts
│   │   │   ├── constants.ts
│   │   │   ├── errors.ts
│   │   │   ├── file.ts
│   │   │   ├── gen.ts
│   │   │   ├── json.ts
│   │   │   ├── options.ts
│   │   │   ├── schemas.ts
│   │   │   ├── types.ts
│   │   │   ├── urls.ts
│   │   │   └── version.ts
│   │   └── vite.config.js
│   ├── eslint-config
│   │   ├── base.js
│   │   └── package.json
│   ├── flag-evaluation
│   │   ├── .prettierignore
│   │   ├── eslint.config.js
│   │   ├── jest.config.js
│   │   ├── package.json
│   │   ├── src
│   │   │   └── index.ts
│   │   ├── test
│   │   │   └── index.test.ts
│   │   ├── tsconfig.build.json
│   │   ├── tsconfig.eslint.json
│   │   └── tsconfig.json
│   ├── node-sdk
│   │   ├── .prettierignore
│   │   ├── docs
│   │   │   ├── type-check-failed.png
│   │   │   └── type-check-payload-failed.png
│   │   ├── eslint.config.js
│   │   ├── examples
│   │   │   ├── cloudflare-worker
│   │   │   │   ├── .gitignore
│   │   │   │   ├── .prettierignore
│   │   │   │   ├── .vscode
│   │   │   │   │   └── settings.json
│   │   │   │   ├── package.json
│   │   │   │   ├── README.md
│   │   │   │   ├── src
│   │   │   │   │   └── index.ts
│   │   │   │   ├── tsconfig.json
│   │   │   │   ├── vitest.config.mts
│   │   │   │   ├── worker-configuration.d.ts
│   │   │   │   ├── wrangler.jsonc
│   │   │   │   └── yarn.lock
│   │   │   └── express
│   │   │       ├── app.test.ts
│   │   │       ├── app.ts
│   │   │       ├── bucket.ts
│   │   │       ├── bucketConfig.json
│   │   │       ├── package.json
│   │   │       ├── README.md
│   │   │       ├── serve.ts
│   │   │       ├── tsconfig.json
│   │   │       └── yarn.lock
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── batch-buffer.ts
│   │   │   ├── client.ts
│   │   │   ├── config.ts
│   │   │   ├── edgeClient.ts
│   │   │   ├── fetch-http-client.ts
│   │   │   ├── flusher.ts
│   │   │   ├── index.ts
│   │   │   ├── inRequestCache.ts
│   │   │   ├── periodicallyUpdatingCache.ts
│   │   │   ├── rate-limiter.ts
│   │   │   ├── types.ts
│   │   │   └── utils.ts
│   │   ├── test
│   │   │   ├── batch-buffer.test.ts
│   │   │   ├── client.test.ts
│   │   │   ├── config.test.ts
│   │   │   ├── fetch-http-client.test.ts
│   │   │   ├── flusher.test.ts
│   │   │   ├── inRequestCache.test.ts
│   │   │   ├── periodicallyUpdatingCache.test.ts
│   │   │   ├── rate-limiter.test.ts
│   │   │   ├── testConfig.json
│   │   │   └── utils.test.ts
│   │   ├── tsconfig.build.json
│   │   ├── tsconfig.eslint.json
│   │   ├── tsconfig.json
│   │   ├── typedoc.json
│   │   └── vite.config.js
│   ├── openfeature-browser-provider
│   │   ├── .prettierignore
│   │   ├── eslint.config.js
│   │   ├── example
│   │   │   ├── .eslintrc.json
│   │   │   ├── .gitignore
│   │   │   ├── app
│   │   │   │   ├── featureManagement.ts
│   │   │   │   ├── globals.css
│   │   │   │   ├── layout.tsx
│   │   │   │   └── page.tsx
│   │   │   ├── components
│   │   │   │   ├── Context.tsx
│   │   │   │   ├── HuddleFeature.tsx
│   │   │   │   └── OpenFeatureProvider.tsx
│   │   │   ├── next.config.mjs
│   │   │   ├── package.json
│   │   │   ├── postcss.config.mjs
│   │   │   ├── README.md
│   │   │   ├── tailwind.config.ts
│   │   │   └── tsconfig.json
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── index.test.ts
│   │   │   └── index.ts
│   │   ├── tsconfig.build.json
│   │   ├── tsconfig.eslint.json
│   │   ├── tsconfig.json
│   │   └── vite.config.js
│   ├── openfeature-node-provider
│   │   ├── .prettierignore
│   │   ├── eslint.config.js
│   │   ├── example
│   │   │   ├── app.ts
│   │   │   ├── package.json
│   │   │   ├── README.md
│   │   │   ├── reflag.ts
│   │   │   ├── serve.ts
│   │   │   ├── tsconfig.json
│   │   │   └── yarn.lock
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── index.test.ts
│   │   │   └── index.ts
│   │   ├── tsconfig.build.json
│   │   ├── tsconfig.eslint.json
│   │   ├── tsconfig.json
│   │   └── vite.config.js
│   ├── react-sdk
│   │   ├── .prettierignore
│   │   ├── dev
│   │   │   ├── .env
│   │   │   ├── nextjs-bootstrap-demo
│   │   │   │   ├── .eslintrc.json
│   │   │   │   ├── .gitignore
│   │   │   │   ├── app
│   │   │   │   │   ├── client.ts
│   │   │   │   │   ├── favicon.ico
│   │   │   │   │   ├── globals.css
│   │   │   │   │   ├── layout.tsx
│   │   │   │   │   └── page.tsx
│   │   │   │   ├── components
│   │   │   │   │   └── Flags.tsx
│   │   │   │   ├── next.config.mjs
│   │   │   │   ├── package.json
│   │   │   │   ├── postcss.config.mjs
│   │   │   │   ├── public
│   │   │   │   │   ├── next.svg
│   │   │   │   │   └── vercel.svg
│   │   │   │   ├── README.md
│   │   │   │   ├── tailwind.config.ts
│   │   │   │   └── tsconfig.json
│   │   │   ├── nextjs-flag-demo
│   │   │   │   ├── .eslintrc.json
│   │   │   │   ├── .gitignore
│   │   │   │   ├── app
│   │   │   │   │   ├── favicon.ico
│   │   │   │   │   ├── globals.css
│   │   │   │   │   ├── layout.tsx
│   │   │   │   │   └── page.tsx
│   │   │   │   ├── components
│   │   │   │   │   ├── Flags.tsx
│   │   │   │   │   └── Providers.tsx
│   │   │   │   ├── next.config.mjs
│   │   │   │   ├── package.json
│   │   │   │   ├── postcss.config.mjs
│   │   │   │   ├── public
│   │   │   │   │   ├── next.svg
│   │   │   │   │   └── vercel.svg
│   │   │   │   ├── README.md
│   │   │   │   ├── tailwind.config.ts
│   │   │   │   └── tsconfig.json
│   │   │   └── plain
│   │   │       ├── app.tsx
│   │   │       ├── index.html
│   │   │       ├── index.tsx
│   │   │       ├── tsconfig.json
│   │   │       └── vite-env.d.ts
│   │   ├── eslint.config.js
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.tsx
│   │   ├── test
│   │   │   └── usage.test.tsx
│   │   ├── tsconfig.build.json
│   │   ├── tsconfig.eslint.json
│   │   ├── tsconfig.json
│   │   ├── typedoc.json
│   │   └── vite.config.mjs
│   ├── tsconfig
│   │   ├── library.json
│   │   └── package.json
│   └── vue-sdk
│       ├── .prettierignore
│       ├── dev
│       │   └── plain
│       │       ├── App.vue
│       │       ├── components
│       │       │   ├── Events.vue
│       │       │   ├── FlagsList.vue
│       │       │   ├── MissingKeyMessage.vue
│       │       │   ├── RequestFeedback.vue
│       │       │   ├── Section.vue
│       │       │   ├── StartHuddlesButton.vue
│       │       │   └── Track.vue
│       │       ├── env.d.ts
│       │       ├── index.html
│       │       └── index.ts
│       ├── eslint.config.js
│       ├── package.json
│       ├── README.md
│       ├── src
│       │   ├── hooks.ts
│       │   ├── index.ts
│       │   ├── ReflagBootstrappedProvider.vue
│       │   ├── ReflagClientProvider.vue
│       │   ├── ReflagProvider.vue
│       │   ├── types.ts
│       │   ├── version.ts
│       │   └── vue.d.ts
│       ├── test
│       │   └── usage.test.ts
│       ├── tsconfig.build.json
│       ├── tsconfig.eslint.json
│       ├── tsconfig.json
│       ├── typedoc.json
│       └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```

# Files

--------------------------------------------------------------------------------
/packages/node-sdk/src/client.ts:
--------------------------------------------------------------------------------

```typescript
   1 | import fs from "fs";
   2 | 
   3 | import {
   4 |   EvaluationResult,
   5 |   flattenJSON,
   6 |   newEvaluator,
   7 | } from "@reflag/flag-evaluation";
   8 | 
   9 | import BatchBuffer from "./batch-buffer";
  10 | import {
  11 |   API_BASE_URL,
  12 |   API_TIMEOUT_MS,
  13 |   FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
  14 |   FLAGS_REFETCH_MS,
  15 |   loadConfig,
  16 |   REFLAG_LOG_PREFIX,
  17 |   SDK_VERSION,
  18 |   SDK_VERSION_HEADER_NAME,
  19 | } from "./config";
  20 | import fetchClient, { withRetry } from "./fetch-http-client";
  21 | import { subscribe as triggerOnExit } from "./flusher";
  22 | import inRequestCache from "./inRequestCache";
  23 | import periodicallyUpdatingCache from "./periodicallyUpdatingCache";
  24 | import { newRateLimiter } from "./rate-limiter";
  25 | import type {
  26 |   BootstrappedFlags,
  27 |   CachedFlagDefinition,
  28 |   CacheStrategy,
  29 |   EvaluatedFlagsAPIResponse,
  30 |   FlagDefinition,
  31 |   FlagOverrides,
  32 |   FlagOverridesFn,
  33 |   IdType,
  34 |   RawFlag,
  35 |   RawFlags,
  36 |   TypedFlagKey,
  37 | } from "./types";
  38 | import {
  39 |   Attributes,
  40 |   Cache,
  41 |   ClientOptions,
  42 |   Context,
  43 |   ContextWithTracking,
  44 |   FlagEvent,
  45 |   FlagsAPIResponse,
  46 |   HttpClient,
  47 |   Logger,
  48 |   TrackingMeta,
  49 |   TrackOptions,
  50 |   TypedFlags,
  51 | } from "./types";
  52 | import {
  53 |   applyLogLevel,
  54 |   decorateLogger,
  55 |   hashObject,
  56 |   idOk,
  57 |   isObject,
  58 |   mergeSkipUndefined,
  59 |   ok,
  60 |   once,
  61 | } from "./utils";
  62 | 
  63 | const reflagConfigDefaultFile = "reflag.config.json";
  64 | 
  65 | type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
  66 | 
  67 | type BulkEvent =
  68 |   | {
  69 |       type: "company";
  70 |       companyId: IdType;
  71 |       userId?: IdType;
  72 |       attributes?: Attributes;
  73 |       context?: TrackingMeta;
  74 |     }
  75 |   | {
  76 |       type: "user";
  77 |       userId: IdType;
  78 |       attributes?: Attributes;
  79 |       context?: TrackingMeta;
  80 |     }
  81 |   | {
  82 |       type: "feature-flag-event";
  83 |       action: "check" | "check-config";
  84 |       key: string;
  85 |       targetingVersion?: number;
  86 |       evalResult:
  87 |         | boolean
  88 |         | { key: string; payload: any }
  89 |         | { key: undefined; payload: undefined };
  90 |       evalContext?: Record<string, any>;
  91 |       evalRuleResults?: boolean[];
  92 |       evalMissingFields?: string[];
  93 |     }
  94 |   | {
  95 |       type: "event";
  96 |       event: string;
  97 |       companyId?: IdType;
  98 |       userId: IdType;
  99 |       attributes?: Attributes;
 100 |       context?: TrackingMeta;
 101 |     };
 102 | 
 103 | /**
 104 |  * The SDK client.
 105 |  *
 106 |  * @remarks
 107 |  * This is the main class for interacting with Reflag.
 108 |  * It is used to evaluate flags, update user and company contexts, and track events.
 109 |  *
 110 |  * @example
 111 |  * ```ts
 112 |  * // set the REFLAG_SECRET_KEY environment variable or pass the secret key to the constructor
 113 |  * const client = new ReflagClient();
 114 |  *
 115 |  * // evaluate a flag
 116 |  * const isFlagEnabled = client.getFlag("flag-key", {
 117 |  *   user: { id: "user-id" },
 118 |  *   company: { id: "company-id" },
 119 |  * });
 120 |  * ```
 121 |  **/
 122 | export class ReflagClient {
 123 |   private _config: {
 124 |     apiBaseUrl: string;
 125 |     refetchInterval: number;
 126 |     staleWarningInterval: number;
 127 |     headers: Record<string, string>;
 128 |     fallbackFlags?: RawFlags;
 129 |     flagOverrides: FlagOverridesFn;
 130 |     offline: boolean;
 131 |     configFile?: string;
 132 |     flagsFetchRetries: number;
 133 |     fetchTimeoutMs: number;
 134 |     cacheStrategy: CacheStrategy;
 135 |   };
 136 |   httpClient: HttpClient;
 137 | 
 138 |   private flagsCache: Cache<CachedFlagDefinition[]>;
 139 |   private batchBuffer: BatchBuffer<BulkEvent>;
 140 |   private rateLimiter: ReturnType<typeof newRateLimiter>;
 141 | 
 142 |   /**
 143 |    * Gets the logger associated with the client.
 144 |    */
 145 |   public readonly logger: Logger;
 146 | 
 147 |   private initializationFinished = false;
 148 |   private _initialize = once(async () => {
 149 |     const start = Date.now();
 150 |     if (!this._config.offline) {
 151 |       await this.flagsCache.refresh();
 152 |     }
 153 |     this.logger.info(
 154 |       "Reflag initialized in " +
 155 |         Math.round(Date.now() - start) +
 156 |         "ms" +
 157 |         (this._config.offline ? " (offline mode)" : ""),
 158 |     );
 159 |     this.initializationFinished = true;
 160 |   });
 161 | 
 162 |   /**
 163 |    * Creates a new SDK client.
 164 |    * See README for configuration options.
 165 |    *
 166 |    * @param options - The options for the client or an existing client to clone.
 167 |    * @param options.secretKey - The secret key to use for the client.
 168 |    * @param options.apiBaseUrl - The base URL to send requests to (optional).
 169 |    * @param options.logger - The logger to use for logging (optional).
 170 |    * @param options.httpClient - The HTTP client to use for sending requests (optional).
 171 |    * @param options.logLevel - The log level to use for logging (optional).
 172 |    * @param options.offline - Whether to run in offline mode (optional).
 173 |    * @param options.fallbackFlags - The fallback flags to use if the flag is not found (optional).
 174 |    * @param options.batchOptions - The options for the batch buffer (optional).
 175 |    * @param options.flagOverrides - The flag overrides to use for the client (optional).
 176 |    * @param options.configFile - The path to the config file (optional).
 177 |    * @param options.flagsFetchRetries - Number of retries for fetching flags (optional, defaults to 3).
 178 |    * @param options.fetchTimeoutMs - Timeout for fetching flags (optional, defaults to 10000ms).
 179 |    * @param options.cacheStrategy - The cache strategy to use for the client (optional, defaults to "periodically-update").
 180 |    *
 181 |    * @throws An error if the options are invalid.
 182 |    **/
 183 |   constructor(options: ClientOptions = {}) {
 184 |     ok(isObject(options), "options must be an object");
 185 | 
 186 |     ok(
 187 |       options.host === undefined ||
 188 |         (typeof options.host === "string" && options.host.length > 0),
 189 |       "host must be a string",
 190 |     );
 191 |     ok(
 192 |       options.apiBaseUrl === undefined ||
 193 |         (typeof options.apiBaseUrl === "string" &&
 194 |           options.apiBaseUrl.length > 0),
 195 |       "apiBaseUrl must be a string",
 196 |     );
 197 |     ok(
 198 |       options.logger === undefined || isObject(options.logger),
 199 |       "logger must be an object",
 200 |     );
 201 |     ok(
 202 |       options.httpClient === undefined || isObject(options.httpClient),
 203 |       "httpClient must be an object",
 204 |     );
 205 |     ok(
 206 |       options.fallbackFlags === undefined ||
 207 |         Array.isArray(options.fallbackFlags) ||
 208 |         isObject(options.fallbackFlags),
 209 |       "fallbackFlags must be an array or object",
 210 |     );
 211 |     ok(
 212 |       options.batchOptions === undefined || isObject(options.batchOptions),
 213 |       "batchOptions must be an object",
 214 |     );
 215 |     ok(
 216 |       options.configFile === undefined ||
 217 |         typeof options.configFile === "string",
 218 |       "configFile must be a string",
 219 |     );
 220 | 
 221 |     ok(
 222 |       options.flagsFetchRetries === undefined ||
 223 |         (Number.isInteger(options.flagsFetchRetries) &&
 224 |           options.flagsFetchRetries >= 0),
 225 |       "flagsFetchRetries must be a non-negative integer",
 226 |     );
 227 | 
 228 |     ok(
 229 |       options.fetchTimeoutMs === undefined ||
 230 |         (Number.isInteger(options.fetchTimeoutMs) &&
 231 |           options.fetchTimeoutMs >= 0),
 232 |       "fetchTimeoutMs must be a non-negative integer",
 233 |     );
 234 | 
 235 |     if (!options.configFile) {
 236 |       options.configFile =
 237 |         (process.env.REFLAG_CONFIG_FILE ??
 238 |         fs.existsSync(reflagConfigDefaultFile))
 239 |           ? reflagConfigDefaultFile
 240 |           : undefined;
 241 |     }
 242 | 
 243 |     const externalConfig = loadConfig(options.configFile);
 244 |     const config = mergeSkipUndefined(externalConfig, options);
 245 | 
 246 |     const offline = config.offline ?? process.env.NODE_ENV === "test";
 247 |     if (!offline) {
 248 |       ok(
 249 |         typeof config.secretKey === "string",
 250 |         "secretKey must be a string, or set offline=true",
 251 |       );
 252 |       ok(config.secretKey.length > 22, "invalid secretKey specified");
 253 |     }
 254 | 
 255 |     // use the supplied logger or apply the log level to the console logger
 256 |     const logLevel = options.logLevel ?? config?.logLevel ?? "INFO";
 257 | 
 258 |     this.logger = options.logger
 259 |       ? options.logger
 260 |       : applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel);
 261 | 
 262 |     const fallbackFlags = Array.isArray(options.fallbackFlags)
 263 |       ? options.fallbackFlags.reduce((acc, key) => {
 264 |           acc[key as TypedFlagKey] = {
 265 |             isEnabled: true,
 266 |             key,
 267 |           };
 268 |           return acc;
 269 |         }, {} as RawFlags)
 270 |       : isObject(options.fallbackFlags)
 271 |         ? Object.entries(options.fallbackFlags).reduce(
 272 |             (acc, [key, fallback]) => {
 273 |               acc[key as TypedFlagKey] = {
 274 |                 isEnabled:
 275 |                   typeof fallback === "object"
 276 |                     ? fallback.isEnabled
 277 |                     : !!fallback,
 278 |                 key,
 279 |                 config:
 280 |                   typeof fallback === "object" && fallback.config
 281 |                     ? {
 282 |                         key: fallback.config.key,
 283 |                         payload: fallback.config.payload,
 284 |                       }
 285 |                     : undefined,
 286 |               };
 287 |               return acc;
 288 |             },
 289 |             {} as RawFlags,
 290 |           )
 291 |         : undefined;
 292 | 
 293 |     this.rateLimiter = newRateLimiter(FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS);
 294 |     this.httpClient = options.httpClient || fetchClient;
 295 |     this.batchBuffer = new BatchBuffer<BulkEvent>({
 296 |       ...options?.batchOptions,
 297 |       flushHandler: (items) => this.sendBulkEvents(items),
 298 |       logger: this.logger,
 299 |     });
 300 | 
 301 |     this._config = {
 302 |       offline,
 303 |       apiBaseUrl: (config.apiBaseUrl ?? config.host) || API_BASE_URL,
 304 |       headers: {
 305 |         "Content-Type": "application/json",
 306 |         [SDK_VERSION_HEADER_NAME]: SDK_VERSION,
 307 |         ["Authorization"]: `Bearer ${config.secretKey}`,
 308 |       },
 309 |       refetchInterval: FLAGS_REFETCH_MS,
 310 |       staleWarningInterval: FLAGS_REFETCH_MS * 5,
 311 |       fallbackFlags: fallbackFlags,
 312 |       flagOverrides:
 313 |         typeof config.flagOverrides === "function"
 314 |           ? config.flagOverrides
 315 |           : () => config.flagOverrides,
 316 |       flagsFetchRetries: options.flagsFetchRetries ?? 3,
 317 |       fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS,
 318 |       cacheStrategy: options.cacheStrategy ?? "periodically-update",
 319 |     };
 320 | 
 321 |     if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) {
 322 |       triggerOnExit(() => this.flush());
 323 |     }
 324 | 
 325 |     if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) {
 326 |       this._config.apiBaseUrl += "/";
 327 |     }
 328 | 
 329 |     const fetchFlags = async () => {
 330 |       const res = await this.get<FlagsAPIResponse>(
 331 |         "features",
 332 |         this._config.flagsFetchRetries,
 333 |       );
 334 |       if (!isObject(res) || !Array.isArray(res?.features)) {
 335 |         this.logger.warn("flags cache: invalid response", res);
 336 |         return undefined;
 337 |       }
 338 | 
 339 |       return res.features.map((flagDef) => {
 340 |         return {
 341 |           ...flagDef,
 342 |           enabledEvaluator: newEvaluator(
 343 |             flagDef.targeting.rules.map((rule) => ({
 344 |               filter: rule.filter,
 345 |               value: true,
 346 |             })),
 347 |           ),
 348 |           configEvaluator: flagDef.config
 349 |             ? newEvaluator(
 350 |                 flagDef.config?.variants.map((variant) => ({
 351 |                   filter: variant.filter,
 352 |                   value: {
 353 |                     key: variant.key,
 354 |                     payload: variant.payload,
 355 |                   },
 356 |                 })),
 357 |               )
 358 |             : undefined,
 359 |         } satisfies CachedFlagDefinition;
 360 |       });
 361 |     };
 362 | 
 363 |     if (this._config.cacheStrategy === "periodically-update") {
 364 |       this.flagsCache = periodicallyUpdatingCache<CachedFlagDefinition[]>(
 365 |         this._config.refetchInterval,
 366 |         this._config.staleWarningInterval,
 367 |         this.logger,
 368 |         fetchFlags,
 369 |       );
 370 |     } else {
 371 |       this.flagsCache = inRequestCache<CachedFlagDefinition[]>(
 372 |         this._config.refetchInterval,
 373 |         this.logger,
 374 |         fetchFlags,
 375 |       );
 376 |     }
 377 |   }
 378 | 
 379 |   /**
 380 |    * Sets the flag overrides.
 381 |    *
 382 |    * @param overrides - The flag overrides.
 383 |    *
 384 |    * @remarks
 385 |    * The flag overrides are used to override the flag definitions.
 386 |    * This is useful for testing or development.
 387 |    *
 388 |    * @example
 389 |    * ```ts
 390 |    * client.flagOverrides = {
 391 |    *   "flag-1": true,
 392 |    *   "flag-2": false,
 393 |    * };
 394 |    * ```
 395 |    **/
 396 |   set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) {
 397 |     if (typeof overrides === "object") {
 398 |       this._config.flagOverrides = () => overrides;
 399 |     } else {
 400 |       this._config.flagOverrides = overrides;
 401 |     }
 402 |   }
 403 | 
 404 |   /**
 405 |    * Clears the flag overrides.
 406 |    *
 407 |    * @remarks
 408 |    * This is useful for testing or development.
 409 |    *
 410 |    * @example
 411 |    * ```ts
 412 |    * afterAll(() => {
 413 |    *   client.clearFlagOverrides();
 414 |    * });
 415 |    * ```
 416 |    **/
 417 |   clearFlagOverrides() {
 418 |     this._config.flagOverrides = () => ({});
 419 |   }
 420 | 
 421 |   /**
 422 |    * Returns a new BoundReflagClient with the user/company/otherContext
 423 |    * set to be used in subsequent calls.
 424 |    * For example, for evaluating flag targeting or tracking events.
 425 |    *
 426 |    * @param context - The context to bind the client to.
 427 |    * @param context.enableTracking - Whether to enable tracking for the context.
 428 |    * @param context.user - The user context.
 429 |    * @param context.company - The company context.
 430 |    * @param context.other - The other context.
 431 |    *
 432 |    * @returns A new client bound with the arguments given.
 433 |    *
 434 |    * @throws An error if the user/company is given but their ID is not a string.
 435 |    *
 436 |    * @remarks
 437 |    * The `updateUser` / `updateCompany` methods will automatically be called when
 438 |    * the user/company is set respectively.
 439 |    **/
 440 |   public bindClient({
 441 |     enableTracking = true,
 442 |     ...context
 443 |   }: ContextWithTracking) {
 444 |     return new BoundReflagClient(this, { enableTracking, ...context });
 445 |   }
 446 | 
 447 |   /**
 448 |    * Updates the associated user in Reflag.
 449 |    *
 450 |    * @param userId - The userId of the user to update.
 451 |    * @param options - The options for the user.
 452 |    * @param options.attributes - The additional attributes of the user (optional).
 453 |    * @param options.meta - The meta context associated with tracking (optional).
 454 |    *
 455 |    * @throws An error if the company is not set or the options are invalid.
 456 |    * @remarks
 457 |    * The company must be set using `withCompany` before calling this method.
 458 |    * If the user is set, the company will be associated with the user.
 459 |    **/
 460 |   public async updateUser(userId: IdType, options?: TrackOptions) {
 461 |     idOk(userId, "userId");
 462 |     ok(options === undefined || isObject(options), "options must be an object");
 463 |     ok(
 464 |       options?.attributes === undefined || isObject(options.attributes),
 465 |       "attributes must be an object",
 466 |     );
 467 |     checkMeta(options?.meta);
 468 | 
 469 |     if (this._config.offline) {
 470 |       return;
 471 |     }
 472 | 
 473 |     if (this.rateLimiter.isAllowed(hashObject({ ...options, userId }))) {
 474 |       await this.batchBuffer.add({
 475 |         type: "user",
 476 |         userId,
 477 |         attributes: options?.attributes,
 478 |         context: options?.meta,
 479 |       });
 480 |     }
 481 |   }
 482 | 
 483 |   /**
 484 |    * Updates the associated company in Reflag.
 485 |    *
 486 |    * @param companyId - The companyId of the company to update.
 487 |    * @param options - The options for the company.
 488 |    * @param options.attributes - The additional attributes of the company (optional).
 489 |    * @param options.meta - The meta context associated with tracking (optional).
 490 |    * @param options.userId - The userId of the user to associate with the company (optional).
 491 |    *
 492 |    * @throws An error if the company is not set or the options are invalid.
 493 |    * @remarks
 494 |    * The company must be set using `withCompany` before calling this method.
 495 |    * If the user is set, the company will be associated with the user.
 496 |    **/
 497 |   public async updateCompany(
 498 |     companyId: IdType,
 499 |     options?: TrackOptions & { userId?: IdType },
 500 |   ) {
 501 |     idOk(companyId, "companyId");
 502 |     ok(options === undefined || isObject(options), "options must be an object");
 503 |     ok(
 504 |       options?.attributes === undefined || isObject(options.attributes),
 505 |       "attributes must be an object",
 506 |     );
 507 |     checkMeta(options?.meta);
 508 | 
 509 |     if (typeof options?.userId !== "undefined") {
 510 |       idOk(options?.userId, "userId");
 511 |     }
 512 | 
 513 |     if (this._config.offline) {
 514 |       return;
 515 |     }
 516 | 
 517 |     if (this.rateLimiter.isAllowed(hashObject({ ...options, companyId }))) {
 518 |       await this.batchBuffer.add({
 519 |         type: "company",
 520 |         companyId,
 521 |         userId: options?.userId,
 522 |         attributes: options?.attributes,
 523 |         context: options?.meta,
 524 |       });
 525 |     }
 526 |   }
 527 | 
 528 |   /**
 529 |    * Tracks an event in Reflag.
 530 | 
 531 |    * @param options.companyId - Optional company ID for the event (optional).
 532 |    *
 533 |    * @throws An error if the user is not set or the event is invalid or the options are invalid.
 534 |    * @remarks
 535 |    * If the company is set, the event will be associated with the company.
 536 |    **/
 537 |   public async track(
 538 |     userId: IdType,
 539 |     event: string,
 540 |     options?: TrackOptions & { companyId?: IdType },
 541 |   ) {
 542 |     idOk(userId, "userId");
 543 |     ok(typeof event === "string" && event.length > 0, "event must be a string");
 544 |     ok(options === undefined || isObject(options), "options must be an object");
 545 |     ok(
 546 |       options?.attributes === undefined || isObject(options.attributes),
 547 |       "attributes must be an object",
 548 |     );
 549 |     ok(
 550 |       options?.meta === undefined || isObject(options.meta),
 551 |       "meta must be an object",
 552 |     );
 553 |     if (options?.companyId !== undefined) {
 554 |       idOk(options?.companyId, "companyId");
 555 |     }
 556 | 
 557 |     if (this._config.offline) {
 558 |       return;
 559 |     }
 560 | 
 561 |     await this.batchBuffer.add({
 562 |       type: "event",
 563 |       event,
 564 |       companyId: options?.companyId,
 565 |       userId,
 566 |       attributes: options?.attributes,
 567 |       context: options?.meta,
 568 |     });
 569 |   }
 570 | 
 571 |   /**
 572 |    * Initializes the client by caching the flags definitions.
 573 |    *
 574 |    * @remarks
 575 |    * Call this method before calling `getFlags` to ensure the flag definitions are cached.
 576 |    * The client will ignore subsequent calls to this method.
 577 |    **/
 578 |   public async initialize() {
 579 |     await this._initialize();
 580 |     return;
 581 |   }
 582 | 
 583 |   /**
 584 |    * Flushes and completes any in-flight fetches in the flag cache.
 585 |    *
 586 |    * @remarks
 587 |    * It is recommended to call this method when the application is shutting down to ensure all events are sent
 588 |    * before the process exits.
 589 |    *
 590 |    * This method is automatically called when the process exits if `batchOptions.flushOnExit` is `true` in the options (default).
 591 |    */
 592 |   public async flush() {
 593 |     if (this._config.offline) {
 594 |       return;
 595 |     }
 596 | 
 597 |     await this.batchBuffer.flush();
 598 |     await this.flagsCache.waitRefresh();
 599 |   }
 600 | 
 601 |   /**
 602 |    * Destroys the client and cleans up all resources including timers and background processes.
 603 |    *
 604 |    * @remarks
 605 |    * After calling this method, the client should not be used anymore.
 606 |    * This is particularly useful in development environments with hot reloading to prevent
 607 |    * multiple background processes from running simultaneously.
 608 |    */
 609 |   public destroy() {
 610 |     this.flagsCache.destroy();
 611 |     this.batchBuffer.destroy();
 612 |   }
 613 | 
 614 |   /**
 615 |    * Gets the flag definitions, including all config values.
 616 |    * To evaluate which flags are enabled for a given user/company, use `getFlags`.
 617 |    *
 618 |    * @returns The flags definitions.
 619 |    */
 620 |   public getFlagDefinitions(): FlagDefinition[] {
 621 |     const flags = this.flagsCache.get() || [];
 622 |     return flags.map((f) => ({
 623 |       key: f.key,
 624 |       description: f.description,
 625 |       flag: f.targeting,
 626 |       config: f.config,
 627 |     }));
 628 |   }
 629 | 
 630 |   /**
 631 |    * Gets the evaluated flags for the current context which includes the user, company, and custom context.
 632 |    *
 633 |    * @param options - The options for the context.
 634 |    * @param options.enableTracking - Whether to enable tracking for the context.
 635 |    * @param options.meta - The meta context associated with the context.
 636 |    * @param options.user - The user context.
 637 |    * @param options.company - The company context.
 638 |    * @param options.other - The other context.
 639 |    *
 640 |    * @returns The evaluated flags.
 641 |    *
 642 |    * @remarks
 643 |    * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
 644 |    **/
 645 |   public getFlags({
 646 |     enableTracking = true,
 647 |     ...context
 648 |   }: ContextWithTracking): TypedFlags {
 649 |     const contextWithTracking = { enableTracking, ...context };
 650 |     const rawFlags = this._getFlags(contextWithTracking);
 651 |     return Object.fromEntries(
 652 |       Object.entries(rawFlags).map(([key, rawFlag]) => [
 653 |         key,
 654 |         this._wrapRawFlag(contextWithTracking, rawFlag),
 655 |       ]),
 656 |     );
 657 |   }
 658 | 
 659 |   /**
 660 |    * Gets the evaluated flag for the current context which includes the user, company, and custom context.
 661 |    * Using the `isEnabled` property sends a `check` event to Reflag.
 662 |    *
 663 |    * @param key - The key of the flag to get.
 664 |    * @returns The evaluated flag.
 665 |    *
 666 |    * @remarks
 667 |    * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
 668 |    **/
 669 |   public getFlag<TKey extends TypedFlagKey>(
 670 |     { enableTracking = true, ...context }: ContextWithTracking,
 671 |     key: TKey,
 672 |   ): TypedFlags[TKey] {
 673 |     const contextWithTracking = { enableTracking, ...context };
 674 |     const rawFlag = this._getFlags(contextWithTracking, key);
 675 |     return this._wrapRawFlag(
 676 |       { enableChecks: true, ...contextWithTracking },
 677 |       rawFlag ?? { key },
 678 |     );
 679 |   }
 680 | 
 681 |   /**
 682 |    * Gets the evaluated flags for the current context without wrapping them in getters.
 683 |    * This method returns raw flag data suitable for bootstrapping client-side applications.
 684 |    *
 685 |    * @param options - The options for the context.
 686 |    * @param options.enableTracking - Whether to enable tracking for the context.
 687 |    * @param options.meta - The meta context associated with the context.
 688 |    * @param options.user - The user context.
 689 |    * @param options.company - The company context.
 690 |    * @param options.other - The other context.
 691 |    *
 692 |    * @returns The evaluated raw flags and the context.
 693 |    *
 694 |    * @remarks
 695 |    * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise.
 696 |    * This method returns RawFlag objects without wrapping them in getters, making them suitable for serialization.
 697 |    **/
 698 |   public getFlagsForBootstrap({
 699 |     enableTracking = true,
 700 |     ...context
 701 |   }: ContextWithTracking): BootstrappedFlags {
 702 |     const contextWithTracking = { enableTracking, ...context };
 703 |     return {
 704 |       context: contextWithTracking,
 705 |       flags: this._getFlags(contextWithTracking),
 706 |     };
 707 |   }
 708 | 
 709 |   /**
 710 |    * Gets evaluated flags with the usage of remote context.
 711 |    * This method triggers a network request every time it's called.
 712 |    *
 713 |    * @param userId - The userId of the user to get the flags for.
 714 |    * @param companyId - The companyId of the company to get the flags for.
 715 |    * @param additionalContext - The additional context to get the flags for.
 716 |    *
 717 |    * @returns evaluated flags
 718 |    */
 719 |   public async getFlagsRemote(
 720 |     userId?: IdType,
 721 |     companyId?: IdType,
 722 |     additionalContext?: Context,
 723 |   ): Promise<TypedFlags> {
 724 |     return this._getFlagsRemote(
 725 |       undefined,
 726 |       userId,
 727 |       companyId,
 728 |       additionalContext,
 729 |     );
 730 |   }
 731 | 
 732 |   /**
 733 |    * Gets evaluated flag with the usage of remote context.
 734 |    * This method triggers a network request every time it's called.
 735 |    *
 736 |    * @param key - The key of the flag to get.
 737 |    * @param userId - The userId of the user to get the flag for.
 738 |    * @param companyId - The companyId of the company to get the flag for.
 739 |    * @param additionalContext - The additional context to get the flag for.
 740 |    *
 741 |    * @returns evaluated flag
 742 |    */
 743 |   public async getFlagRemote<TKey extends TypedFlagKey>(
 744 |     key: TKey,
 745 |     userId?: IdType,
 746 |     companyId?: IdType,
 747 |     additionalContext?: Context,
 748 |   ): Promise<TypedFlags[TKey]> {
 749 |     return this._getFlagsRemote(key, userId, companyId, additionalContext);
 750 |   }
 751 | 
 752 |   private buildUrl(path: string) {
 753 |     if (path.startsWith("/")) {
 754 |       path = path.slice(1);
 755 |     }
 756 | 
 757 |     const url = new URL(path, this._config.apiBaseUrl);
 758 |     return url.toString();
 759 |   }
 760 | 
 761 |   /**
 762 |    * Sends a POST request to the specified path.
 763 |    *
 764 |    * @param path - The path to send the request to.
 765 |    * @param body - The body of the request.
 766 |    *
 767 |    * @returns A boolean indicating if the request was successful.
 768 |    *
 769 |    * @throws An error if the path or body is invalid.
 770 |    **/
 771 |   private async post<TBody>(path: string, body: TBody) {
 772 |     ok(typeof path === "string" && path.length > 0, "path must be a string");
 773 |     ok(typeof body === "object", "body must be an object");
 774 | 
 775 |     const url = this.buildUrl(path);
 776 |     try {
 777 |       const response = await this.httpClient.post<TBody, { success: boolean }>(
 778 |         url,
 779 |         this._config.headers,
 780 |         body,
 781 |       );
 782 | 
 783 |       this.logger.debug(`post request to "${url}"`, response);
 784 | 
 785 |       if (!response.ok || !isObject(response.body) || !response.body.success) {
 786 |         this.logger.warn(
 787 |           `invalid response received from server for "${url}"`,
 788 |           JSON.stringify(response),
 789 |         );
 790 |         return false;
 791 |       }
 792 |       return true;
 793 |     } catch (error) {
 794 |       this.logger.error(`post request to "${url}" failed with error`, error);
 795 |       return false;
 796 |     }
 797 |   }
 798 | 
 799 |   /**
 800 |    * Sends a GET request to the specified path.
 801 |    *
 802 |    * @param path - The path to send the request to.
 803 |    * @param retries - Optional number of retries for the request.
 804 |    *
 805 |    * @returns The response from the server.
 806 |    * @throws An error if the path is invalid.
 807 |    **/
 808 |   private async get<TResponse>(path: string, retries: number = 3) {
 809 |     ok(typeof path === "string" && path.length > 0, "path must be a string");
 810 | 
 811 |     try {
 812 |       const url = this.buildUrl(path);
 813 |       return await withRetry(
 814 |         async () => {
 815 |           const response = await this.httpClient.get<
 816 |             TResponse & { success: boolean }
 817 |           >(url, this._config.headers, this._config.fetchTimeoutMs);
 818 | 
 819 |           this.logger.debug(`get request to "${url}"`, response);
 820 | 
 821 |           if (
 822 |             !response.ok ||
 823 |             !isObject(response.body) ||
 824 |             !response.body.success
 825 |           ) {
 826 |             throw new Error(
 827 |               `invalid response received from server for "${url}": ${JSON.stringify(response.body)}`,
 828 |             );
 829 |           }
 830 |           const { success: _, ...result } = response.body;
 831 |           return result as TResponse;
 832 |         },
 833 |         () => {
 834 |           this.logger.warn("failed to fetch flags, will retry");
 835 |         },
 836 |         retries,
 837 |         1000,
 838 |         10000,
 839 |       );
 840 |     } catch (error) {
 841 |       this.logger.error(
 842 |         `get request to "${path}" failed with error after ${retries} retries`,
 843 |         error,
 844 |       );
 845 |       return undefined;
 846 |     }
 847 |   }
 848 | 
 849 |   /**
 850 |    * Sends a batch of events to the Reflag API.
 851 |    *
 852 |    * @param events - The events to send.
 853 |    *
 854 |    * @throws An error if the send fails.
 855 |    **/
 856 |   private async sendBulkEvents(events: BulkEvent[]) {
 857 |     ok(
 858 |       Array.isArray(events) && events.length > 0,
 859 |       "events must be a non-empty array",
 860 |     );
 861 | 
 862 |     const sent = await this.post("bulk", events);
 863 |     if (!sent) {
 864 |       throw new Error("Failed to send bulk events");
 865 |     }
 866 |   }
 867 | 
 868 |   /**
 869 |    * Sends a flag event to the Reflag API.
 870 |    *
 871 |    * Flag events are used to track the evaluation of flag targeting rules.
 872 |    * "check" events are sent when a flag's `isEnabled` property is checked.
 873 |    * "evaluate" events are sent when a flag's targeting rules are matched against
 874 |    * the current context.
 875 |    *
 876 |    * @param event - The event to send.
 877 |    * @param event.action - The action to send.
 878 |    * @param event.key - The key of the flag to send.
 879 |    * @param event.targetingVersion - The targeting version of the flag to send.
 880 |    * @param event.evalResult - The evaluation result of the flag to send.
 881 |    * @param event.evalContext - The evaluation context of the flag to send.
 882 |    * @param event.evalRuleResults - The evaluation rule results of the flag to send.
 883 |    * @param event.evalMissingFields - The evaluation missing fields of the flag to send.
 884 |    *
 885 |    * @throws An error if the event is invalid.
 886 |    *
 887 |    * @remarks
 888 |    * This method is rate-limited to prevent too many events from being sent.
 889 |    **/
 890 |   private async sendFlagEvent(event: FlagEvent) {
 891 |     ok(typeof event === "object", "event must be an object");
 892 |     ok(
 893 |       typeof event.action === "string" &&
 894 |         (event.action === "check" || event.action === "check-config"),
 895 |       "event must have an action",
 896 |     );
 897 |     ok(
 898 |       typeof event.key === "string" && event.key.length > 0,
 899 |       "event must have a flag key",
 900 |     );
 901 |     ok(
 902 |       typeof event.targetingVersion === "number" ||
 903 |         event.targetingVersion === undefined,
 904 |       "event must have a targeting version",
 905 |     );
 906 |     ok(
 907 |       typeof event.evalResult === "boolean" || isObject(event.evalResult),
 908 |       "event must have an evaluation result",
 909 |     );
 910 |     ok(
 911 |       event.evalContext === undefined || typeof event.evalContext === "object",
 912 |       "event context must be an object",
 913 |     );
 914 |     ok(
 915 |       event.evalRuleResults === undefined ||
 916 |         Array.isArray(event.evalRuleResults),
 917 |       "event rule results must be an array",
 918 |     );
 919 |     ok(
 920 |       event.evalMissingFields === undefined ||
 921 |         Array.isArray(event.evalMissingFields),
 922 |       "event missing fields must be an array",
 923 |     );
 924 | 
 925 |     const contextKey = new URLSearchParams(
 926 |       flattenJSON(event.evalContext || {}),
 927 |     ).toString();
 928 | 
 929 |     if (this._config.offline) {
 930 |       return;
 931 |     }
 932 | 
 933 |     if (
 934 |       !this.rateLimiter.isAllowed(
 935 |         hashObject({
 936 |           action: event.action,
 937 |           key: event.key,
 938 |           targetingVersion: event.targetingVersion,
 939 |           evalResult: event.evalResult,
 940 |           contextKey,
 941 |         }),
 942 |       )
 943 |     ) {
 944 |       return;
 945 |     }
 946 | 
 947 |     await this.batchBuffer.add({
 948 |       type: "feature-flag-event",
 949 |       action: event.action,
 950 |       key: event.key,
 951 |       targetingVersion: event.targetingVersion,
 952 |       evalContext: event.evalContext,
 953 |       evalResult: event.evalResult,
 954 |       evalRuleResults: event.evalRuleResults,
 955 |       evalMissingFields: event.evalMissingFields,
 956 |     });
 957 |   }
 958 | 
 959 |   /**
 960 |    * Updates the context in Reflag (if needed).
 961 |    * This method should be used before requesting flags or binding a client.
 962 |    *
 963 |    * @param options - The options for the context.
 964 |    * @param options.enableTracking - Whether to enable tracking for the context.
 965 |    * @param options.meta - The meta context associated with the context.
 966 |    * @param options.user - The user context.
 967 |    * @param options.company - The company context.
 968 |    * @param options.other - The other context.
 969 |    */
 970 |   private async syncContext(options: ContextWithTracking) {
 971 |     if (!options.enableTracking) {
 972 |       this.logger.debug("tracking disabled, not updating user/company");
 973 | 
 974 |       return;
 975 |     }
 976 | 
 977 |     const promises: Promise<void>[] = [];
 978 |     if (typeof options.company?.id !== "undefined") {
 979 |       const { id: _, ...attributes } = options.company;
 980 |       promises.push(
 981 |         this.updateCompany(options.company.id, {
 982 |           attributes,
 983 |           meta: options.meta,
 984 |         }),
 985 |       );
 986 |     }
 987 | 
 988 |     if (typeof options.user?.id !== "undefined") {
 989 |       const { id: _, ...attributes } = options.user;
 990 |       promises.push(
 991 |         this.updateUser(options.user.id, {
 992 |           attributes,
 993 |           meta: options.meta,
 994 |         }),
 995 |       );
 996 |     }
 997 | 
 998 |     if (promises.length > 0) {
 999 |       await Promise.all(promises);
1000 |     }
1001 |   }
1002 | 
1003 |   /**
1004 |    * Warns if a flag has targeting rules that require context fields that are missing.
1005 |    *
1006 |    * @param context - The context.
1007 |    * @param flag - The flag to check.
1008 |    */
1009 |   private _warnMissingFlagContextFields(
1010 |     context: Context,
1011 |     flag: {
1012 |       key: string;
1013 |       missingContextFields?: string[];
1014 |       config?: {
1015 |         key: string;
1016 |         missingContextFields?: string[];
1017 |       };
1018 |     },
1019 |   ) {
1020 |     const report: Record<string, string[]> = {};
1021 |     const { config, ...flagData } = flag;
1022 | 
1023 |     if (
1024 |       flagData.missingContextFields?.length &&
1025 |       this.rateLimiter.isAllowed(
1026 |         hashObject({
1027 |           flagKey: flagData.key,
1028 |           missingContextFields: flagData.missingContextFields,
1029 |           context,
1030 |         }),
1031 |       )
1032 |     ) {
1033 |       report[flagData.key] = flagData.missingContextFields;
1034 |     }
1035 | 
1036 |     if (
1037 |       config?.missingContextFields?.length &&
1038 |       this.rateLimiter.isAllowed(
1039 |         hashObject({
1040 |           flagKey: flagData.key,
1041 |           configKey: config.key,
1042 |           missingContextFields: config.missingContextFields,
1043 |           context,
1044 |         }),
1045 |       )
1046 |     ) {
1047 |       report[`${flagData.key}.config`] = config.missingContextFields;
1048 |     }
1049 | 
1050 |     if (Object.keys(report).length > 0) {
1051 |       this.logger.warn(
1052 |         `flag targeting rules might not be correctly evaluated due to missing context fields.`,
1053 |         report,
1054 |       );
1055 |     }
1056 |   }
1057 | 
1058 |   private _getFlags(options: ContextWithTracking): RawFlags;
1059 |   private _getFlags<TKey extends TypedFlagKey>(
1060 |     options: ContextWithTracking,
1061 |     key: TKey,
1062 |   ): RawFlag | undefined;
1063 |   private _getFlags<TKey extends TypedFlagKey>(
1064 |     options: ContextWithTracking,
1065 |     key?: TKey,
1066 |   ): RawFlags | RawFlag | undefined {
1067 |     checkContextWithTracking(options);
1068 | 
1069 |     if (!this.initializationFinished) {
1070 |       this.logger.error("getFlag(s): ReflagClient is not initialized yet.");
1071 |     }
1072 | 
1073 |     void this.syncContext(options);
1074 |     let flagDefinitions: CachedFlagDefinition[] = [];
1075 | 
1076 |     if (!this._config.offline) {
1077 |       const flagDefs = this.flagsCache.get();
1078 |       if (!flagDefs) {
1079 |         this.logger.warn(
1080 |           "no flag definitions available, using fallback flags.",
1081 |         );
1082 |         const fallbackFlags = this._config.fallbackFlags || {};
1083 |         if (key) {
1084 |           return fallbackFlags[key];
1085 |         }
1086 |         return fallbackFlags;
1087 |       }
1088 |       flagDefinitions = flagDefs;
1089 |     }
1090 | 
1091 |     const { enableTracking: _, meta: __, ...context } = options;
1092 | 
1093 |     const evaluated = flagDefinitions
1094 |       .filter(({ key: flagKey }) => (key ? key === flagKey : true))
1095 |       .map((flag) => ({
1096 |         flagKey: flag.key,
1097 |         targetingVersion: flag.targeting.version,
1098 |         configVersion: flag.config?.version,
1099 |         enabledResult: flag.enabledEvaluator(context, flag.key),
1100 |         configResult:
1101 |           flag.configEvaluator?.(context, flag.key) ??
1102 |           ({
1103 |             flagKey: flag.key,
1104 |             context,
1105 |             value: undefined,
1106 |             ruleEvaluationResults: [],
1107 |             missingContextFields: [],
1108 |           } satisfies EvaluationResult<any>),
1109 |       }));
1110 | 
1111 |     let evaluatedFlags = evaluated.reduce((acc, res) => {
1112 |       acc[res.flagKey as TypedFlagKey] = {
1113 |         key: res.flagKey,
1114 |         isEnabled: res.enabledResult.value ?? false,
1115 |         ruleEvaluationResults: res.enabledResult.ruleEvaluationResults,
1116 |         missingContextFields: res.enabledResult.missingContextFields,
1117 |         targetingVersion: res.targetingVersion,
1118 |         config: {
1119 |           key: res.configResult?.value?.key,
1120 |           payload: res.configResult?.value?.payload,
1121 |           targetingVersion: res.configVersion,
1122 |           ruleEvaluationResults: res.configResult?.ruleEvaluationResults,
1123 |           missingContextFields: res.configResult?.missingContextFields,
1124 |         },
1125 |       };
1126 |       return acc;
1127 |     }, {} as RawFlags);
1128 | 
1129 |     const overrides = Object.entries(this._config.flagOverrides(context))
1130 |       .filter(([flagKey]) => (key ? key === flagKey : true))
1131 |       .map(([flagKey, override]) => [
1132 |         flagKey,
1133 |         isObject(override)
1134 |           ? {
1135 |               key: flagKey,
1136 |               isEnabled: override.isEnabled,
1137 |               config: override.config,
1138 |             }
1139 |           : {
1140 |               key: flagKey,
1141 |               isEnabled: !!override,
1142 |               config: undefined,
1143 |             },
1144 |       ]);
1145 | 
1146 |     if (overrides.length > 0) {
1147 |       // merge overrides into evaluated flags
1148 |       evaluatedFlags = {
1149 |         ...evaluatedFlags,
1150 |         ...Object.fromEntries(overrides),
1151 |       };
1152 |     }
1153 | 
1154 |     if (key) {
1155 |       return evaluatedFlags[key];
1156 |     }
1157 | 
1158 |     return evaluatedFlags;
1159 |   }
1160 | 
1161 |   private _wrapRawFlag<TKey extends TypedFlagKey>(
1162 |     {
1163 |       enableTracking,
1164 |       enableChecks = false,
1165 |       ...context
1166 |     }: { enableTracking: boolean; enableChecks?: boolean } & Context,
1167 |     { config, ...flag }: PartialBy<RawFlag, "isEnabled">,
1168 |   ): TypedFlags[TKey] {
1169 |     // eslint-disable-next-line @typescript-eslint/no-this-alias
1170 |     const client = this;
1171 | 
1172 |     const simplifiedConfig = config
1173 |       ? { key: config.key, payload: config.payload }
1174 |       : { key: undefined, payload: undefined };
1175 | 
1176 |     return {
1177 |       get isEnabled() {
1178 |         if (enableTracking && enableChecks) {
1179 |           client._warnMissingFlagContextFields(context, flag);
1180 | 
1181 |           void client
1182 |             .sendFlagEvent({
1183 |               action: "check",
1184 |               key: flag.key,
1185 |               targetingVersion: flag.targetingVersion,
1186 |               evalResult: flag.isEnabled ?? false,
1187 |               evalContext: context,
1188 |               evalRuleResults: flag.ruleEvaluationResults,
1189 |               evalMissingFields: flag.missingContextFields,
1190 |             })
1191 |             .catch((err) => {
1192 |               client.logger?.error(
1193 |                 `failed to send check event for "${flag.key}": ${err}`,
1194 |                 err,
1195 |               );
1196 |             });
1197 |         }
1198 |         return flag.isEnabled ?? false;
1199 |       },
1200 |       get config() {
1201 |         if (enableTracking && enableChecks) {
1202 |           client._warnMissingFlagContextFields(context, flag);
1203 | 
1204 |           void client
1205 |             .sendFlagEvent({
1206 |               action: "check-config",
1207 |               key: flag.key,
1208 |               targetingVersion: config?.targetingVersion,
1209 |               evalResult: simplifiedConfig,
1210 |               evalContext: context,
1211 |               evalRuleResults: config?.ruleEvaluationResults,
1212 |               evalMissingFields: config?.missingContextFields,
1213 |             })
1214 |             .catch((err) => {
1215 |               client.logger?.error(
1216 |                 `failed to send check event for "${flag.key}": ${err}`,
1217 |                 err,
1218 |               );
1219 |             });
1220 |         }
1221 |         return simplifiedConfig as TypedFlags[TKey]["config"];
1222 |       },
1223 |       key: flag.key,
1224 |       track: async () => {
1225 |         if (typeof context.user?.id === "undefined") {
1226 |           this.logger.warn("no user set, cannot track event");
1227 |           return;
1228 |         }
1229 | 
1230 |         if (enableTracking) {
1231 |           await this.track(context.user.id, flag.key, {
1232 |             companyId: context.company?.id,
1233 |           });
1234 |         } else {
1235 |           this.logger.debug("tracking disabled, not tracking event");
1236 |         }
1237 |       },
1238 |     };
1239 |   }
1240 | 
1241 |   private async _getFlagsRemote(
1242 |     key: undefined,
1243 |     userId?: IdType,
1244 |     companyId?: IdType,
1245 |     additionalContext?: Context,
1246 |   ): Promise<TypedFlags>;
1247 |   private async _getFlagsRemote<TKey extends TypedFlagKey>(
1248 |     key: TKey,
1249 |     userId?: IdType,
1250 |     companyId?: IdType,
1251 |     additionalContext?: Context,
1252 |   ): Promise<TypedFlags[TKey]>;
1253 |   private async _getFlagsRemote<TKey extends TypedFlagKey>(
1254 |     key?: string,
1255 |     userId?: IdType,
1256 |     companyId?: IdType,
1257 |     additionalContext?: Context,
1258 |   ): Promise<TypedFlags | TypedFlags[TKey]> {
1259 |     const context = additionalContext || {};
1260 |     if (userId) {
1261 |       context.user = { id: userId };
1262 |     }
1263 |     if (companyId) {
1264 |       context.company = { id: companyId };
1265 |     }
1266 | 
1267 |     const contextWithTracking = {
1268 |       ...context,
1269 |       enableTracking: true,
1270 |     };
1271 | 
1272 |     checkContextWithTracking(contextWithTracking);
1273 | 
1274 |     const params = new URLSearchParams(
1275 |       Object.keys(context).length ? flattenJSON({ context }) : undefined,
1276 |     );
1277 | 
1278 |     if (key) {
1279 |       params.append("key", key);
1280 |     }
1281 | 
1282 |     const res = await this.get<EvaluatedFlagsAPIResponse>(
1283 |       `features/evaluated?${params}`,
1284 |     );
1285 | 
1286 |     if (key) {
1287 |       const flag = res?.features[key];
1288 |       if (!flag) {
1289 |         this.logger.error(`flag ${key} not found`);
1290 |       }
1291 |       return this._wrapRawFlag(contextWithTracking, { key, ...flag });
1292 |     } else {
1293 |       return res?.features
1294 |         ? Object.fromEntries(
1295 |             Object.entries(res?.features).map(([flagKey, flag]) => [
1296 |               flagKey,
1297 |               this._wrapRawFlag(contextWithTracking, flag),
1298 |             ]),
1299 |           )
1300 |         : {};
1301 |     }
1302 |   }
1303 | }
1304 | 
1305 | /**
1306 |  * A client bound with a specific user, company, and other context.
1307 |  */
1308 | export class BoundReflagClient {
1309 |   private readonly _client: ReflagClient;
1310 |   private readonly _options: ContextWithTracking;
1311 | 
1312 |   /**
1313 |    * (Internal) Creates a new BoundReflagClient. Use `bindClient` to create a new client bound with a specific context.
1314 |    *
1315 |    * @param client - The `ReflagClient` to use.
1316 |    * @param options - The options for the client.
1317 |    * @param options.enableTracking - Whether to enable tracking for the client.
1318 |    *
1319 |    * @internal
1320 |    */
1321 |   constructor(
1322 |     client: ReflagClient,
1323 |     { enableTracking = true, ...context }: ContextWithTracking,
1324 |   ) {
1325 |     this._client = client;
1326 | 
1327 |     this._options = { enableTracking, ...context };
1328 | 
1329 |     checkContextWithTracking(this._options);
1330 |     void this._client["syncContext"](this._options);
1331 |   }
1332 | 
1333 |   /**
1334 |    * Gets the "other" context associated with the client.
1335 |    *
1336 |    * @returns The "other" context or `undefined` if it is not set.
1337 |    **/
1338 |   public get otherContext() {
1339 |     return this._options.other;
1340 |   }
1341 | 
1342 |   /**
1343 |    * Gets the user associated with the client.
1344 |    *
1345 |    * @returns The user or `undefined` if it is not set.
1346 |    **/
1347 |   public get user() {
1348 |     return this._options.user;
1349 |   }
1350 | 
1351 |   /**
1352 |    * Gets the company associated with the client.
1353 |    *
1354 |    * @returns The company or `undefined` if it is not set.
1355 |    **/
1356 |   public get company() {
1357 |     return this._options.company;
1358 |   }
1359 | 
1360 |   /**
1361 |    * Get flags for the user/company/other context bound to this client.
1362 |    * Meant for use in serialization of flags for transferring to the client-side/browser.
1363 |    *
1364 |    * @returns Flags for the given user/company and whether each one is enabled or not
1365 |    */
1366 |   public getFlags(): TypedFlags {
1367 |     return this._client.getFlags(this._options);
1368 |   }
1369 | 
1370 |   /**
1371 |    * Get raw flags for the user/company/other context bound to this client without wrapping them in getters.
1372 |    * This method returns raw flag data suitable for bootstrapping client-side applications.
1373 |    *
1374 |    * @returns Raw flags for the given user/company and whether each one is enabled or not
1375 |    */
1376 |   public getFlagsForBootstrap(): BootstrappedFlags {
1377 |     return this._client.getFlagsForBootstrap({ ...this._options });
1378 |   }
1379 | 
1380 |   /**
1381 |    * Get a specific flag for the user/company/other context bound to this client.
1382 |    * Using the `isEnabled` property sends a `check` event to Reflag.
1383 |    *
1384 |    * @param key - The key of the flag to get.
1385 |    *
1386 |    * @returns Flags for the given user/company and whether each one is enabled or not
1387 |    */
1388 |   public getFlag<TKey extends TypedFlagKey>(key: TKey): TypedFlags[TKey] {
1389 |     return this._client.getFlag(this._options, key);
1390 |   }
1391 | 
1392 |   /**
1393 |    * Get remotely evaluated flag for the user/company/other context bound to this client.
1394 |    *
1395 |    * @returns Flags for the given user/company and whether each one is enabled or not
1396 |    */
1397 |   public async getFlagsRemote() {
1398 |     const { enableTracking: _, meta: __, ...context } = this._options;
1399 |     return await this._client.getFlagsRemote(undefined, undefined, context);
1400 |   }
1401 | 
1402 |   /**
1403 |    * Get remotely evaluated flag for the user/company/other context bound to this client.
1404 |    *
1405 |    * @param key - The key of the flag to get.
1406 |    *
1407 |    * @returns Flag for the given user/company and key and whether it's enabled or not
1408 |    */
1409 |   public async getFlagRemote(key: string) {
1410 |     const { enableTracking: _, meta: __, ...context } = this._options;
1411 |     return await this._client.getFlagRemote(key, undefined, undefined, context);
1412 |   }
1413 | 
1414 |   /**
1415 |    * Track an event in Reflag.
1416 |    *
1417 |    * @param event - The event to track.
1418 |    * @param options - The options for the event.
1419 |    * @param options.attributes - The attributes of the event (optional).
1420 |    * @param options.meta - The meta context associated with tracking (optional).
1421 |    * @param options.companyId - Optional company ID for the event (optional).
1422 |    *
1423 |    * @throws An error if the event is invalid or the options are invalid.
1424 |    */
1425 |   public async track(
1426 |     event: string,
1427 |     options?: TrackOptions & { companyId?: string },
1428 |   ) {
1429 |     ok(options === undefined || isObject(options), "options must be an object");
1430 |     checkMeta(options?.meta);
1431 | 
1432 |     const userId = this._options.user?.id;
1433 | 
1434 |     if (!userId) {
1435 |       this._client.logger?.warn("no user set, cannot track event");
1436 |       return;
1437 |     }
1438 | 
1439 |     if (!this._options.enableTracking) {
1440 |       this._client.logger?.debug(
1441 |         "tracking disabled for this bound client, not tracking event",
1442 |       );
1443 |       return;
1444 |     }
1445 | 
1446 |     await this._client.track(
1447 |       userId,
1448 |       event,
1449 |       options?.companyId
1450 |         ? options
1451 |         : { ...options, companyId: this._options.company?.id },
1452 |     );
1453 |   }
1454 | 
1455 |   /**
1456 |    * Create a new client bound with the additional context.
1457 |    * Note: This performs a shallow merge for user/company/other individually.
1458 |    *
1459 |    * @param context - The context to bind the client to.
1460 |    * @param context.user - The user to bind the client to.
1461 |    * @param context.company - The company to bind the client to.
1462 |    * @param context.other - The other context to bind the client to.
1463 |    * @param context.enableTracking - Whether to enable tracking for the client.
1464 |    * @param context.meta - The meta context to bind the client to.
1465 |    *
1466 |    * @returns new client bound with the additional context
1467 |    */
1468 |   public bindClient({
1469 |     user,
1470 |     company,
1471 |     other,
1472 |     enableTracking,
1473 |     meta,
1474 |   }: ContextWithTracking) {
1475 |     // merge new context into existing
1476 |     const boundConfig = {
1477 |       ...this._options,
1478 |       user: user ? { ...this._options.user, ...user } : undefined,
1479 |       company: company ? { ...this._options.company, ...company } : undefined,
1480 |       other: { ...this._options.other, ...other },
1481 |       enableTracking: enableTracking ?? this._options.enableTracking,
1482 |       meta: meta ?? this._options.meta,
1483 |     };
1484 | 
1485 |     return new BoundReflagClient(this._client, boundConfig);
1486 |   }
1487 | 
1488 |   /**
1489 |    * Flushes the batch buffer.
1490 |    */
1491 |   public async flush() {
1492 |     await this._client.flush();
1493 |   }
1494 | }
1495 | 
1496 | function checkMeta(
1497 |   meta?: TrackingMeta,
1498 | ): asserts meta is TrackingMeta | undefined {
1499 |   ok(
1500 |     typeof meta === "undefined" || isObject(meta),
1501 |     "meta must be an object if given",
1502 |   );
1503 |   ok(
1504 |     meta?.active === undefined || typeof meta?.active === "boolean",
1505 |     "meta.active must be a boolean if given",
1506 |   );
1507 | }
1508 | 
1509 | function checkContext(context: Context): asserts context is Context {
1510 |   ok(isObject(context), "context must be an object");
1511 |   ok(
1512 |     typeof context.user === "undefined" || isObject(context.user),
1513 |     "user must be an object if given",
1514 |   );
1515 |   if (typeof context.user?.id !== "undefined") {
1516 |     idOk(context.user.id, "user.id");
1517 |   }
1518 | 
1519 |   ok(
1520 |     typeof context.company === "undefined" || isObject(context.company),
1521 |     "company must be an object if given",
1522 |   );
1523 |   if (typeof context.company?.id !== "undefined") {
1524 |     idOk(context.company.id, "company.id");
1525 |   }
1526 | 
1527 |   ok(
1528 |     context.other === undefined || isObject(context.other),
1529 |     "other must be an object if given",
1530 |   );
1531 | }
1532 | 
1533 | function checkContextWithTracking(
1534 |   context: ContextWithTracking,
1535 | ): asserts context is ContextWithTracking & { enableTracking: boolean } {
1536 |   checkContext(context);
1537 | 
1538 |   ok(
1539 |     typeof context.enableTracking === "boolean",
1540 |     "enableTracking must be a boolean",
1541 |   );
1542 | 
1543 |   checkMeta(context.meta);
1544 | }
1545 | 
```

--------------------------------------------------------------------------------
/packages/node-sdk/test/client.test.ts:
--------------------------------------------------------------------------------

```typescript
   1 | import flushPromises from "flush-promises";
   2 | import {
   3 |   afterEach,
   4 |   beforeAll,
   5 |   beforeEach,
   6 |   describe,
   7 |   expect,
   8 |   it,
   9 |   test,
  10 |   vi,
  11 | } from "vitest";
  12 | 
  13 | import { BoundReflagClient, ReflagClient } from "../src";
  14 | import {
  15 |   API_BASE_URL,
  16 |   API_TIMEOUT_MS,
  17 |   BATCH_INTERVAL_MS,
  18 |   BATCH_MAX_SIZE,
  19 |   FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
  20 |   FLAGS_REFETCH_MS,
  21 |   SDK_VERSION,
  22 |   SDK_VERSION_HEADER_NAME,
  23 | } from "../src/config";
  24 | import fetchClient from "../src/fetch-http-client";
  25 | import { subscribe as triggerOnExit } from "../src/flusher";
  26 | import { newRateLimiter } from "../src/rate-limiter";
  27 | import { ClientOptions, Context, FlagsAPIResponse } from "../src/types";
  28 | 
  29 | const BULK_ENDPOINT = "https://api.example.com/bulk";
  30 | 
  31 | vi.mock("../src/rate-limiter", async (importOriginal) => {
  32 |   const original = (await importOriginal()) as any;
  33 | 
  34 |   return {
  35 |     ...original,
  36 |     newRateLimiter: vi.fn(original.newRateLimiter),
  37 |   };
  38 | });
  39 | 
  40 | vi.mock("../src/flusher", () => ({
  41 |   subscribe: vi.fn(),
  42 | }));
  43 | 
  44 | // Mock NextJS headers module for dynamic import
  45 | vi.mock("next/headers", () => ({
  46 |   cookies: vi.fn(),
  47 | }));
  48 | 
  49 | const user = {
  50 |   id: "user123",
  51 |   age: 1,
  52 |   name: "John",
  53 | };
  54 | 
  55 | const company = {
  56 |   id: "company123",
  57 |   employees: 100,
  58 |   name: "Acme Inc.",
  59 | };
  60 | 
  61 | const event = {
  62 |   event: "flag-event",
  63 |   attrs: { key: "value" },
  64 | };
  65 | 
  66 | const otherContext = { custom: "context", key: "value" };
  67 | const logger = {
  68 |   debug: vi.fn(),
  69 |   info: vi.fn(),
  70 |   warn: vi.fn(),
  71 |   error: vi.fn(),
  72 | };
  73 | const httpClient = { post: vi.fn(), get: vi.fn() };
  74 | 
  75 | const fallbackFlags = ["key"];
  76 | 
  77 | const validOptions: ClientOptions = {
  78 |   secretKey: "validSecretKeyWithMoreThan22Chars",
  79 |   apiBaseUrl: "https://api.example.com/",
  80 |   logger,
  81 |   httpClient,
  82 |   fallbackFlags,
  83 |   flagsFetchRetries: 2,
  84 |   batchOptions: {
  85 |     maxSize: 99,
  86 |     intervalMs: 10001,
  87 |     flushOnExit: false,
  88 |   },
  89 |   offline: false,
  90 | };
  91 | 
  92 | const expectedHeaders = {
  93 |   [SDK_VERSION_HEADER_NAME]: SDK_VERSION,
  94 |   "Content-Type": "application/json",
  95 |   Authorization: `Bearer ${validOptions.secretKey}`,
  96 | };
  97 | 
  98 | const flagDefinitions: FlagsAPIResponse = {
  99 |   features: [
 100 |     {
 101 |       key: "flag1",
 102 |       description: "Flag 1",
 103 |       targeting: {
 104 |         version: 1,
 105 |         rules: [
 106 |           {
 107 |             filter: {
 108 |               type: "context" as const,
 109 |               field: "company.id",
 110 |               operator: "IS",
 111 |               values: ["company123"],
 112 |             },
 113 |           },
 114 |         ],
 115 |       },
 116 |       config: {
 117 |         version: 1,
 118 |         variants: [
 119 |           {
 120 |             filter: {
 121 |               type: "context",
 122 |               field: "company.id",
 123 |               operator: "IS",
 124 |               values: ["company123"],
 125 |             },
 126 |             key: "config-1",
 127 |             payload: { something: "else" },
 128 |           },
 129 |         ],
 130 |       },
 131 |     },
 132 |     {
 133 |       key: "flag2",
 134 |       description: "Flag 2",
 135 |       targeting: {
 136 |         version: 2,
 137 |         rules: [
 138 |           {
 139 |             filter: {
 140 |               type: "group" as const,
 141 |               operator: "and",
 142 |               filters: [
 143 |                 {
 144 |                   type: "context" as const,
 145 |                   field: "company.id",
 146 |                   operator: "IS",
 147 |                   values: ["company123"],
 148 |                 },
 149 |                 {
 150 |                   partialRolloutThreshold: 0.5,
 151 |                   partialRolloutAttribute: "attributeKey",
 152 |                   type: "rolloutPercentage" as const,
 153 |                   key: "flag2",
 154 |                 },
 155 |               ],
 156 |             },
 157 |           },
 158 |         ],
 159 |       },
 160 |     },
 161 |   ],
 162 | };
 163 | 
 164 | describe("ReflagClient", () => {
 165 |   afterEach(() => {
 166 |     vi.clearAllMocks();
 167 |   });
 168 | 
 169 |   describe("constructor", () => {
 170 |     it("should initialize with no options", async () => {
 171 |       const secretKeyEnv = process.env.REFLAG_SECRET_KEY;
 172 |       process.env.REFLAG_SECRET_KEY = "validSecretKeyWithMoreThan22Chars";
 173 |       try {
 174 |         const reflagInstance = new ReflagClient();
 175 |         expect(reflagInstance).toBeInstanceOf(ReflagClient);
 176 |       } finally {
 177 |         process.env.REFLAG_SECRET_KEY = secretKeyEnv;
 178 |       }
 179 |     });
 180 | 
 181 |     it("should accept fallback flags as an array", async () => {
 182 |       const reflagInstance = new ReflagClient({
 183 |         secretKey: "validSecretKeyWithMoreThan22Chars",
 184 |         fallbackFlags: ["flag1", "flag2"],
 185 |       });
 186 | 
 187 |       expect(reflagInstance["_config"].fallbackFlags).toEqual({
 188 |         flag1: {
 189 |           isEnabled: true,
 190 |           key: "flag1",
 191 |         },
 192 |         flag2: {
 193 |           isEnabled: true,
 194 |           key: "flag2",
 195 |         },
 196 |       });
 197 |     });
 198 | 
 199 |     it("should accept fallback flags as an object", async () => {
 200 |       const reflagInstance = new ReflagClient({
 201 |         secretKey: "validSecretKeyWithMoreThan22Chars",
 202 |         fallbackFlags: {
 203 |           flag1: true,
 204 |           flag2: {
 205 |             isEnabled: true,
 206 |             config: {
 207 |               key: "config1",
 208 |               payload: { value: true },
 209 |             },
 210 |           },
 211 |         },
 212 |       });
 213 | 
 214 |       expect(reflagInstance["_config"].fallbackFlags).toStrictEqual({
 215 |         flag1: {
 216 |           key: "flag1",
 217 |           config: undefined,
 218 |           isEnabled: true,
 219 |         },
 220 |         flag2: {
 221 |           key: "flag2",
 222 |           isEnabled: true,
 223 |           config: {
 224 |             key: "config1",
 225 |             payload: { value: true },
 226 |           },
 227 |         },
 228 |       });
 229 |     });
 230 | 
 231 |     it("should create a client instance with valid options", () => {
 232 |       const client = new ReflagClient(validOptions);
 233 | 
 234 |       expect(client).toBeInstanceOf(ReflagClient);
 235 |       expect(client["_config"].apiBaseUrl).toBe("https://api.example.com/");
 236 |       expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS);
 237 |       expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5);
 238 |       expect(client.logger).toBeDefined();
 239 |       expect(client.httpClient).toBe(validOptions.httpClient);
 240 |       expect(client["_config"].headers).toEqual(expectedHeaders);
 241 |       expect(client["batchBuffer"]).toMatchObject({
 242 |         maxSize: 99,
 243 |         intervalMs: 10001,
 244 |       });
 245 | 
 246 |       expect(client["_config"].fallbackFlags).toEqual({
 247 |         key: {
 248 |           key: "key",
 249 |           isEnabled: true,
 250 |         },
 251 |       });
 252 |       expect(client["_config"].flagsFetchRetries).toBe(2);
 253 |     });
 254 | 
 255 |     it("should route messages to the supplied logger", () => {
 256 |       const client = new ReflagClient(validOptions);
 257 | 
 258 |       const actualLogger = client.logger!;
 259 |       actualLogger.debug("debug message");
 260 |       actualLogger.info("info message");
 261 |       actualLogger.warn("warn message");
 262 |       actualLogger.error("error message");
 263 | 
 264 |       expect(logger.debug).toHaveBeenCalledWith(
 265 |         expect.stringMatching("debug message"),
 266 |       );
 267 |       expect(logger.info).toHaveBeenCalledWith(
 268 |         expect.stringMatching("info message"),
 269 |       );
 270 |       expect(logger.warn).toHaveBeenCalledWith(
 271 |         expect.stringMatching("warn message"),
 272 |       );
 273 |       expect(logger.error).toHaveBeenCalledWith(
 274 |         expect.stringMatching("error message"),
 275 |       );
 276 |     });
 277 | 
 278 |     it("should create a client instance with default values for optional fields", () => {
 279 |       const client = new ReflagClient({
 280 |         secretKey: "validSecretKeyWithMoreThan22Chars",
 281 |       });
 282 | 
 283 |       expect(client["_config"].apiBaseUrl).toBe(API_BASE_URL);
 284 |       expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS);
 285 |       expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5);
 286 |       expect(client.httpClient).toBe(fetchClient);
 287 |       expect(client["_config"].headers).toEqual(expectedHeaders);
 288 |       expect(client["_config"].fallbackFlags).toBeUndefined();
 289 |       expect(client["batchBuffer"]).toMatchObject({
 290 |         maxSize: BATCH_MAX_SIZE,
 291 |         intervalMs: BATCH_INTERVAL_MS,
 292 |       });
 293 |     });
 294 | 
 295 |     it("should throw an error if options are invalid", () => {
 296 |       let invalidOptions: any = null;
 297 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 298 |         "options must be an object",
 299 |       );
 300 | 
 301 |       invalidOptions = { ...validOptions, secretKey: "shortKey" };
 302 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 303 |         "invalid secretKey specified",
 304 |       );
 305 | 
 306 |       invalidOptions = { ...validOptions, host: 123 };
 307 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 308 |         "host must be a string",
 309 |       );
 310 | 
 311 |       invalidOptions = {
 312 |         ...validOptions,
 313 |         logger: "invalidLogger" as any,
 314 |       };
 315 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 316 |         "logger must be an object",
 317 |       );
 318 | 
 319 |       invalidOptions = {
 320 |         ...validOptions,
 321 |         httpClient: "invalidHttpClient" as any,
 322 |       };
 323 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 324 |         "httpClient must be an object",
 325 |       );
 326 | 
 327 |       invalidOptions = {
 328 |         ...validOptions,
 329 |         batchOptions: "invalid" as any,
 330 |       };
 331 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 332 |         "batchOptions must be an object",
 333 |       );
 334 | 
 335 |       invalidOptions = {
 336 |         ...validOptions,
 337 |         fallbackFlags: "invalid" as any,
 338 |       };
 339 |       expect(() => new ReflagClient(invalidOptions)).toThrow(
 340 |         "fallbackFlags must be an array or object",
 341 |       );
 342 |     });
 343 | 
 344 |     it("should create a new flag events rate-limiter", () => {
 345 |       const client = new ReflagClient(validOptions);
 346 | 
 347 |       expect(client["rateLimiter"]).toBeDefined();
 348 |       expect(newRateLimiter).toHaveBeenCalledWith(
 349 |         FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
 350 |       );
 351 |     });
 352 | 
 353 |     it("should not register an exit flush handler if `batchOptions.flushOnExit` is false", () => {
 354 |       new ReflagClient({
 355 |         ...validOptions,
 356 |         batchOptions: { ...validOptions.batchOptions, flushOnExit: false },
 357 |       });
 358 | 
 359 |       expect(triggerOnExit).not.toHaveBeenCalled();
 360 |     });
 361 | 
 362 |     it("should not register an exit flush handler if `offline` is true", () => {
 363 |       new ReflagClient({
 364 |         ...validOptions,
 365 |         offline: true,
 366 |       });
 367 | 
 368 |       expect(triggerOnExit).not.toHaveBeenCalled();
 369 |     });
 370 | 
 371 |     it.each([undefined, true])(
 372 |       "should register an exit flush handler if `batchOptions.flushOnExit` is `%s`",
 373 |       (flushOnExit) => {
 374 |         new ReflagClient({
 375 |           ...validOptions,
 376 |           batchOptions: { ...validOptions.batchOptions, flushOnExit },
 377 |         });
 378 | 
 379 |         expect(triggerOnExit).toHaveBeenCalledWith(expect.any(Function));
 380 |       },
 381 |     );
 382 | 
 383 |     it.each([
 384 |       ["https://api.example.com", "https://api.example.com/bulk"],
 385 |       ["https://api.example.com/", "https://api.example.com/bulk"],
 386 |       ["https://api.example.com/path", "https://api.example.com/path/bulk"],
 387 |       ["https://api.example.com/path/", "https://api.example.com/path/bulk"],
 388 |     ])(
 389 |       "should build the URLs correctly %s -> %s",
 390 |       async (apiBaseUrl, expectedUrl) => {
 391 |         const client = new ReflagClient({
 392 |           ...validOptions,
 393 |           apiBaseUrl,
 394 |         });
 395 | 
 396 |         await client.updateUser("user_id");
 397 |         await client.flush();
 398 | 
 399 |         expect(httpClient.post).toHaveBeenCalledWith(
 400 |           expectedUrl,
 401 |           expect.any(Object),
 402 |           expect.any(Object),
 403 |         );
 404 |       },
 405 |     );
 406 |   });
 407 | 
 408 |   describe("bindClient", () => {
 409 |     const client = new ReflagClient(validOptions);
 410 |     const context = {
 411 |       user,
 412 |       company,
 413 |       other: otherContext,
 414 |     };
 415 | 
 416 |     beforeEach(() => {
 417 |       vi.mocked(httpClient.post).mockResolvedValue({ body: { success: true } });
 418 |       client["rateLimiter"].clearStale(true);
 419 |     });
 420 | 
 421 |     it("should return a new client instance with the `user`, `company` and `other` set", async () => {
 422 |       const newClient = client.bindClient(context);
 423 |       await client.flush();
 424 | 
 425 |       expect(newClient.user).toEqual(user);
 426 |       expect(newClient.company).toEqual(company);
 427 |       expect(newClient.otherContext).toEqual(otherContext);
 428 | 
 429 |       expect(newClient).toBeInstanceOf(BoundReflagClient);
 430 |       expect(newClient).not.toBe(client); // Ensure a new instance is returned
 431 |       expect(newClient["_options"]).toEqual({
 432 |         enableTracking: true,
 433 |         ...context,
 434 |       });
 435 |     });
 436 | 
 437 |     it("should update user in Reflag when called", async () => {
 438 |       client.bindClient({ user: context.user });
 439 |       await client.flush();
 440 | 
 441 |       const { id: _, ...attributes } = context.user;
 442 | 
 443 |       expect(httpClient.post).toHaveBeenCalledWith(
 444 |         BULK_ENDPOINT,
 445 |         expectedHeaders,
 446 |         [
 447 |           {
 448 |             type: "user",
 449 |             userId: user.id,
 450 |             attributes: attributes,
 451 |             context: undefined,
 452 |           },
 453 |         ],
 454 |       );
 455 | 
 456 |       expect(httpClient.post).toHaveBeenCalledOnce();
 457 |     });
 458 | 
 459 |     it("should update company in Reflag when called", async () => {
 460 |       client.bindClient({ company: context.company, meta: { active: true } });
 461 |       await client.flush();
 462 | 
 463 |       const { id: _, ...attributes } = context.company;
 464 | 
 465 |       expect(httpClient.post).toHaveBeenCalledWith(
 466 |         BULK_ENDPOINT,
 467 |         expectedHeaders,
 468 |         [
 469 |           {
 470 |             type: "company",
 471 |             companyId: company.id,
 472 |             attributes: attributes,
 473 |             context: {
 474 |               active: true,
 475 |             },
 476 |           },
 477 |         ],
 478 |       );
 479 | 
 480 |       expect(httpClient.post).toHaveBeenCalledOnce();
 481 |     });
 482 | 
 483 |     it("should not update `company` or `user` in Reflag when `enableTracking` is `false`", async () => {
 484 |       client.bindClient({
 485 |         user: context.user,
 486 |         company: context.company,
 487 |         enableTracking: false,
 488 |       });
 489 | 
 490 |       await client.flush();
 491 | 
 492 |       expect(httpClient.post).not.toHaveBeenCalled();
 493 |     });
 494 | 
 495 |     it("should throw an error if `user` is invalid", () => {
 496 |       expect(() =>
 497 |         client.bindClient({ user: "bad_attributes" as any }),
 498 |       ).toThrow("validation failed: user must be an object if given");
 499 |       expect(() => client.bindClient({ user: { id: {} as any } })).toThrow(
 500 |         "validation failed: user.id must be a string or number if given",
 501 |       );
 502 |     });
 503 | 
 504 |     it("should throw an error if `company` is invalid", () => {
 505 |       expect(() =>
 506 |         client.bindClient({ company: "bad_attributes" as any }),
 507 |       ).toThrow("validation failed: company must be an object if given");
 508 |       expect(() => client.bindClient({ company: { id: {} as any } })).toThrow(
 509 |         "validation failed: company.id must be a string or number if given",
 510 |       );
 511 |     });
 512 | 
 513 |     it("should throw an error if `other` is invalid", () => {
 514 |       expect(() =>
 515 |         client.bindClient({ other: "bad_attributes" as any }),
 516 |       ).toThrow("validation failed: other must be an object");
 517 |     });
 518 | 
 519 |     it("should throw an error if `enableTracking` is invalid", () => {
 520 |       expect(() =>
 521 |         client.bindClient({ enableTracking: "bad_attributes" as any }),
 522 |       ).toThrow("validation failed: enableTracking must be a boolean");
 523 |     });
 524 | 
 525 |     it("should allow context without id", () => {
 526 |       const c = client.bindClient({
 527 |         user: { id: undefined, name: "userName" },
 528 |         company: { id: undefined, name: "companyName" },
 529 |       });
 530 |       expect(c.user?.id).toBeUndefined();
 531 |       expect(c.company?.id).toBeUndefined();
 532 |     });
 533 |   });
 534 | 
 535 |   describe("updateUser", () => {
 536 |     const client = new ReflagClient(validOptions);
 537 | 
 538 |     beforeEach(() => {
 539 |       client["rateLimiter"].clearStale(true);
 540 |     });
 541 | 
 542 |     // try with both string and number IDs
 543 |     test.each([
 544 |       { id: "user123", age: 1, name: "John" },
 545 |       { id: 42, age: 1, name: "John" },
 546 |     ])("should successfully update the user", async (testUser) => {
 547 |       const response = { status: 200, body: { success: true } };
 548 |       httpClient.post.mockResolvedValue(response);
 549 | 
 550 |       await client.updateUser(testUser.id, {
 551 |         attributes: { age: 2, brave: false },
 552 |         meta: {
 553 |           active: true,
 554 |         },
 555 |       });
 556 | 
 557 |       await client.flush();
 558 | 
 559 |       expect(httpClient.post).toHaveBeenCalledWith(
 560 |         BULK_ENDPOINT,
 561 |         expectedHeaders,
 562 |         [
 563 |           {
 564 |             type: "user",
 565 |             userId: testUser.id,
 566 |             attributes: { age: 2, brave: false },
 567 |             context: { active: true },
 568 |           },
 569 |         ],
 570 |       );
 571 | 
 572 |       expect(logger.debug).toHaveBeenCalledWith(
 573 |         expect.stringMatching("post request to "),
 574 |         response,
 575 |       );
 576 |     });
 577 | 
 578 |     it("should log an error if the post request throws", async () => {
 579 |       const error = new Error("Network error");
 580 |       httpClient.post.mockRejectedValue(error);
 581 | 
 582 |       await client.updateUser(user.id);
 583 |       await client.flush();
 584 | 
 585 |       expect(logger.error).toHaveBeenCalledWith(
 586 |         expect.stringMatching("post request to .* failed with error"),
 587 |         error,
 588 |       );
 589 |     });
 590 | 
 591 |     it("should log if API call returns false", async () => {
 592 |       const response = { status: 200, body: { success: false } };
 593 | 
 594 |       httpClient.post.mockResolvedValue(response);
 595 | 
 596 |       await client.updateUser(user.id);
 597 |       await client.flush();
 598 | 
 599 |       expect(logger.warn).toHaveBeenCalledWith(
 600 |         expect.stringMatching("invalid response received from server for"),
 601 |         JSON.stringify(response),
 602 |       );
 603 |     });
 604 | 
 605 |     it("should throw an error if opts are not valid or the user is not set", async () => {
 606 |       await expect(
 607 |         client.updateUser(user.id, "bad_opts" as any),
 608 |       ).rejects.toThrow("validation failed: options must be an object");
 609 | 
 610 |       await expect(
 611 |         client.updateUser(user.id, { attributes: "bad_attributes" as any }),
 612 |       ).rejects.toThrow("attributes must be an object");
 613 | 
 614 |       await expect(
 615 |         client.updateUser(user.id, { meta: "bad_meta" as any }),
 616 |       ).rejects.toThrow("meta must be an object");
 617 |     });
 618 |   });
 619 | 
 620 |   describe("updateCompany", () => {
 621 |     const client = new ReflagClient(validOptions);
 622 | 
 623 |     beforeEach(() => {
 624 |       client["rateLimiter"].clearStale(true);
 625 |     });
 626 | 
 627 |     test.each([
 628 |       {
 629 |         id: "company123",
 630 |         employees: 100,
 631 |         name: "Acme Inc.",
 632 |         userId: "user123",
 633 |       },
 634 |       { id: 42, employees: 100, name: "Acme Inc.", userId: 42 },
 635 |     ])(`should successfully update the company`, async (testCompany) => {
 636 |       const response = { status: 200, body: { success: true } };
 637 |       httpClient.post.mockResolvedValue(response);
 638 | 
 639 |       await client.updateCompany(testCompany.id, {
 640 |         attributes: { employees: 200, bankrupt: false },
 641 |         meta: { active: true },
 642 |         userId: testCompany.userId,
 643 |       });
 644 | 
 645 |       await client.flush();
 646 | 
 647 |       expect(httpClient.post).toHaveBeenCalledWith(
 648 |         BULK_ENDPOINT,
 649 |         expectedHeaders,
 650 |         [
 651 |           {
 652 |             type: "company",
 653 |             companyId: testCompany.id,
 654 |             attributes: { employees: 200, bankrupt: false },
 655 |             context: { active: true },
 656 |             userId: testCompany.userId,
 657 |           },
 658 |         ],
 659 |       );
 660 | 
 661 |       expect(logger.debug).toHaveBeenCalledWith(
 662 |         expect.stringMatching("post request to .*"),
 663 |         response,
 664 |       );
 665 |     });
 666 | 
 667 |     it("should log an error if the post request throws", async () => {
 668 |       const error = new Error("Network error");
 669 |       httpClient.post.mockRejectedValue(error);
 670 | 
 671 |       await client.updateCompany(company.id, {});
 672 |       await client.flush();
 673 | 
 674 |       expect(logger.error).toHaveBeenCalledWith(
 675 |         expect.stringMatching("post request to .* failed with error"),
 676 |         error,
 677 |       );
 678 |     });
 679 | 
 680 |     it("should log an error if API responds with success: false", async () => {
 681 |       const response = {
 682 |         status: 200,
 683 |         body: { success: false },
 684 |       };
 685 | 
 686 |       httpClient.post.mockResolvedValue(response);
 687 | 
 688 |       await client.updateCompany(company.id, {});
 689 |       await client.flush();
 690 | 
 691 |       expect(logger.warn).toHaveBeenCalledWith(
 692 |         expect.stringMatching("invalid response received from server for"),
 693 |         JSON.stringify(response),
 694 |       );
 695 |     });
 696 | 
 697 |     it("should throw an error if company is not valid", async () => {
 698 |       await expect(
 699 |         client.updateCompany(company.id, "bad_opts" as any),
 700 |       ).rejects.toThrow("validation failed: options must be an object");
 701 | 
 702 |       await expect(
 703 |         client.updateCompany(company.id, {
 704 |           attributes: "bad_attributes" as any,
 705 |         }),
 706 |       ).rejects.toThrow("attributes must be an object");
 707 | 
 708 |       await expect(
 709 |         client.updateCompany(company.id, { meta: "bad_meta" as any }),
 710 |       ).rejects.toThrow("meta must be an object");
 711 |     });
 712 |   });
 713 | 
 714 |   describe("track", () => {
 715 |     const client = new ReflagClient(validOptions);
 716 | 
 717 |     beforeEach(() => {
 718 |       client["rateLimiter"].clearStale(true);
 719 |     });
 720 | 
 721 |     test.each([
 722 |       { id: "user123", age: 1, name: "John" },
 723 |       { id: 42, age: 1, name: "John" },
 724 |     ])("should successfully track the flag usage", async (testUser) => {
 725 |       const response = {
 726 |         status: 200,
 727 |         body: { success: true },
 728 |       };
 729 |       httpClient.post.mockResolvedValue(response);
 730 | 
 731 |       await client.bindClient({ user: testUser, company }).track(event.event, {
 732 |         attributes: event.attrs,
 733 |         meta: { active: true },
 734 |       });
 735 | 
 736 |       await client.flush();
 737 |       expect(httpClient.post).toHaveBeenCalledWith(
 738 |         BULK_ENDPOINT,
 739 |         expectedHeaders,
 740 |         [
 741 |           expect.objectContaining({
 742 |             type: "company",
 743 |           }),
 744 |           expect.objectContaining({
 745 |             type: "user",
 746 |           }),
 747 |           {
 748 |             attributes: {
 749 |               key: "value",
 750 |             },
 751 |             context: {
 752 |               active: true,
 753 |             },
 754 |             event: "flag-event",
 755 |             type: "event",
 756 |             userId: testUser.id,
 757 |             companyId: company.id,
 758 |           },
 759 |         ],
 760 |       );
 761 | 
 762 |       expect(logger.debug).toHaveBeenCalledWith(
 763 |         expect.stringMatching("post request to"),
 764 |         response,
 765 |       );
 766 |     });
 767 | 
 768 |     it("should successfully track the flag usage including user and company", async () => {
 769 |       httpClient.post.mockResolvedValue({
 770 |         status: 200,
 771 |         body: { success: true },
 772 |       });
 773 | 
 774 |       await client.bindClient({ user }).track(event.event, {
 775 |         companyId: "otherCompanyId",
 776 |         attributes: event.attrs,
 777 |         meta: { active: true },
 778 |       });
 779 | 
 780 |       await client.flush();
 781 |       expect(httpClient.post).toHaveBeenCalledWith(
 782 |         BULK_ENDPOINT,
 783 |         expectedHeaders,
 784 |         [
 785 |           expect.objectContaining({
 786 |             type: "user",
 787 |           }),
 788 |           {
 789 |             attributes: {
 790 |               key: "value",
 791 |             },
 792 |             context: {
 793 |               active: true,
 794 |             },
 795 |             event: "flag-event",
 796 |             companyId: "otherCompanyId",
 797 |             type: "event",
 798 |             userId: "user123",
 799 |           },
 800 |         ],
 801 |       );
 802 |     });
 803 | 
 804 |     it("should log an error if the post request fails", async () => {
 805 |       const error = new Error("Network error");
 806 |       httpClient.post.mockRejectedValue(error);
 807 | 
 808 |       await client.bindClient({ user }).track(event.event);
 809 |       await client.flush();
 810 | 
 811 |       expect(logger.error).toHaveBeenCalledWith(
 812 |         expect.stringMatching("post request to .* failed with error"),
 813 |         error,
 814 |       );
 815 |     });
 816 | 
 817 |     it("should log if the API call returns false", async () => {
 818 |       const response = {
 819 |         status: 200,
 820 |         body: { success: false },
 821 |       };
 822 | 
 823 |       httpClient.post.mockResolvedValue(response);
 824 | 
 825 |       await client.bindClient({ user }).track(event.event);
 826 |       await client.flush();
 827 | 
 828 |       expect(logger.warn).toHaveBeenCalledWith(
 829 |         expect.stringMatching("invalid response received from server for "),
 830 |         JSON.stringify(response),
 831 |       );
 832 |     });
 833 | 
 834 |     it("should log if user is not set", async () => {
 835 |       const boundClient = client.bindClient({ company });
 836 | 
 837 |       await boundClient.track("hello");
 838 | 
 839 |       expect(httpClient.post).not.toHaveBeenCalled();
 840 |       expect(logger.warn).toHaveBeenCalledWith(
 841 |         expect.stringMatching("no user set, cannot track event"),
 842 |       );
 843 |     });
 844 | 
 845 |     it("should throw an error if event is invalid", async () => {
 846 |       const boundClient = client.bindClient({ company, user });
 847 | 
 848 |       await expect(boundClient.track(undefined as any)).rejects.toThrow(
 849 |         "event must be a string",
 850 |       );
 851 |       await expect(boundClient.track(1 as any)).rejects.toThrow(
 852 |         "event must be a string",
 853 |       );
 854 | 
 855 |       await expect(
 856 |         boundClient.track(event.event, "bad_opts" as any),
 857 |       ).rejects.toThrow("validation failed: options must be an object");
 858 | 
 859 |       await expect(
 860 |         boundClient.track(event.event, {
 861 |           attributes: "bad_attributes" as any,
 862 |         }),
 863 |       ).rejects.toThrow("attributes must be an object");
 864 | 
 865 |       await expect(
 866 |         boundClient.track(event.event, { meta: "bad_meta" as any }),
 867 |       ).rejects.toThrow("meta must be an object");
 868 |     });
 869 |   });
 870 | 
 871 |   describe("user", () => {
 872 |     it("should return the undefined if user was not set", () => {
 873 |       const client = new ReflagClient(validOptions).bindClient({ company });
 874 |       expect(client.user).toBeUndefined();
 875 |     });
 876 | 
 877 |     it("should return the user if user was associated", () => {
 878 |       const client = new ReflagClient(validOptions).bindClient({ user });
 879 | 
 880 |       expect(client.user).toEqual(user);
 881 |     });
 882 |   });
 883 | 
 884 |   describe("company", () => {
 885 |     it("should return the undefined if company was not set", () => {
 886 |       const client = new ReflagClient(validOptions).bindClient({ user });
 887 |       expect(client.company).toBeUndefined();
 888 |     });
 889 | 
 890 |     it("should return the user if company was associated", () => {
 891 |       const client = new ReflagClient(validOptions).bindClient({ company });
 892 | 
 893 |       expect(client.company).toEqual(company);
 894 |     });
 895 |   });
 896 | 
 897 |   describe("otherContext", () => {
 898 |     it("should return the undefined if custom context was not set", () => {
 899 |       const client = new ReflagClient(validOptions).bindClient({ company });
 900 |       expect(client.otherContext).toBeUndefined();
 901 |     });
 902 | 
 903 |     it("should return the user if custom context was associated", () => {
 904 |       const client = new ReflagClient(validOptions).bindClient({
 905 |         other: otherContext,
 906 |       });
 907 | 
 908 |       expect(client.otherContext).toEqual(otherContext);
 909 |     });
 910 |   });
 911 | 
 912 |   describe("initialize", () => {
 913 |     it("should initialize the client", async () => {
 914 |       const client = new ReflagClient(validOptions);
 915 | 
 916 |       const get = vi
 917 |         .spyOn(client["flagsCache"], "get")
 918 |         .mockReturnValue(undefined);
 919 |       const refresh = vi
 920 |         .spyOn(client["flagsCache"], "refresh")
 921 |         .mockResolvedValue(undefined);
 922 | 
 923 |       await client.initialize();
 924 |       await client.initialize();
 925 |       await client.initialize();
 926 | 
 927 |       expect(refresh).toHaveBeenCalledTimes(1);
 928 |       expect(get).not.toHaveBeenCalled();
 929 |     });
 930 | 
 931 |     it("should call the backend to obtain flags", async () => {
 932 |       const client = new ReflagClient(validOptions);
 933 | 
 934 |       httpClient.get.mockResolvedValue({
 935 |         ok: true,
 936 |         status: 200,
 937 |       });
 938 | 
 939 |       await client.initialize();
 940 | 
 941 |       expect(httpClient.get).toHaveBeenCalledWith(
 942 |         `https://api.example.com/features`,
 943 |         expectedHeaders,
 944 |         API_TIMEOUT_MS,
 945 |       );
 946 |     });
 947 |   });
 948 | 
 949 |   describe("flush", () => {
 950 |     it("should flush all bulk data", async () => {
 951 |       const client = new ReflagClient(validOptions);
 952 | 
 953 |       await client.updateUser(user.id, { attributes: { age: 2 } });
 954 |       await client.updateUser(user.id, { attributes: { age: 3 } });
 955 |       await client.updateUser(user.id, { attributes: { name: "Jane" } });
 956 | 
 957 |       await client.flush();
 958 | 
 959 |       expect(httpClient.post).toHaveBeenCalledWith(
 960 |         BULK_ENDPOINT,
 961 |         expectedHeaders,
 962 |         [
 963 |           {
 964 |             type: "user",
 965 |             userId: user.id,
 966 |             attributes: { age: 2 },
 967 |           },
 968 |           {
 969 |             type: "user",
 970 |             userId: user.id,
 971 |             attributes: { age: 3 },
 972 |           },
 973 |           {
 974 |             type: "user",
 975 |             userId: user.id,
 976 |             attributes: { name: "Jane" },
 977 |           },
 978 |         ],
 979 |       );
 980 |     });
 981 | 
 982 |     it("should not flush all bulk data if `offline` is true", async () => {
 983 |       const client = new ReflagClient({
 984 |         ...validOptions,
 985 |         offline: true,
 986 |       });
 987 | 
 988 |       await client.updateUser(user.id, { attributes: { age: 2 } });
 989 |       await client.flush();
 990 | 
 991 |       expect(httpClient.post).not.toHaveBeenCalled();
 992 |     });
 993 |   });
 994 | 
 995 |   describe("getFlag", () => {
 996 |     let client: ReflagClient;
 997 | 
 998 |     beforeEach(async () => {
 999 |       httpClient.get.mockResolvedValue({
1000 |         ok: true,
1001 |         status: 200,
1002 |         body: {
1003 |           success: true,
1004 |           ...flagDefinitions,
1005 |         },
1006 |       });
1007 | 
1008 |       client = new ReflagClient(validOptions);
1009 | 
1010 |       httpClient.post.mockResolvedValue({
1011 |         status: 200,
1012 |         body: { success: true },
1013 |       });
1014 |     });
1015 | 
1016 |     it("returns a flag", async () => {
1017 |       await client.initialize();
1018 |       const flag = client.getFlag(
1019 |         {
1020 |           company,
1021 |           user,
1022 |           other: otherContext,
1023 |         },
1024 |         "flag1",
1025 |       );
1026 | 
1027 |       expect(flag).toStrictEqual({
1028 |         key: "flag1",
1029 |         isEnabled: true,
1030 |         config: {
1031 |           key: "config-1",
1032 |           payload: { something: "else" },
1033 |         },
1034 |         track: expect.any(Function),
1035 |       });
1036 |     });
1037 | 
1038 |     it("`track` sends all expected events when `enableTracking` is `true`", async () => {
1039 |       const context = {
1040 |         company,
1041 |         user,
1042 |         other: otherContext,
1043 |       };
1044 | 
1045 |       // test that the flag is returned
1046 |       await client.initialize();
1047 |       const flag = client.getFlag(
1048 |         {
1049 |           ...context,
1050 |           meta: {
1051 |             active: true,
1052 |           },
1053 |           enableTracking: true,
1054 |         },
1055 |         "flag1",
1056 |       );
1057 | 
1058 |       await flag.track();
1059 |       await client.flush();
1060 | 
1061 |       expect(httpClient.post).toHaveBeenCalledWith(
1062 |         BULK_ENDPOINT,
1063 |         expectedHeaders,
1064 |         [
1065 |           {
1066 |             attributes: {
1067 |               employees: 100,
1068 |               name: "Acme Inc.",
1069 |             },
1070 |             companyId: "company123",
1071 |             context: {
1072 |               active: true,
1073 |             },
1074 |             type: "company",
1075 |             userId: undefined,
1076 |           },
1077 |           {
1078 |             attributes: {
1079 |               age: 1,
1080 |               name: "John",
1081 |             },
1082 |             context: {
1083 |               active: true,
1084 |             },
1085 |             type: "user",
1086 |             userId: "user123",
1087 |           },
1088 |           {
1089 |             type: "event",
1090 |             event: "flag1",
1091 |             userId: user.id,
1092 |             companyId: company.id,
1093 |           },
1094 |         ],
1095 |       );
1096 |     });
1097 | 
1098 |     it("`isEnabled` sends `check` event", async () => {
1099 |       const context = {
1100 |         company,
1101 |         user,
1102 |         other: otherContext,
1103 |       };
1104 | 
1105 |       // test that the flag is returned
1106 |       await client.initialize();
1107 |       const flag = client.getFlag(context, "flag1");
1108 | 
1109 |       // trigger `check` event
1110 |       expect(flag.isEnabled).toBe(true);
1111 | 
1112 |       await client.flush();
1113 |       const checkEvents = httpClient.post.mock.calls
1114 |         .flatMap((call) => call[2])
1115 |         .filter((e) => e.action === "check");
1116 | 
1117 |       expect(checkEvents).toStrictEqual([
1118 |         {
1119 |           type: "feature-flag-event",
1120 |           action: "check",
1121 |           key: "flag1",
1122 |           targetingVersion: 1,
1123 |           evalResult: true,
1124 |           evalContext: context,
1125 |           evalRuleResults: [true],
1126 |           evalMissingFields: [],
1127 |         },
1128 |       ]);
1129 |     });
1130 | 
1131 |     it("`isEnabled` warns about missing context fields", async () => {
1132 |       const context = {
1133 |         company,
1134 |         user,
1135 |         other: otherContext,
1136 |       };
1137 | 
1138 |       // test that the flag is returned
1139 |       await client.initialize();
1140 |       const flag = client.getFlag(context, "flag2");
1141 | 
1142 |       // trigger the warning
1143 |       expect(flag.isEnabled).toBe(false);
1144 | 
1145 |       expect(logger.warn).toHaveBeenCalledWith(
1146 |         "flag targeting rules might not be correctly evaluated due to missing context fields.",
1147 |         {
1148 |           flag2: ["attributeKey"],
1149 |         },
1150 |       );
1151 |     });
1152 | 
1153 |     it("`isEnabled` should not warn about missing context fields if not needed", async () => {
1154 |       const context = {
1155 |         company,
1156 |         user,
1157 |         other: otherContext,
1158 |       };
1159 | 
1160 |       // test that the flag is returned
1161 |       await client.initialize();
1162 |       const flag = client.getFlag(context, "flag1");
1163 | 
1164 |       // should not trigger the warning
1165 |       expect(flag.isEnabled).toBe(true);
1166 | 
1167 |       expect(logger.warn).not.toHaveBeenCalled();
1168 |     });
1169 | 
1170 |     it("`config` sends `check` event", async () => {
1171 |       const context = {
1172 |         company,
1173 |         user,
1174 |         other: otherContext,
1175 |       };
1176 | 
1177 |       // test that the flag is returned
1178 |       await client.initialize();
1179 |       const flag = client.getFlag(context, "flag1");
1180 | 
1181 |       // trigger `check` event
1182 |       expect(flag.config).toBeDefined();
1183 | 
1184 |       await client.flush();
1185 | 
1186 |       const checkEvents = httpClient.post.mock.calls
1187 |         .flatMap((call) => call[2])
1188 |         .filter((e) => e.action === "check-config");
1189 | 
1190 |       expect(checkEvents).toStrictEqual([
1191 |         {
1192 |           type: "feature-flag-event",
1193 |           action: "check-config",
1194 |           key: "flag1",
1195 |           evalResult: {
1196 |             key: "config-1",
1197 |             payload: {
1198 |               something: "else",
1199 |             },
1200 |           },
1201 |           targetingVersion: 1,
1202 |           evalContext: context,
1203 |           evalRuleResults: [true],
1204 |           evalMissingFields: [],
1205 |         },
1206 |       ]);
1207 |     });
1208 | 
1209 |     it("sends events for unknown flags", async () => {
1210 |       const context: Context = {
1211 |         company,
1212 |         user,
1213 |         other: otherContext,
1214 |       };
1215 | 
1216 |       // test that the flag is returned
1217 |       await client.initialize();
1218 |       const flag = client.getFlag(context, "unknown-flag");
1219 | 
1220 |       // trigger `check` event
1221 |       expect(flag.isEnabled).toBe(false);
1222 |       await flag.track();
1223 |       await client.flush();
1224 | 
1225 |       const checkEvents = httpClient.post.mock.calls
1226 |         .flatMap((call) => call[2])
1227 |         .filter((e) => e.type === "feature-flag-event");
1228 | 
1229 |       expect(checkEvents).toStrictEqual([
1230 |         {
1231 |           type: "feature-flag-event",
1232 |           action: "check",
1233 |           key: "unknown-flag",
1234 |           targetingVersion: undefined,
1235 |           evalContext: context,
1236 |           evalResult: false,
1237 |           evalRuleResults: undefined,
1238 |           evalMissingFields: undefined,
1239 |         },
1240 |       ]);
1241 |     });
1242 | 
1243 |     it("sends company/user and track events", async () => {
1244 |       const context: Context = {
1245 |         company,
1246 |         user,
1247 |         other: otherContext,
1248 |       };
1249 | 
1250 |       // test that the flag is returned
1251 |       await client.initialize();
1252 |       const flag = client.getFlag(context, "flag1");
1253 | 
1254 |       // trigger `check` event
1255 |       await flag.track();
1256 |       await client.flush();
1257 | 
1258 |       const checkEvents = httpClient.post.mock.calls
1259 |         .flatMap((call) => call[2])
1260 |         .filter(
1261 |           (e) =>
1262 |             e.type === "company" || e.type === "user" || e.type === "event",
1263 |         );
1264 | 
1265 |       expect(checkEvents).toStrictEqual([
1266 |         {
1267 |           type: "company",
1268 |           companyId: "company123",
1269 |           attributes: {
1270 |             employees: 100,
1271 |             name: "Acme Inc.",
1272 |           },
1273 |           userId: undefined, // this is a bug, will fix in separate PR
1274 |           context: undefined,
1275 |         },
1276 |         {
1277 |           type: "user",
1278 |           userId: "user123",
1279 |           attributes: {
1280 |             age: 1,
1281 |             name: "John",
1282 |           },
1283 |           context: undefined,
1284 |         },
1285 |         {
1286 |           type: "event",
1287 |           event: "flag1",
1288 |           userId: user.id,
1289 |           companyId: company.id,
1290 |           context: undefined,
1291 |           attributes: undefined,
1292 |         },
1293 |       ]);
1294 |     });
1295 |   });
1296 | 
1297 |   describe("getFlags", () => {
1298 |     let client: ReflagClient;
1299 | 
1300 |     beforeEach(async () => {
1301 |       httpClient.get.mockResolvedValue({
1302 |         ok: true,
1303 |         status: 200,
1304 |         body: {
1305 |           success: true,
1306 |           ...flagDefinitions,
1307 |         },
1308 |       });
1309 | 
1310 |       client = new ReflagClient(validOptions);
1311 | 
1312 |       client["rateLimiter"].clearStale(true);
1313 | 
1314 |       httpClient.post.mockResolvedValue({
1315 |         ok: true,
1316 |         status: 200,
1317 |         body: { success: true },
1318 |       });
1319 |     });
1320 | 
1321 |     it("should return evaluated flags", async () => {
1322 |       httpClient.post.mockClear(); // not interested in updates
1323 | 
1324 |       await client.initialize();
1325 |       const result = client.getFlags({
1326 |         company,
1327 |         user,
1328 |         other: otherContext,
1329 |       });
1330 | 
1331 |       expect(result).toStrictEqual({
1332 |         flag1: {
1333 |           key: "flag1",
1334 |           isEnabled: true,
1335 |           config: {
1336 |             key: "config-1",
1337 |             payload: {
1338 |               something: "else",
1339 |             },
1340 |           },
1341 |           track: expect.any(Function),
1342 |         },
1343 |         flag2: {
1344 |           key: "flag2",
1345 |           isEnabled: false,
1346 |           config: { key: undefined, payload: undefined },
1347 |           track: expect.any(Function),
1348 |         },
1349 |       });
1350 | 
1351 |       await client.flush();
1352 | 
1353 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1354 |     });
1355 | 
1356 |     it("should properly define the rate limiter key", async () => {
1357 |       const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed");
1358 | 
1359 |       await client.initialize();
1360 |       client.getFlags({ user, company, other: otherContext });
1361 | 
1362 |       expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0=");
1363 |     });
1364 | 
1365 |     it("should return evaluated flags when only user is defined", async () => {
1366 |       httpClient.post.mockClear(); // not interested in updates
1367 | 
1368 |       await client.initialize();
1369 |       const flags = client.getFlags({ user });
1370 | 
1371 |       expect(flags).toStrictEqual({
1372 |         flag1: {
1373 |           isEnabled: false,
1374 |           key: "flag1",
1375 |           config: {
1376 |             key: undefined,
1377 |             payload: undefined,
1378 |           },
1379 |           track: expect.any(Function),
1380 |         },
1381 |         flag2: {
1382 |           key: "flag2",
1383 |           isEnabled: false,
1384 |           config: { key: undefined, payload: undefined },
1385 |           track: expect.any(Function),
1386 |         },
1387 |       });
1388 | 
1389 |       await client.flush();
1390 | 
1391 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1392 |     });
1393 | 
1394 |     it("should return evaluated flags when only company is defined", async () => {
1395 |       await client.initialize();
1396 |       const flags = client.getFlags({ company });
1397 | 
1398 |       // expect will trigger the `isEnabled` getter and send a `check` event
1399 |       expect(flags).toStrictEqual({
1400 |         flag1: {
1401 |           isEnabled: true,
1402 |           key: "flag1",
1403 |           config: {
1404 |             key: "config-1",
1405 |             payload: {
1406 |               something: "else",
1407 |             },
1408 |           },
1409 |           track: expect.any(Function),
1410 |         },
1411 |         flag2: {
1412 |           key: "flag2",
1413 |           isEnabled: false,
1414 |           config: { key: undefined, payload: undefined },
1415 |           track: expect.any(Function),
1416 |         },
1417 |       });
1418 | 
1419 |       await client.flush();
1420 | 
1421 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1422 |     });
1423 | 
1424 |     it("should not send flag events when `enableTracking` is `false`", async () => {
1425 |       await client.initialize();
1426 |       const flags = client.getFlags({ company, enableTracking: false });
1427 | 
1428 |       // expect will trigger the `isEnabled` getter and send a `check` event
1429 |       expect(flags).toStrictEqual({
1430 |         flag1: {
1431 |           isEnabled: true,
1432 |           key: "flag1",
1433 |           config: {
1434 |             key: "config-1",
1435 |             payload: {
1436 |               something: "else",
1437 |             },
1438 |           },
1439 |           track: expect.any(Function),
1440 |         },
1441 |         flag2: {
1442 |           key: "flag2",
1443 |           isEnabled: false,
1444 |           config: { key: undefined, payload: undefined },
1445 |           track: expect.any(Function),
1446 |         },
1447 |       });
1448 | 
1449 |       await client.flush();
1450 | 
1451 |       expect(httpClient.post).not.toHaveBeenCalled();
1452 |     });
1453 | 
1454 |     it("should return evaluated flags when only other context is defined", async () => {
1455 |       await client.initialize();
1456 |       const flags = client.getFlags({ other: otherContext });
1457 | 
1458 |       expect(flags).toStrictEqual({
1459 |         flag1: {
1460 |           isEnabled: false,
1461 |           key: "flag1",
1462 |           config: {
1463 |             key: undefined,
1464 |             payload: undefined,
1465 |           },
1466 |           track: expect.any(Function),
1467 |         },
1468 |         flag2: {
1469 |           key: "flag2",
1470 |           isEnabled: false,
1471 |           config: { key: undefined, payload: undefined },
1472 |           track: expect.any(Function),
1473 |         },
1474 |       });
1475 | 
1476 |       await client.flush();
1477 |     });
1478 | 
1479 |     it("should send `track` with user and company if provided", async () => {
1480 |       await client.initialize();
1481 |       const flags = client.getFlags({ company, user });
1482 |       await client.flush();
1483 | 
1484 |       await flags.flag1.track();
1485 |       await client.flush();
1486 | 
1487 |       expect(httpClient.post).toHaveBeenCalledTimes(2);
1488 |       // second call includes the track event
1489 |       const events = httpClient.post.mock.calls[1][2].filter(
1490 |         (e: any) => e.type === "event",
1491 |       );
1492 | 
1493 |       expect(events).toStrictEqual([
1494 |         {
1495 |           event: "flag1",
1496 |           type: "event",
1497 |           userId: "user123",
1498 |           companyId: "company123",
1499 |           attributes: undefined,
1500 |           context: undefined,
1501 |         },
1502 |       ]);
1503 |     });
1504 | 
1505 |     it("should send `track` with user if provided", async () => {
1506 |       await client.initialize();
1507 |       const flags = client.getFlags({ user });
1508 | 
1509 |       await client.flush();
1510 |       await flags.flag1.track();
1511 |       await client.flush();
1512 | 
1513 |       expect(httpClient.post).toHaveBeenCalledTimes(2);
1514 | 
1515 |       const emptyEvents = httpClient.post.mock.calls[0][2].filter(
1516 |         (e: any) => e.type === "event",
1517 |       );
1518 | 
1519 |       expect(emptyEvents).toStrictEqual([]);
1520 | 
1521 |       // second call includes the track event
1522 |       const events = httpClient.post.mock.calls[1][2].filter(
1523 |         (e: any) => e.type === "event",
1524 |       );
1525 | 
1526 |       expect(events).toStrictEqual([
1527 |         {
1528 |           event: "flag1",
1529 |           type: "event",
1530 |           userId: "user123",
1531 |           companyId: undefined,
1532 |           attributes: undefined,
1533 |           context: undefined,
1534 |         },
1535 |       ]);
1536 |     });
1537 | 
1538 |     it("should not send `track` with only company if no user is provided", async () => {
1539 |       // we do not accept track events without a userId
1540 |       await client.initialize();
1541 |       const flag = client.getFlags({ company });
1542 | 
1543 |       await flag.flag1.track();
1544 |       await client.flush();
1545 | 
1546 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1547 |       const events = httpClient.post.mock.calls[0][2].filter(
1548 |         (e: any) => e.type === "event",
1549 |       );
1550 | 
1551 |       expect(events).toStrictEqual([]);
1552 |     });
1553 | 
1554 |     it("`isEnabled` does not send `check` event", async () => {
1555 |       const context = {
1556 |         company,
1557 |         user,
1558 |         other: otherContext,
1559 |       };
1560 | 
1561 |       // test that the flag is returned
1562 |       await client.initialize();
1563 |       const flag = client.getFlags(context);
1564 | 
1565 |       // trigger `check` event
1566 |       expect(flag.flag1.isEnabled).toBe(true);
1567 | 
1568 |       await client.flush();
1569 |       const checkEvents = httpClient.post.mock.calls
1570 |         .flatMap((call) => call[2])
1571 |         .filter((e) => e.action === "check");
1572 | 
1573 |       expect(checkEvents).toStrictEqual([]);
1574 |     });
1575 | 
1576 |     it("`config` does not send `check` event", async () => {
1577 |       const context = {
1578 |         company,
1579 |         user,
1580 |         other: otherContext,
1581 |       };
1582 | 
1583 |       // test that the flag is returned
1584 |       await client.initialize();
1585 |       const flag = client.getFlags(context);
1586 | 
1587 |       // attempt to trigger `check` event
1588 |       expect(flag.flag1.config).toBeDefined();
1589 | 
1590 |       await client.flush();
1591 | 
1592 |       const checkEvents = httpClient.post.mock.calls
1593 |         .flatMap((call) => call[2])
1594 |         .filter((e) => e.action === "check-config");
1595 | 
1596 |       expect(checkEvents).toStrictEqual([]);
1597 |     });
1598 | 
1599 |     it("sends company/user events", async () => {
1600 |       const context: Context = {
1601 |         company,
1602 |         user,
1603 |         other: otherContext,
1604 |       };
1605 | 
1606 |       // test that the flag is returned
1607 |       await client.initialize();
1608 |       client.getFlags(context);
1609 | 
1610 |       // trigger `check` event
1611 |       await client.flush();
1612 | 
1613 |       const checkEvents = httpClient.post.mock.calls
1614 |         .flatMap((call) => call[2])
1615 |         .filter(
1616 |           (e) =>
1617 |             e.type === "company" || e.type === "user" || e.type === "event",
1618 |         );
1619 | 
1620 |       expect(checkEvents).toStrictEqual([
1621 |         {
1622 |           type: "company",
1623 |           companyId: "company123",
1624 |           attributes: {
1625 |             employees: 100,
1626 |             name: "Acme Inc.",
1627 |           },
1628 |           userId: undefined, // this is a bug, will fix in separate PR
1629 |           context: undefined,
1630 |         },
1631 |         {
1632 |           type: "user",
1633 |           userId: "user123",
1634 |           attributes: {
1635 |             age: 1,
1636 |             name: "John",
1637 |           },
1638 |           context: undefined,
1639 |         },
1640 |       ]);
1641 |     });
1642 | 
1643 |     it("should use fallback flags when `getFlagDefinitions` returns `undefined`", async () => {
1644 |       httpClient.get.mockResolvedValue({
1645 |         success: false,
1646 |       });
1647 | 
1648 |       await client.initialize();
1649 |       const result = client.getFlag(
1650 |         { user: { id: "user123" }, enableTracking: true },
1651 |         "key",
1652 |       );
1653 | 
1654 |       expect(result).toStrictEqual({
1655 |         key: "key",
1656 |         isEnabled: true,
1657 |         config: { key: undefined, payload: undefined },
1658 |         track: expect.any(Function),
1659 |       });
1660 | 
1661 |       expect(logger.warn).toHaveBeenCalledWith(
1662 |         expect.stringMatching(
1663 |           "no flag definitions available, using fallback flags",
1664 |         ),
1665 |       );
1666 | 
1667 |       await client.flush();
1668 | 
1669 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1670 |       expect(httpClient.post).toHaveBeenCalledWith(
1671 |         BULK_ENDPOINT,
1672 |         expectedHeaders,
1673 |         [
1674 |           expect.objectContaining({ type: "user" }),
1675 |           expect.objectContaining({
1676 |             type: "feature-flag-event",
1677 |             action: "check-config",
1678 |             key: "key",
1679 |           }),
1680 |           expect.objectContaining({
1681 |             type: "feature-flag-event",
1682 |             action: "check",
1683 |             key: "key",
1684 |             evalResult: true,
1685 |           }),
1686 |         ],
1687 |       );
1688 |     });
1689 | 
1690 |     it("should not fail if sendFlagEvent fails to send check event", async () => {
1691 |       httpClient.post.mockResolvedValue({
1692 |         status: 200,
1693 |         body: { success: true },
1694 |       });
1695 | 
1696 |       await client.initialize();
1697 |       httpClient.post.mockRejectedValue(new Error("Network error"));
1698 |       const context = { user, company, other: otherContext };
1699 | 
1700 |       const result = client.getFlags(context);
1701 | 
1702 |       // Trigger a flag check
1703 |       expect(result.flag1).toStrictEqual({
1704 |         key: "flag1",
1705 |         isEnabled: true,
1706 |         track: expect.any(Function),
1707 |         config: {
1708 |           key: "config-1",
1709 |           payload: {
1710 |             something: "else",
1711 |           },
1712 |         },
1713 |       });
1714 | 
1715 |       await client.flush();
1716 | 
1717 |       expect(logger.error).toHaveBeenCalledWith(
1718 |         expect.stringMatching("post request .* failed with error"),
1719 |         expect.any(Error),
1720 |       );
1721 |     });
1722 | 
1723 |     it("should use flag overrides", async () => {
1724 |       await client.initialize();
1725 |       const context = { user, company, other: otherContext };
1726 | 
1727 |       const pristineResults = client.getFlags(context);
1728 |       expect(pristineResults).toStrictEqual({
1729 |         flag1: {
1730 |           key: "flag1",
1731 |           isEnabled: true,
1732 |           config: {
1733 |             key: "config-1",
1734 |             payload: {
1735 |               something: "else",
1736 |             },
1737 |           },
1738 |           track: expect.any(Function),
1739 |         },
1740 |         flag2: {
1741 |           key: "flag2",
1742 |           isEnabled: false,
1743 |           config: { key: undefined, payload: undefined },
1744 |           track: expect.any(Function),
1745 |         },
1746 |       });
1747 | 
1748 |       client.flagOverrides = {
1749 |         flag1: false,
1750 |       };
1751 |       const flags = client.getFlags(context);
1752 | 
1753 |       expect(flags).toStrictEqual({
1754 |         flag1: {
1755 |           key: "flag1",
1756 |           isEnabled: false,
1757 |           config: { key: undefined, payload: undefined },
1758 |           track: expect.any(Function),
1759 |         },
1760 |         flag2: {
1761 |           key: "flag2",
1762 |           isEnabled: false,
1763 |           config: { key: undefined, payload: undefined },
1764 |           track: expect.any(Function),
1765 |         },
1766 |       });
1767 | 
1768 |       client.clearFlagOverrides();
1769 |       const flags2 = client.getFlags(context);
1770 | 
1771 |       expect(flags2).toStrictEqual({
1772 |         ...pristineResults,
1773 |         flag1: {
1774 |           ...pristineResults.flag1,
1775 |           track: expect.any(Function),
1776 |         },
1777 |         flag2: {
1778 |           ...pristineResults.flag2,
1779 |           track: expect.any(Function),
1780 |         },
1781 |       });
1782 |     });
1783 | 
1784 |     it("should use flag overrides from function", async () => {
1785 |       await client.initialize();
1786 |       const context = { user, company, other: otherContext };
1787 | 
1788 |       const pristineResults = client.getFlags(context);
1789 |       expect(pristineResults).toStrictEqual({
1790 |         flag1: {
1791 |           key: "flag1",
1792 |           isEnabled: true,
1793 |           config: {
1794 |             key: "config-1",
1795 |             payload: {
1796 |               something: "else",
1797 |             },
1798 |           },
1799 |           track: expect.any(Function),
1800 |         },
1801 |         flag2: {
1802 |           key: "flag2",
1803 |           isEnabled: false,
1804 |           config: { key: undefined, payload: undefined },
1805 |           track: expect.any(Function),
1806 |         },
1807 |       });
1808 | 
1809 |       client.flagOverrides = (_context: Context) => {
1810 |         expect(context).toStrictEqual(context);
1811 |         return {
1812 |           flag1: { isEnabled: false },
1813 |           flag2: true,
1814 |           flag3: {
1815 |             isEnabled: true,
1816 |             config: {
1817 |               key: "config-1",
1818 |               payload: { something: "else" },
1819 |             },
1820 |           },
1821 |         };
1822 |       };
1823 |       const flags = client.getFlags(context);
1824 | 
1825 |       expect(flags).toStrictEqual({
1826 |         flag1: {
1827 |           key: "flag1",
1828 |           isEnabled: false,
1829 |           config: { key: undefined, payload: undefined },
1830 |           track: expect.any(Function),
1831 |         },
1832 |         flag2: {
1833 |           key: "flag2",
1834 |           isEnabled: true,
1835 |           config: { key: undefined, payload: undefined },
1836 |           track: expect.any(Function),
1837 |         },
1838 |         flag3: {
1839 |           key: "flag3",
1840 |           isEnabled: true,
1841 |           config: {
1842 |             key: "config-1",
1843 |             payload: { something: "else" },
1844 |           },
1845 |           track: expect.any(Function),
1846 |         },
1847 |       });
1848 |     });
1849 |   });
1850 | 
1851 |   describe("getFlagsForBootstrap", () => {
1852 |     let client: ReflagClient;
1853 | 
1854 |     beforeEach(async () => {
1855 |       httpClient.get.mockResolvedValue({
1856 |         ok: true,
1857 |         status: 200,
1858 |         body: {
1859 |           success: true,
1860 |           ...flagDefinitions,
1861 |         },
1862 |       });
1863 | 
1864 |       client = new ReflagClient(validOptions);
1865 | 
1866 |       client["rateLimiter"].clearStale(true);
1867 | 
1868 |       httpClient.post.mockResolvedValue({
1869 |         ok: true,
1870 |         status: 200,
1871 |         body: { success: true },
1872 |       });
1873 |     });
1874 | 
1875 |     it("should return raw flags without wrapper functions", async () => {
1876 |       httpClient.post.mockClear(); // not interested in updates
1877 | 
1878 |       await client.initialize();
1879 |       const result = client.getFlagsForBootstrap({
1880 |         company,
1881 |         user,
1882 |         other: otherContext,
1883 |         enableTracking: true,
1884 |       });
1885 | 
1886 |       expect(result).toStrictEqual({
1887 |         context: {
1888 |           company,
1889 |           user,
1890 |           other: otherContext,
1891 |           enableTracking: true,
1892 |         },
1893 |         flags: {
1894 |           flag1: {
1895 |             key: "flag1",
1896 |             isEnabled: true,
1897 |             targetingVersion: 1,
1898 |             config: {
1899 |               key: "config-1",
1900 |               payload: {
1901 |                 something: "else",
1902 |               },
1903 |               targetingVersion: 1,
1904 |               missingContextFields: [],
1905 |               ruleEvaluationResults: [true],
1906 |             },
1907 |             ruleEvaluationResults: [true],
1908 |             missingContextFields: [],
1909 |           },
1910 |           flag2: {
1911 |             key: "flag2",
1912 |             isEnabled: false,
1913 |             targetingVersion: 2,
1914 |             config: {
1915 |               key: undefined,
1916 |               payload: undefined,
1917 |               targetingVersion: undefined,
1918 |               missingContextFields: [],
1919 |               ruleEvaluationResults: [],
1920 |             },
1921 |             ruleEvaluationResults: [false],
1922 |             missingContextFields: ["attributeKey"],
1923 |           },
1924 |         },
1925 |       });
1926 | 
1927 |       // Should not have track function like regular getFlags
1928 |       expect(result.flags.flag1).not.toHaveProperty("track");
1929 |       expect(result.flags.flag2).not.toHaveProperty("track");
1930 | 
1931 |       await client.flush();
1932 | 
1933 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1934 |     });
1935 | 
1936 |     it("should return raw flags when only user is defined", async () => {
1937 |       httpClient.post.mockClear(); // not interested in updates
1938 | 
1939 |       await client.initialize();
1940 |       const flags = client.getFlagsForBootstrap({ user });
1941 | 
1942 |       expect(flags).toStrictEqual({
1943 |         context: {
1944 |           user,
1945 |           enableTracking: true,
1946 |         },
1947 |         flags: {
1948 |           flag1: {
1949 |             key: "flag1",
1950 |             isEnabled: false,
1951 |             targetingVersion: 1,
1952 |             config: {
1953 |               key: undefined,
1954 |               payload: undefined,
1955 |               targetingVersion: 1,
1956 |               missingContextFields: ["company.id"],
1957 |               ruleEvaluationResults: [false],
1958 |             },
1959 |             ruleEvaluationResults: [false],
1960 |             missingContextFields: ["company.id"],
1961 |           },
1962 |           flag2: {
1963 |             key: "flag2",
1964 |             isEnabled: false,
1965 |             targetingVersion: 2,
1966 |             config: {
1967 |               key: undefined,
1968 |               payload: undefined,
1969 |               targetingVersion: undefined,
1970 |               missingContextFields: [],
1971 |               ruleEvaluationResults: [],
1972 |             },
1973 |             ruleEvaluationResults: [false],
1974 |             missingContextFields: ["company.id"],
1975 |           },
1976 |         },
1977 |       });
1978 | 
1979 |       // Should not have track function
1980 |       expect(flags.flags.flag1).not.toHaveProperty("track");
1981 |       expect(flags.flags.flag2).not.toHaveProperty("track");
1982 | 
1983 |       await client.flush();
1984 | 
1985 |       expect(httpClient.post).toHaveBeenCalledTimes(1);
1986 |     });
1987 | 
1988 |     it("should return raw flags when only company is defined", async () => {
1989 |       await client.initialize();
1990 |       const flags = client.getFlagsForBootstrap({ company });
1991 | 
1992 |       expect(flags).toStrictEqual({
1993 |         context: {
1994 |           company,
1995 |           enableTracking: true,
1996 |         },
1997 |         flags: {
1998 |           flag1: {
1999 |             key: "flag1",
2000 |             isEnabled: true,
2001 |             targetingVersion: 1,
2002 |             config: {
2003 |               key: "config-1",
2004 |               payload: {
2005 |                 something: "else",
2006 |               },
2007 |               targetingVersion: 1,
2008 |               missingContextFields: [],
2009 |               ruleEvaluationResults: [true],
2010 |             },
2011 |             ruleEvaluationResults: [true],
2012 |             missingContextFields: [],
2013 |           },
2014 |           flag2: {
2015 |             key: "flag2",
2016 |             isEnabled: false,
2017 |             targetingVersion: 2,
2018 |             config: {
2019 |               key: undefined,
2020 |               payload: undefined,
2021 |               targetingVersion: undefined,
2022 |               missingContextFields: [],
2023 |               ruleEvaluationResults: [],
2024 |             },
2025 |             ruleEvaluationResults: [false],
2026 |             missingContextFields: ["attributeKey"],
2027 |           },
2028 |         },
2029 |       });
2030 | 
2031 |       // Should not have track function
2032 |       expect(flags.flags.flag1).not.toHaveProperty("track");
2033 |       expect(flags.flags.flag2).not.toHaveProperty("track");
2034 |     });
2035 | 
2036 |     it("should return raw flags when only other context is defined", async () => {
2037 |       await client.initialize();
2038 |       const flags = client.getFlagsForBootstrap({ other: otherContext });
2039 | 
2040 |       expect(flags).toStrictEqual({
2041 |         context: {
2042 |           other: otherContext,
2043 |           enableTracking: true,
2044 |         },
2045 |         flags: {
2046 |           flag1: {
2047 |             key: "flag1",
2048 |             isEnabled: false,
2049 |             targetingVersion: 1,
2050 |             config: {
2051 |               key: undefined,
2052 |               payload: undefined,
2053 |               targetingVersion: 1,
2054 |               missingContextFields: ["company.id"],
2055 |               ruleEvaluationResults: [false],
2056 |             },
2057 |             ruleEvaluationResults: [false],
2058 |             missingContextFields: ["company.id"],
2059 |           },
2060 |           flag2: {
2061 |             key: "flag2",
2062 |             isEnabled: false,
2063 |             targetingVersion: 2,
2064 |             config: {
2065 |               key: undefined,
2066 |               payload: undefined,
2067 |               targetingVersion: undefined,
2068 |               missingContextFields: [],
2069 |               ruleEvaluationResults: [],
2070 |             },
2071 |             ruleEvaluationResults: [false],
2072 |             missingContextFields: ["company.id"],
2073 |           },
2074 |         },
2075 |       });
2076 | 
2077 |       // Should not have track function
2078 |       expect(flags.flags.flag1).not.toHaveProperty("track");
2079 |       expect(flags.flags.flag2).not.toHaveProperty("track");
2080 |     });
2081 | 
2082 |     it("should return fallback flags when client is not initialized", async () => {
2083 |       const flags = client.getFlagsForBootstrap({
2084 |         company,
2085 |         user,
2086 |         other: otherContext,
2087 |         enableTracking: true,
2088 |       });
2089 | 
2090 |       // Should return the fallback flags defined in validOptions
2091 |       expect(flags).toStrictEqual({
2092 |         context: {
2093 |           company,
2094 |           user,
2095 |           other: otherContext,
2096 |           enableTracking: true,
2097 |         },
2098 |         flags: {
2099 |           key: {
2100 |             isEnabled: true,
2101 |             key: "key",
2102 |           },
2103 |         },
2104 |       });
2105 |     });
2106 | 
2107 |     it("should return fallback flags when flag definitions are not available", async () => {
2108 |       httpClient.get.mockResolvedValueOnce({
2109 |         ok: true,
2110 |         status: 200,
2111 |         body: {
2112 |           success: true,
2113 |           features: [], // No flag definitions
2114 |         },
2115 |       });
2116 | 
2117 |       await client.initialize();
2118 |       const flags = client.getFlagsForBootstrap({
2119 |         company,
2120 |         user,
2121 |         other: otherContext,
2122 |       });
2123 | 
2124 |       expect(flags).toStrictEqual({
2125 |         context: {
2126 |           company,
2127 |           user,
2128 |           other: otherContext,
2129 |           enableTracking: true,
2130 |         },
2131 |         flags: {},
2132 |       });
2133 |     });
2134 | 
2135 |     it("should handle enableTracking parameter", async () => {
2136 |       await client.initialize();
2137 | 
2138 |       // Test with enableTracking: true (default)
2139 |       const flagsWithTracking = client.getFlagsForBootstrap({
2140 |         company,
2141 |         user,
2142 |         other: otherContext,
2143 |         enableTracking: true,
2144 |       });
2145 | 
2146 |       // Test with enableTracking: false
2147 |       const flagsWithoutTracking = client.getFlagsForBootstrap({
2148 |         company,
2149 |         user,
2150 |         other: otherContext,
2151 |         enableTracking: true,
2152 |       });
2153 | 
2154 |       // Both should return the same raw flag structure
2155 |       expect(flagsWithTracking).toStrictEqual(flagsWithoutTracking);
2156 | 
2157 |       // Neither should have track functions
2158 |       expect(flagsWithTracking.flags.flag1).not.toHaveProperty("track");
2159 |       expect(flagsWithoutTracking.flags.flag1).not.toHaveProperty("track");
2160 |     });
2161 | 
2162 |     it("should properly define the rate limiter key", async () => {
2163 |       const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed");
2164 | 
2165 |       await client.initialize();
2166 |       client.getFlagsForBootstrap({ user, company, other: otherContext });
2167 | 
2168 |       expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0=");
2169 |     });
2170 | 
2171 |     it("should work in offline mode", async () => {
2172 |       const offlineClient = new ReflagClient({
2173 |         ...validOptions,
2174 |         offline: true,
2175 |       });
2176 | 
2177 |       const flags = offlineClient.getFlagsForBootstrap({
2178 |         company,
2179 |         user,
2180 |         other: otherContext,
2181 |         enableTracking: true,
2182 |       });
2183 | 
2184 |       expect(flags).toStrictEqual({
2185 |         context: {
2186 |           company,
2187 |           user,
2188 |           other: otherContext,
2189 |           enableTracking: true,
2190 |         },
2191 |         flags: {},
2192 |       });
2193 |     });
2194 | 
2195 |     it("should use fallback flags when provided and no definitions available", async () => {
2196 |       const fallbackTestFlags = {
2197 |         fallbackFlag: {
2198 |           key: "fallbackFlag",
2199 |           isEnabled: true,
2200 |           config: { key: "fallback-config", payload: { test: "data" } },
2201 |         },
2202 |       };
2203 | 
2204 |       const clientWithFallback = new ReflagClient({
2205 |         ...validOptions,
2206 |         fallbackFlags: fallbackTestFlags,
2207 |       });
2208 | 
2209 |       // Don't initialize to simulate no flag definitions
2210 |       const flags = clientWithFallback.getFlagsForBootstrap({
2211 |         company,
2212 |         user,
2213 |         other: otherContext,
2214 |         enableTracking: true,
2215 |       });
2216 | 
2217 |       expect(flags).toStrictEqual({
2218 |         context: {
2219 |           company,
2220 |           user,
2221 |           other: otherContext,
2222 |           enableTracking: true,
2223 |         },
2224 |         flags: fallbackTestFlags,
2225 |       });
2226 |     });
2227 |   });
2228 | });
2229 | 
2230 | describe("getFlagsRemote", () => {
2231 |   let client: ReflagClient;
2232 | 
2233 |   beforeEach(async () => {
2234 |     httpClient.get.mockResolvedValue({
2235 |       ok: true,
2236 |       status: 200,
2237 |       body: {
2238 |         success: true,
2239 |         remoteContextUsed: true,
2240 |         features: {
2241 |           flag1: {
2242 |             key: "flag1",
2243 |             targetingVersion: 1,
2244 |             isEnabled: true,
2245 |             config: {
2246 |               key: "config-1",
2247 |               version: 3,
2248 |               default: true,
2249 |               payload: { something: "else" },
2250 |               missingContextFields: ["funny"],
2251 |             },
2252 |             missingContextFields: ["something", "funny"],
2253 |           },
2254 |           flag2: {
2255 |             key: "flag2",
2256 |             targetingVersion: 2,
2257 |             isEnabled: false,
2258 |             missingContextFields: ["another"],
2259 |           },
2260 |           flag3: {
2261 |             key: "flag3",
2262 |             targetingVersion: 5,
2263 |             isEnabled: true,
2264 |           },
2265 |         },
2266 |       },
2267 |     });
2268 | 
2269 |     client = new ReflagClient(validOptions);
2270 |   });
2271 | 
2272 |   afterEach(() => {
2273 |     httpClient.get.mockClear();
2274 |   });
2275 | 
2276 |   it("should return evaluated flags", async () => {
2277 |     const result = await client.getFlagsRemote("c1", "u1", {
2278 |       other: otherContext,
2279 |     });
2280 | 
2281 |     expect(result).toStrictEqual({
2282 |       flag1: {
2283 |         key: "flag1",
2284 |         isEnabled: true,
2285 |         config: {
2286 |           key: "config-1",
2287 |           payload: { something: "else" },
2288 |         },
2289 |         track: expect.any(Function),
2290 |       },
2291 |       flag2: {
2292 |         key: "flag2",
2293 |         isEnabled: false,
2294 |         config: { key: undefined, payload: undefined },
2295 |         track: expect.any(Function),
2296 |       },
2297 |       flag3: {
2298 |         key: "flag3",
2299 |         isEnabled: true,
2300 |         config: { key: undefined, payload: undefined },
2301 |         track: expect.any(Function),
2302 |       },
2303 |     });
2304 | 
2305 |     expect(httpClient.get).toHaveBeenCalledTimes(1);
2306 | 
2307 |     expect(httpClient.get).toHaveBeenCalledWith(
2308 |       "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1",
2309 |       expectedHeaders,
2310 |       API_TIMEOUT_MS,
2311 |     );
2312 |   });
2313 | 
2314 |   it("should not try to append the context if it's empty", async () => {
2315 |     await client.getFlagsRemote();
2316 | 
2317 |     expect(httpClient.get).toHaveBeenCalledTimes(1);
2318 | 
2319 |     expect(httpClient.get).toHaveBeenCalledWith(
2320 |       "https://api.example.com/features/evaluated?",
2321 |       expectedHeaders,
2322 |       API_TIMEOUT_MS,
2323 |     );
2324 |   });
2325 | });
2326 | 
2327 | describe("getFlagRemote", () => {
2328 |   let client: ReflagClient;
2329 | 
2330 |   beforeEach(async () => {
2331 |     httpClient.get.mockResolvedValue({
2332 |       ok: true,
2333 |       status: 200,
2334 |       body: {
2335 |         success: true,
2336 |         remoteContextUsed: true,
2337 |         features: {
2338 |           flag1: {
2339 |             key: "flag1",
2340 |             targetingVersion: 1,
2341 |             isEnabled: true,
2342 |             config: {
2343 |               key: "config-1",
2344 |               version: 3,
2345 |               default: true,
2346 |               payload: { something: "else" },
2347 |               missingContextFields: ["two"],
2348 |             },
2349 |             missingContextFields: ["one", "two"],
2350 |           },
2351 |         },
2352 |       },
2353 |     });
2354 | 
2355 |     client = new ReflagClient(validOptions);
2356 |   });
2357 | 
2358 |   afterEach(() => {
2359 |     httpClient.get.mockClear();
2360 |   });
2361 | 
2362 |   it("should return evaluated flag", async () => {
2363 |     const result = await client.getFlagRemote("flag1", "c1", "u1", {
2364 |       other: otherContext,
2365 |     });
2366 | 
2367 |     expect(result).toStrictEqual({
2368 |       key: "flag1",
2369 |       isEnabled: true,
2370 |       track: expect.any(Function),
2371 |       config: {
2372 |         key: "config-1",
2373 |         payload: { something: "else" },
2374 |       },
2375 |     });
2376 | 
2377 |     expect(httpClient.get).toHaveBeenCalledTimes(1);
2378 | 
2379 |     expect(httpClient.get).toHaveBeenCalledWith(
2380 |       "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=flag1",
2381 |       expectedHeaders,
2382 |       API_TIMEOUT_MS,
2383 |     );
2384 |   });
2385 | 
2386 |   it("should not try to append the context if it's empty", async () => {
2387 |     await client.getFlagRemote("flag1");
2388 | 
2389 |     expect(httpClient.get).toHaveBeenCalledWith(
2390 |       "https://api.example.com/features/evaluated?key=flag1",
2391 |       expectedHeaders,
2392 |       API_TIMEOUT_MS,
2393 |     );
2394 |   });
2395 | });
2396 | 
2397 | describe("offline mode", () => {
2398 |   let client: ReflagClient;
2399 | 
2400 |   beforeEach(async () => {
2401 |     client = new ReflagClient({
2402 |       ...validOptions,
2403 |       offline: true,
2404 |     });
2405 |     await client.initialize();
2406 |   });
2407 | 
2408 |   it("should send not send or fetch anything", async () => {
2409 |     client.getFlags({});
2410 | 
2411 |     expect(httpClient.get).toHaveBeenCalledTimes(0);
2412 |     expect(httpClient.post).toHaveBeenCalledTimes(0);
2413 |   });
2414 | });
2415 | 
2416 | describe("BoundReflagClient", () => {
2417 |   beforeAll(() => {
2418 |     const response = {
2419 |       status: 200,
2420 |       body: { success: true },
2421 |     };
2422 | 
2423 |     httpClient.post.mockResolvedValue(response);
2424 | 
2425 |     httpClient.get.mockResolvedValue({
2426 |       ok: true,
2427 |       status: 200,
2428 |       body: {
2429 |         success: true,
2430 |         ...flagDefinitions,
2431 |       },
2432 |     });
2433 |   });
2434 |   const client = new ReflagClient(validOptions);
2435 | 
2436 |   beforeEach(async () => {
2437 |     await flushPromises();
2438 |     await client.flush();
2439 | 
2440 |     vi.mocked(httpClient.post).mockClear();
2441 |     client["rateLimiter"].clearStale(true);
2442 |   });
2443 | 
2444 |   it("should create a client instance", () => {
2445 |     expect(client).toBeInstanceOf(ReflagClient);
2446 |   });
2447 | 
2448 |   it("should return a new client instance with merged attributes", () => {
2449 |     const userOverride = { sex: "male", age: 30 };
2450 |     const companyOverride = { employees: 200, bankrupt: false };
2451 |     const otherOverride = { key: "new-value" };
2452 |     const other = { key: "value" };
2453 | 
2454 |     const newClient = client
2455 |       .bindClient({
2456 |         user,
2457 |         company,
2458 |         other,
2459 |       })
2460 |       .bindClient({
2461 |         user: { id: user.id, ...userOverride },
2462 |         company: { id: company.id, ...companyOverride },
2463 |         other: otherOverride,
2464 |       });
2465 | 
2466 |     expect(newClient["_options"]).toEqual({
2467 |       user: { ...user, ...userOverride },
2468 |       company: { ...company, ...companyOverride },
2469 |       other: { ...other, ...otherOverride },
2470 |       enableTracking: true,
2471 |     });
2472 |   });
2473 | 
2474 |   it("should allow using expected methods when bound to user", async () => {
2475 |     const boundClient = client.bindClient({ user });
2476 |     expect(boundClient.user).toEqual(user);
2477 | 
2478 |     expect(
2479 |       boundClient.bindClient({ other: otherContext }).otherContext,
2480 |     ).toEqual(otherContext);
2481 | 
2482 |     boundClient.getFlags();
2483 | 
2484 |     await boundClient.track("flag");
2485 |     await client.flush();
2486 | 
2487 |     expect(httpClient.post).toHaveBeenCalledWith(
2488 |       BULK_ENDPOINT,
2489 |       expectedHeaders,
2490 |       [
2491 |         expect.objectContaining({ type: "user" }),
2492 |         {
2493 |           event: "flag",
2494 |           type: "event",
2495 |           userId: "user123",
2496 |         },
2497 |       ],
2498 |     );
2499 |   });
2500 | 
2501 |   it("should add company ID from the context if not explicitly supplied", async () => {
2502 |     const boundClient = client.bindClient({ user, company });
2503 | 
2504 |     boundClient.getFlags();
2505 |     await boundClient.track("flag");
2506 | 
2507 |     await client.flush();
2508 | 
2509 |     expect(httpClient.post).toHaveBeenCalledWith(
2510 |       BULK_ENDPOINT,
2511 |       expectedHeaders,
2512 |       [
2513 |         expect.objectContaining({ type: "company" }),
2514 |         expect.objectContaining({ type: "user" }),
2515 |         {
2516 |           companyId: "company123",
2517 |           event: "flag",
2518 |           type: "event",
2519 |           userId: "user123",
2520 |         },
2521 |       ],
2522 |     );
2523 |   });
2524 | 
2525 |   it("should disable tracking within the client if `enableTracking` is `false`", async () => {
2526 |     const boundClient = client.bindClient({
2527 |       user,
2528 |       company,
2529 |       enableTracking: false,
2530 |     });
2531 | 
2532 |     const { track } = boundClient.getFlag("flag2");
2533 |     await track();
2534 |     await boundClient.track("flag1");
2535 | 
2536 |     await client.flush();
2537 | 
2538 |     expect(httpClient.post).not.toHaveBeenCalled();
2539 |   });
2540 | 
2541 |   it("should allow using expected methods", async () => {
2542 |     const boundClient = client.bindClient({ other: { key: "value" } });
2543 |     expect(boundClient.otherContext).toEqual({
2544 |       key: "value",
2545 |     });
2546 | 
2547 |     await client.initialize();
2548 | 
2549 |     boundClient.getFlags();
2550 |     boundClient.getFlag("flag1");
2551 | 
2552 |     await boundClient.flush();
2553 |   });
2554 | 
2555 |   it("should return raw flags for bootstrap from bound client", async () => {
2556 |     // Ensure client is properly initialized
2557 |     await client.initialize();
2558 |     const boundClient = client.bindClient({
2559 |       user,
2560 |       company,
2561 |       other: otherContext,
2562 |       enableTracking: true,
2563 |     });
2564 | 
2565 |     const result = boundClient.getFlagsForBootstrap();
2566 | 
2567 |     expect(result).toStrictEqual({
2568 |       context: {
2569 |         user,
2570 |         company,
2571 |         other: otherContext,
2572 |         enableTracking: true,
2573 |       },
2574 |       flags: {
2575 |         flag1: {
2576 |           key: "flag1",
2577 |           isEnabled: true,
2578 |           targetingVersion: 1,
2579 |           config: {
2580 |             key: "config-1",
2581 |             payload: {
2582 |               something: "else",
2583 |             },
2584 |             targetingVersion: 1,
2585 |             missingContextFields: [],
2586 |             ruleEvaluationResults: [true],
2587 |           },
2588 |           ruleEvaluationResults: [true],
2589 |           missingContextFields: [],
2590 |         },
2591 |         flag2: {
2592 |           key: "flag2",
2593 |           isEnabled: false,
2594 |           targetingVersion: 2,
2595 |           config: {
2596 |             key: undefined,
2597 |             payload: undefined,
2598 |             targetingVersion: undefined,
2599 |             missingContextFields: [],
2600 |             ruleEvaluationResults: [],
2601 |           },
2602 |           ruleEvaluationResults: [false],
2603 |           missingContextFields: ["attributeKey"],
2604 |         },
2605 |       },
2606 |     });
2607 | 
2608 |     // Should not have track function like regular getFlags
2609 |     expect(result.flags.flag1).not.toHaveProperty("track");
2610 |     expect(result.flags.flag2).not.toHaveProperty("track");
2611 |   });
2612 | 
2613 |   describe("getFlagRemote/getFlagsRemote", () => {
2614 |     beforeEach(async () => {
2615 |       httpClient.get.mockClear();
2616 |       httpClient.get.mockResolvedValue({
2617 |         ok: true,
2618 |         status: 200,
2619 |         body: {
2620 |           success: true,
2621 |           remoteContextUsed: true,
2622 |           features: {
2623 |             flag1: {
2624 |               key: "flag1",
2625 |               targetingVersion: 1,
2626 |               isEnabled: true,
2627 |               config: {
2628 |                 key: "config-1",
2629 |                 version: 3,
2630 |                 default: true,
2631 |                 payload: { something: "else" },
2632 |                 missingContextFields: ["else"],
2633 |               },
2634 |             },
2635 |             flag2: {
2636 |               key: "flag2",
2637 |               targetingVersion: 2,
2638 |               isEnabled: false,
2639 |               missingContextFields: ["something"],
2640 |             },
2641 |           },
2642 |         },
2643 |       });
2644 |     });
2645 | 
2646 |     it("should return evaluated flags", async () => {
2647 |       const boundClient = client.bindClient({
2648 |         user,
2649 |         company,
2650 |         other: otherContext,
2651 |       });
2652 | 
2653 |       const result = await boundClient.getFlagsRemote();
2654 | 
2655 |       expect(result).toStrictEqual({
2656 |         flag1: {
2657 |           key: "flag1",
2658 |           isEnabled: true,
2659 |           config: { key: "config-1", payload: { something: "else" } },
2660 |           track: expect.any(Function),
2661 |         },
2662 |         flag2: {
2663 |           key: "flag2",
2664 |           isEnabled: false,
2665 |           config: { key: undefined, payload: undefined },
2666 |           track: expect.any(Function),
2667 |         },
2668 |       });
2669 | 
2670 |       expect(httpClient.get).toHaveBeenCalledTimes(1);
2671 | 
2672 |       expect(httpClient.get).toHaveBeenCalledWith(
2673 |         "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value",
2674 |         expectedHeaders,
2675 |         API_TIMEOUT_MS,
2676 |       );
2677 |     });
2678 | 
2679 |     it("should return evaluated flag", async () => {
2680 |       const boundClient = client.bindClient({
2681 |         user,
2682 |         company,
2683 |         other: otherContext,
2684 |       });
2685 | 
2686 |       const result = await boundClient.getFlagRemote("flag1");
2687 | 
2688 |       expect(result).toStrictEqual({
2689 |         key: "flag1",
2690 |         isEnabled: true,
2691 |         config: { key: "config-1", payload: { something: "else" } },
2692 |         track: expect.any(Function),
2693 |       });
2694 | 
2695 |       expect(httpClient.get).toHaveBeenCalledTimes(1);
2696 | 
2697 |       expect(httpClient.get).toHaveBeenCalledWith(
2698 |         "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value&key=flag1",
2699 |         expectedHeaders,
2700 |         API_TIMEOUT_MS,
2701 |       );
2702 |     });
2703 |   });
2704 | });
2705 | 
```
Page 8/9FirstPrevNextLast