Skip to main content
Glama

Bucket Feature Flags MCP Server

Official
by reflagcom
client.test.ts72.7 kB
import flushPromises from "flush-promises"; import { afterEach, beforeAll, beforeEach, describe, expect, it, test, vi, } from "vitest"; import { BoundReflagClient, ReflagClient } from "../src"; import { API_BASE_URL, API_TIMEOUT_MS, BATCH_INTERVAL_MS, BATCH_MAX_SIZE, FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, FLAGS_REFETCH_MS, SDK_VERSION, SDK_VERSION_HEADER_NAME, } from "../src/config"; import fetchClient from "../src/fetch-http-client"; import { subscribe as triggerOnExit } from "../src/flusher"; import { newRateLimiter } from "../src/rate-limiter"; import { ClientOptions, Context, FlagsAPIResponse } from "../src/types"; const BULK_ENDPOINT = "https://api.example.com/bulk"; vi.mock("../src/rate-limiter", async (importOriginal) => { const original = (await importOriginal()) as any; return { ...original, newRateLimiter: vi.fn(original.newRateLimiter), }; }); vi.mock("../src/flusher", () => ({ subscribe: vi.fn(), })); // Mock NextJS headers module for dynamic import vi.mock("next/headers", () => ({ cookies: vi.fn(), })); const user = { id: "user123", age: 1, name: "John", }; const company = { id: "company123", employees: 100, name: "Acme Inc.", }; const event = { event: "flag-event", attrs: { key: "value" }, }; const otherContext = { custom: "context", key: "value" }; const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; const httpClient = { post: vi.fn(), get: vi.fn() }; const fallbackFlags = ["key"]; const validOptions: ClientOptions = { secretKey: "validSecretKeyWithMoreThan22Chars", apiBaseUrl: "https://api.example.com/", logger, httpClient, fallbackFlags, flagsFetchRetries: 2, batchOptions: { maxSize: 99, intervalMs: 10001, flushOnExit: false, }, offline: false, }; const expectedHeaders = { [SDK_VERSION_HEADER_NAME]: SDK_VERSION, "Content-Type": "application/json", Authorization: `Bearer ${validOptions.secretKey}`, }; const flagDefinitions: FlagsAPIResponse = { features: [ { key: "flag1", description: "Flag 1", targeting: { version: 1, rules: [ { filter: { type: "context" as const, field: "company.id", operator: "IS", values: ["company123"], }, }, ], }, config: { version: 1, variants: [ { filter: { type: "context", field: "company.id", operator: "IS", values: ["company123"], }, key: "config-1", payload: { something: "else" }, }, ], }, }, { key: "flag2", description: "Flag 2", targeting: { version: 2, rules: [ { filter: { type: "group" as const, operator: "and", filters: [ { type: "context" as const, field: "company.id", operator: "IS", values: ["company123"], }, { partialRolloutThreshold: 0.5, partialRolloutAttribute: "attributeKey", type: "rolloutPercentage" as const, key: "flag2", }, ], }, }, ], }, }, ], }; describe("ReflagClient", () => { afterEach(() => { vi.clearAllMocks(); }); describe("constructor", () => { it("should initialize with no options", async () => { const secretKeyEnv = process.env.REFLAG_SECRET_KEY; process.env.REFLAG_SECRET_KEY = "validSecretKeyWithMoreThan22Chars"; try { const reflagInstance = new ReflagClient(); expect(reflagInstance).toBeInstanceOf(ReflagClient); } finally { process.env.REFLAG_SECRET_KEY = secretKeyEnv; } }); it("should accept fallback flags as an array", async () => { const reflagInstance = new ReflagClient({ secretKey: "validSecretKeyWithMoreThan22Chars", fallbackFlags: ["flag1", "flag2"], }); expect(reflagInstance["_config"].fallbackFlags).toEqual({ flag1: { isEnabled: true, key: "flag1", }, flag2: { isEnabled: true, key: "flag2", }, }); }); it("should accept fallback flags as an object", async () => { const reflagInstance = new ReflagClient({ secretKey: "validSecretKeyWithMoreThan22Chars", fallbackFlags: { flag1: true, flag2: { isEnabled: true, config: { key: "config1", payload: { value: true }, }, }, }, }); expect(reflagInstance["_config"].fallbackFlags).toStrictEqual({ flag1: { key: "flag1", config: undefined, isEnabled: true, }, flag2: { key: "flag2", isEnabled: true, config: { key: "config1", payload: { value: true }, }, }, }); }); it("should create a client instance with valid options", () => { const client = new ReflagClient(validOptions); expect(client).toBeInstanceOf(ReflagClient); expect(client["_config"].apiBaseUrl).toBe("https://api.example.com/"); expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS); expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5); expect(client.logger).toBeDefined(); expect(client.httpClient).toBe(validOptions.httpClient); expect(client["_config"].headers).toEqual(expectedHeaders); expect(client["batchBuffer"]).toMatchObject({ maxSize: 99, intervalMs: 10001, }); expect(client["_config"].fallbackFlags).toEqual({ key: { key: "key", isEnabled: true, }, }); expect(client["_config"].flagsFetchRetries).toBe(2); }); it("should route messages to the supplied logger", () => { const client = new ReflagClient(validOptions); const actualLogger = client.logger!; actualLogger.debug("debug message"); actualLogger.info("info message"); actualLogger.warn("warn message"); actualLogger.error("error message"); expect(logger.debug).toHaveBeenCalledWith( expect.stringMatching("debug message"), ); expect(logger.info).toHaveBeenCalledWith( expect.stringMatching("info message"), ); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching("warn message"), ); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching("error message"), ); }); it("should create a client instance with default values for optional fields", () => { const client = new ReflagClient({ secretKey: "validSecretKeyWithMoreThan22Chars", }); expect(client["_config"].apiBaseUrl).toBe(API_BASE_URL); expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS); expect(client["_config"].staleWarningInterval).toBe(FLAGS_REFETCH_MS * 5); expect(client.httpClient).toBe(fetchClient); expect(client["_config"].headers).toEqual(expectedHeaders); expect(client["_config"].fallbackFlags).toBeUndefined(); expect(client["batchBuffer"]).toMatchObject({ maxSize: BATCH_MAX_SIZE, intervalMs: BATCH_INTERVAL_MS, }); }); it("should throw an error if options are invalid", () => { let invalidOptions: any = null; expect(() => new ReflagClient(invalidOptions)).toThrow( "options must be an object", ); invalidOptions = { ...validOptions, secretKey: "shortKey" }; expect(() => new ReflagClient(invalidOptions)).toThrow( "invalid secretKey specified", ); invalidOptions = { ...validOptions, host: 123 }; expect(() => new ReflagClient(invalidOptions)).toThrow( "host must be a string", ); invalidOptions = { ...validOptions, logger: "invalidLogger" as any, }; expect(() => new ReflagClient(invalidOptions)).toThrow( "logger must be an object", ); invalidOptions = { ...validOptions, httpClient: "invalidHttpClient" as any, }; expect(() => new ReflagClient(invalidOptions)).toThrow( "httpClient must be an object", ); invalidOptions = { ...validOptions, batchOptions: "invalid" as any, }; expect(() => new ReflagClient(invalidOptions)).toThrow( "batchOptions must be an object", ); invalidOptions = { ...validOptions, fallbackFlags: "invalid" as any, }; expect(() => new ReflagClient(invalidOptions)).toThrow( "fallbackFlags must be an array or object", ); }); it("should create a new flag events rate-limiter", () => { const client = new ReflagClient(validOptions); expect(client["rateLimiter"]).toBeDefined(); expect(newRateLimiter).toHaveBeenCalledWith( FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, ); }); it("should not register an exit flush handler if `batchOptions.flushOnExit` is false", () => { new ReflagClient({ ...validOptions, batchOptions: { ...validOptions.batchOptions, flushOnExit: false }, }); expect(triggerOnExit).not.toHaveBeenCalled(); }); it("should not register an exit flush handler if `offline` is true", () => { new ReflagClient({ ...validOptions, offline: true, }); expect(triggerOnExit).not.toHaveBeenCalled(); }); it.each([undefined, true])( "should register an exit flush handler if `batchOptions.flushOnExit` is `%s`", (flushOnExit) => { new ReflagClient({ ...validOptions, batchOptions: { ...validOptions.batchOptions, flushOnExit }, }); expect(triggerOnExit).toHaveBeenCalledWith(expect.any(Function)); }, ); it.each([ ["https://api.example.com", "https://api.example.com/bulk"], ["https://api.example.com/", "https://api.example.com/bulk"], ["https://api.example.com/path", "https://api.example.com/path/bulk"], ["https://api.example.com/path/", "https://api.example.com/path/bulk"], ])( "should build the URLs correctly %s -> %s", async (apiBaseUrl, expectedUrl) => { const client = new ReflagClient({ ...validOptions, apiBaseUrl, }); await client.updateUser("user_id"); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( expectedUrl, expect.any(Object), expect.any(Object), ); }, ); }); describe("bindClient", () => { const client = new ReflagClient(validOptions); const context = { user, company, other: otherContext, }; beforeEach(() => { vi.mocked(httpClient.post).mockResolvedValue({ body: { success: true } }); client["rateLimiter"].clearStale(true); }); it("should return a new client instance with the `user`, `company` and `other` set", async () => { const newClient = client.bindClient(context); await client.flush(); expect(newClient.user).toEqual(user); expect(newClient.company).toEqual(company); expect(newClient.otherContext).toEqual(otherContext); expect(newClient).toBeInstanceOf(BoundReflagClient); expect(newClient).not.toBe(client); // Ensure a new instance is returned expect(newClient["_options"]).toEqual({ enableTracking: true, ...context, }); }); it("should update user in Reflag when called", async () => { client.bindClient({ user: context.user }); await client.flush(); const { id: _, ...attributes } = context.user; expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ { type: "user", userId: user.id, attributes: attributes, context: undefined, }, ], ); expect(httpClient.post).toHaveBeenCalledOnce(); }); it("should update company in Reflag when called", async () => { client.bindClient({ company: context.company, meta: { active: true } }); await client.flush(); const { id: _, ...attributes } = context.company; expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ { type: "company", companyId: company.id, attributes: attributes, context: { active: true, }, }, ], ); expect(httpClient.post).toHaveBeenCalledOnce(); }); it("should not update `company` or `user` in Reflag when `enableTracking` is `false`", async () => { client.bindClient({ user: context.user, company: context.company, enableTracking: false, }); await client.flush(); expect(httpClient.post).not.toHaveBeenCalled(); }); it("should throw an error if `user` is invalid", () => { expect(() => client.bindClient({ user: "bad_attributes" as any }), ).toThrow("validation failed: user must be an object if given"); expect(() => client.bindClient({ user: { id: {} as any } })).toThrow( "validation failed: user.id must be a string or number if given", ); }); it("should throw an error if `company` is invalid", () => { expect(() => client.bindClient({ company: "bad_attributes" as any }), ).toThrow("validation failed: company must be an object if given"); expect(() => client.bindClient({ company: { id: {} as any } })).toThrow( "validation failed: company.id must be a string or number if given", ); }); it("should throw an error if `other` is invalid", () => { expect(() => client.bindClient({ other: "bad_attributes" as any }), ).toThrow("validation failed: other must be an object"); }); it("should throw an error if `enableTracking` is invalid", () => { expect(() => client.bindClient({ enableTracking: "bad_attributes" as any }), ).toThrow("validation failed: enableTracking must be a boolean"); }); it("should allow context without id", () => { const c = client.bindClient({ user: { id: undefined, name: "userName" }, company: { id: undefined, name: "companyName" }, }); expect(c.user?.id).toBeUndefined(); expect(c.company?.id).toBeUndefined(); }); }); describe("updateUser", () => { const client = new ReflagClient(validOptions); beforeEach(() => { client["rateLimiter"].clearStale(true); }); // try with both string and number IDs test.each([ { id: "user123", age: 1, name: "John" }, { id: 42, age: 1, name: "John" }, ])("should successfully update the user", async (testUser) => { const response = { status: 200, body: { success: true } }; httpClient.post.mockResolvedValue(response); await client.updateUser(testUser.id, { attributes: { age: 2, brave: false }, meta: { active: true, }, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ { type: "user", userId: testUser.id, attributes: { age: 2, brave: false }, context: { active: true }, }, ], ); expect(logger.debug).toHaveBeenCalledWith( expect.stringMatching("post request to "), response, ); }); it("should log an error if the post request throws", async () => { const error = new Error("Network error"); httpClient.post.mockRejectedValue(error); await client.updateUser(user.id); await client.flush(); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching("post request to .* failed with error"), error, ); }); it("should log if API call returns false", async () => { const response = { status: 200, body: { success: false } }; httpClient.post.mockResolvedValue(response); await client.updateUser(user.id); await client.flush(); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching("invalid response received from server for"), JSON.stringify(response), ); }); it("should throw an error if opts are not valid or the user is not set", async () => { await expect( client.updateUser(user.id, "bad_opts" as any), ).rejects.toThrow("validation failed: options must be an object"); await expect( client.updateUser(user.id, { attributes: "bad_attributes" as any }), ).rejects.toThrow("attributes must be an object"); await expect( client.updateUser(user.id, { meta: "bad_meta" as any }), ).rejects.toThrow("meta must be an object"); }); }); describe("updateCompany", () => { const client = new ReflagClient(validOptions); beforeEach(() => { client["rateLimiter"].clearStale(true); }); test.each([ { id: "company123", employees: 100, name: "Acme Inc.", userId: "user123", }, { id: 42, employees: 100, name: "Acme Inc.", userId: 42 }, ])(`should successfully update the company`, async (testCompany) => { const response = { status: 200, body: { success: true } }; httpClient.post.mockResolvedValue(response); await client.updateCompany(testCompany.id, { attributes: { employees: 200, bankrupt: false }, meta: { active: true }, userId: testCompany.userId, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ { type: "company", companyId: testCompany.id, attributes: { employees: 200, bankrupt: false }, context: { active: true }, userId: testCompany.userId, }, ], ); expect(logger.debug).toHaveBeenCalledWith( expect.stringMatching("post request to .*"), response, ); }); it("should log an error if the post request throws", async () => { const error = new Error("Network error"); httpClient.post.mockRejectedValue(error); await client.updateCompany(company.id, {}); await client.flush(); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching("post request to .* failed with error"), error, ); }); it("should log an error if API responds with success: false", async () => { const response = { status: 200, body: { success: false }, }; httpClient.post.mockResolvedValue(response); await client.updateCompany(company.id, {}); await client.flush(); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching("invalid response received from server for"), JSON.stringify(response), ); }); it("should throw an error if company is not valid", async () => { await expect( client.updateCompany(company.id, "bad_opts" as any), ).rejects.toThrow("validation failed: options must be an object"); await expect( client.updateCompany(company.id, { attributes: "bad_attributes" as any, }), ).rejects.toThrow("attributes must be an object"); await expect( client.updateCompany(company.id, { meta: "bad_meta" as any }), ).rejects.toThrow("meta must be an object"); }); }); describe("track", () => { const client = new ReflagClient(validOptions); beforeEach(() => { client["rateLimiter"].clearStale(true); }); test.each([ { id: "user123", age: 1, name: "John" }, { id: 42, age: 1, name: "John" }, ])("should successfully track the flag usage", async (testUser) => { const response = { status: 200, body: { success: true }, }; httpClient.post.mockResolvedValue(response); await client.bindClient({ user: testUser, company }).track(event.event, { attributes: event.attrs, meta: { active: true }, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ expect.objectContaining({ type: "company", }), expect.objectContaining({ type: "user", }), { attributes: { key: "value", }, context: { active: true, }, event: "flag-event", type: "event", userId: testUser.id, companyId: company.id, }, ], ); expect(logger.debug).toHaveBeenCalledWith( expect.stringMatching("post request to"), response, ); }); it("should successfully track the flag usage including user and company", async () => { httpClient.post.mockResolvedValue({ status: 200, body: { success: true }, }); await client.bindClient({ user }).track(event.event, { companyId: "otherCompanyId", attributes: event.attrs, meta: { active: true }, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ expect.objectContaining({ type: "user", }), { attributes: { key: "value", }, context: { active: true, }, event: "flag-event", companyId: "otherCompanyId", type: "event", userId: "user123", }, ], ); }); it("should log an error if the post request fails", async () => { const error = new Error("Network error"); httpClient.post.mockRejectedValue(error); await client.bindClient({ user }).track(event.event); await client.flush(); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching("post request to .* failed with error"), error, ); }); it("should log if the API call returns false", async () => { const response = { status: 200, body: { success: false }, }; httpClient.post.mockResolvedValue(response); await client.bindClient({ user }).track(event.event); await client.flush(); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching("invalid response received from server for "), JSON.stringify(response), ); }); it("should log if user is not set", async () => { const boundClient = client.bindClient({ company }); await boundClient.track("hello"); expect(httpClient.post).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching("no user set, cannot track event"), ); }); it("should throw an error if event is invalid", async () => { const boundClient = client.bindClient({ company, user }); await expect(boundClient.track(undefined as any)).rejects.toThrow( "event must be a string", ); await expect(boundClient.track(1 as any)).rejects.toThrow( "event must be a string", ); await expect( boundClient.track(event.event, "bad_opts" as any), ).rejects.toThrow("validation failed: options must be an object"); await expect( boundClient.track(event.event, { attributes: "bad_attributes" as any, }), ).rejects.toThrow("attributes must be an object"); await expect( boundClient.track(event.event, { meta: "bad_meta" as any }), ).rejects.toThrow("meta must be an object"); }); }); describe("user", () => { it("should return the undefined if user was not set", () => { const client = new ReflagClient(validOptions).bindClient({ company }); expect(client.user).toBeUndefined(); }); it("should return the user if user was associated", () => { const client = new ReflagClient(validOptions).bindClient({ user }); expect(client.user).toEqual(user); }); }); describe("company", () => { it("should return the undefined if company was not set", () => { const client = new ReflagClient(validOptions).bindClient({ user }); expect(client.company).toBeUndefined(); }); it("should return the user if company was associated", () => { const client = new ReflagClient(validOptions).bindClient({ company }); expect(client.company).toEqual(company); }); }); describe("otherContext", () => { it("should return the undefined if custom context was not set", () => { const client = new ReflagClient(validOptions).bindClient({ company }); expect(client.otherContext).toBeUndefined(); }); it("should return the user if custom context was associated", () => { const client = new ReflagClient(validOptions).bindClient({ other: otherContext, }); expect(client.otherContext).toEqual(otherContext); }); }); describe("initialize", () => { it("should initialize the client", async () => { const client = new ReflagClient(validOptions); const get = vi .spyOn(client["flagsCache"], "get") .mockReturnValue(undefined); const refresh = vi .spyOn(client["flagsCache"], "refresh") .mockResolvedValue(undefined); await client.initialize(); await client.initialize(); await client.initialize(); expect(refresh).toHaveBeenCalledTimes(1); expect(get).not.toHaveBeenCalled(); }); it("should call the backend to obtain flags", async () => { const client = new ReflagClient(validOptions); httpClient.get.mockResolvedValue({ ok: true, status: 200, }); await client.initialize(); expect(httpClient.get).toHaveBeenCalledWith( `https://api.example.com/features`, expectedHeaders, API_TIMEOUT_MS, ); }); }); describe("flush", () => { it("should flush all bulk data", async () => { const client = new ReflagClient(validOptions); await client.updateUser(user.id, { attributes: { age: 2 } }); await client.updateUser(user.id, { attributes: { age: 3 } }); await client.updateUser(user.id, { attributes: { name: "Jane" } }); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ { type: "user", userId: user.id, attributes: { age: 2 }, }, { type: "user", userId: user.id, attributes: { age: 3 }, }, { type: "user", userId: user.id, attributes: { name: "Jane" }, }, ], ); }); it("should not flush all bulk data if `offline` is true", async () => { const client = new ReflagClient({ ...validOptions, offline: true, }); await client.updateUser(user.id, { attributes: { age: 2 } }); await client.flush(); expect(httpClient.post).not.toHaveBeenCalled(); }); }); describe("getFlag", () => { let client: ReflagClient; beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, ...flagDefinitions, }, }); client = new ReflagClient(validOptions); httpClient.post.mockResolvedValue({ status: 200, body: { success: true }, }); }); it("returns a flag", async () => { await client.initialize(); const flag = client.getFlag( { company, user, other: otherContext, }, "flag1", ); expect(flag).toStrictEqual({ key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else" }, }, track: expect.any(Function), }); }); it("`track` sends all expected events when `enableTracking` is `true`", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag( { ...context, meta: { active: true, }, enableTracking: true, }, "flag1", ); await flag.track(); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ { attributes: { employees: 100, name: "Acme Inc.", }, companyId: "company123", context: { active: true, }, type: "company", userId: undefined, }, { attributes: { age: 1, name: "John", }, context: { active: true, }, type: "user", userId: "user123", }, { type: "event", event: "flag1", userId: user.id, companyId: company.id, }, ], ); }); it("`isEnabled` sends `check` event", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag(context, "flag1"); // trigger `check` event expect(flag.isEnabled).toBe(true); await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter((e) => e.action === "check"); expect(checkEvents).toStrictEqual([ { type: "feature-flag-event", action: "check", key: "flag1", targetingVersion: 1, evalResult: true, evalContext: context, evalRuleResults: [true], evalMissingFields: [], }, ]); }); it("`isEnabled` warns about missing context fields", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag(context, "flag2"); // trigger the warning expect(flag.isEnabled).toBe(false); expect(logger.warn).toHaveBeenCalledWith( "flag targeting rules might not be correctly evaluated due to missing context fields.", { flag2: ["attributeKey"], }, ); }); it("`isEnabled` should not warn about missing context fields if not needed", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag(context, "flag1"); // should not trigger the warning expect(flag.isEnabled).toBe(true); expect(logger.warn).not.toHaveBeenCalled(); }); it("`config` sends `check` event", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag(context, "flag1"); // trigger `check` event expect(flag.config).toBeDefined(); await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter((e) => e.action === "check-config"); expect(checkEvents).toStrictEqual([ { type: "feature-flag-event", action: "check-config", key: "flag1", evalResult: { key: "config-1", payload: { something: "else", }, }, targetingVersion: 1, evalContext: context, evalRuleResults: [true], evalMissingFields: [], }, ]); }); it("sends events for unknown flags", async () => { const context: Context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag(context, "unknown-flag"); // trigger `check` event expect(flag.isEnabled).toBe(false); await flag.track(); await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter((e) => e.type === "feature-flag-event"); expect(checkEvents).toStrictEqual([ { type: "feature-flag-event", action: "check", key: "unknown-flag", targetingVersion: undefined, evalContext: context, evalResult: false, evalRuleResults: undefined, evalMissingFields: undefined, }, ]); }); it("sends company/user and track events", async () => { const context: Context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlag(context, "flag1"); // trigger `check` event await flag.track(); await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter( (e) => e.type === "company" || e.type === "user" || e.type === "event", ); expect(checkEvents).toStrictEqual([ { type: "company", companyId: "company123", attributes: { employees: 100, name: "Acme Inc.", }, userId: undefined, // this is a bug, will fix in separate PR context: undefined, }, { type: "user", userId: "user123", attributes: { age: 1, name: "John", }, context: undefined, }, { type: "event", event: "flag1", userId: user.id, companyId: company.id, context: undefined, attributes: undefined, }, ]); }); }); describe("getFlags", () => { let client: ReflagClient; beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, ...flagDefinitions, }, }); client = new ReflagClient(validOptions); client["rateLimiter"].clearStale(true); httpClient.post.mockResolvedValue({ ok: true, status: 200, body: { success: true }, }); }); it("should return evaluated flags", async () => { httpClient.post.mockClear(); // not interested in updates await client.initialize(); const result = client.getFlags({ company, user, other: otherContext, }); expect(result).toStrictEqual({ flag1: { key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else", }, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); }); it("should properly define the rate limiter key", async () => { const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed"); await client.initialize(); client.getFlags({ user, company, other: otherContext }); expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0="); }); it("should return evaluated flags when only user is defined", async () => { httpClient.post.mockClear(); // not interested in updates await client.initialize(); const flags = client.getFlags({ user }); expect(flags).toStrictEqual({ flag1: { isEnabled: false, key: "flag1", config: { key: undefined, payload: undefined, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); }); it("should return evaluated flags when only company is defined", async () => { await client.initialize(); const flags = client.getFlags({ company }); // expect will trigger the `isEnabled` getter and send a `check` event expect(flags).toStrictEqual({ flag1: { isEnabled: true, key: "flag1", config: { key: "config-1", payload: { something: "else", }, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); }); it("should not send flag events when `enableTracking` is `false`", async () => { await client.initialize(); const flags = client.getFlags({ company, enableTracking: false }); // expect will trigger the `isEnabled` getter and send a `check` event expect(flags).toStrictEqual({ flag1: { isEnabled: true, key: "flag1", config: { key: "config-1", payload: { something: "else", }, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); expect(httpClient.post).not.toHaveBeenCalled(); }); it("should return evaluated flags when only other context is defined", async () => { await client.initialize(); const flags = client.getFlags({ other: otherContext }); expect(flags).toStrictEqual({ flag1: { isEnabled: false, key: "flag1", config: { key: undefined, payload: undefined, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); }); it("should send `track` with user and company if provided", async () => { await client.initialize(); const flags = client.getFlags({ company, user }); await client.flush(); await flags.flag1.track(); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(2); // second call includes the track event const events = httpClient.post.mock.calls[1][2].filter( (e: any) => e.type === "event", ); expect(events).toStrictEqual([ { event: "flag1", type: "event", userId: "user123", companyId: "company123", attributes: undefined, context: undefined, }, ]); }); it("should send `track` with user if provided", async () => { await client.initialize(); const flags = client.getFlags({ user }); await client.flush(); await flags.flag1.track(); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(2); const emptyEvents = httpClient.post.mock.calls[0][2].filter( (e: any) => e.type === "event", ); expect(emptyEvents).toStrictEqual([]); // second call includes the track event const events = httpClient.post.mock.calls[1][2].filter( (e: any) => e.type === "event", ); expect(events).toStrictEqual([ { event: "flag1", type: "event", userId: "user123", companyId: undefined, attributes: undefined, context: undefined, }, ]); }); it("should not send `track` with only company if no user is provided", async () => { // we do not accept track events without a userId await client.initialize(); const flag = client.getFlags({ company }); await flag.flag1.track(); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); const events = httpClient.post.mock.calls[0][2].filter( (e: any) => e.type === "event", ); expect(events).toStrictEqual([]); }); it("`isEnabled` does not send `check` event", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlags(context); // trigger `check` event expect(flag.flag1.isEnabled).toBe(true); await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter((e) => e.action === "check"); expect(checkEvents).toStrictEqual([]); }); it("`config` does not send `check` event", async () => { const context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); const flag = client.getFlags(context); // attempt to trigger `check` event expect(flag.flag1.config).toBeDefined(); await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter((e) => e.action === "check-config"); expect(checkEvents).toStrictEqual([]); }); it("sends company/user events", async () => { const context: Context = { company, user, other: otherContext, }; // test that the flag is returned await client.initialize(); client.getFlags(context); // trigger `check` event await client.flush(); const checkEvents = httpClient.post.mock.calls .flatMap((call) => call[2]) .filter( (e) => e.type === "company" || e.type === "user" || e.type === "event", ); expect(checkEvents).toStrictEqual([ { type: "company", companyId: "company123", attributes: { employees: 100, name: "Acme Inc.", }, userId: undefined, // this is a bug, will fix in separate PR context: undefined, }, { type: "user", userId: "user123", attributes: { age: 1, name: "John", }, context: undefined, }, ]); }); it("should use fallback flags when `getFlagDefinitions` returns `undefined`", async () => { httpClient.get.mockResolvedValue({ success: false, }); await client.initialize(); const result = client.getFlag( { user: { id: "user123" }, enableTracking: true }, "key", ); expect(result).toStrictEqual({ key: "key", isEnabled: true, config: { key: undefined, payload: undefined }, track: expect.any(Function), }); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching( "no flag definitions available, using fallback flags", ), ); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ expect.objectContaining({ type: "user" }), expect.objectContaining({ type: "feature-flag-event", action: "check-config", key: "key", }), expect.objectContaining({ type: "feature-flag-event", action: "check", key: "key", evalResult: true, }), ], ); }); it("should not fail if sendFlagEvent fails to send check event", async () => { httpClient.post.mockResolvedValue({ status: 200, body: { success: true }, }); await client.initialize(); httpClient.post.mockRejectedValue(new Error("Network error")); const context = { user, company, other: otherContext }; const result = client.getFlags(context); // Trigger a flag check expect(result.flag1).toStrictEqual({ key: "flag1", isEnabled: true, track: expect.any(Function), config: { key: "config-1", payload: { something: "else", }, }, }); await client.flush(); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching("post request .* failed with error"), expect.any(Error), ); }); it("should use flag overrides", async () => { await client.initialize(); const context = { user, company, other: otherContext }; const pristineResults = client.getFlags(context); expect(pristineResults).toStrictEqual({ flag1: { key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else", }, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); client.flagOverrides = { flag1: false, }; const flags = client.getFlags(context); expect(flags).toStrictEqual({ flag1: { key: "flag1", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); client.clearFlagOverrides(); const flags2 = client.getFlags(context); expect(flags2).toStrictEqual({ ...pristineResults, flag1: { ...pristineResults.flag1, track: expect.any(Function), }, flag2: { ...pristineResults.flag2, track: expect.any(Function), }, }); }); it("should use flag overrides from function", async () => { await client.initialize(); const context = { user, company, other: otherContext }; const pristineResults = client.getFlags(context); expect(pristineResults).toStrictEqual({ flag1: { key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else", }, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); client.flagOverrides = (_context: Context) => { expect(context).toStrictEqual(context); return { flag1: { isEnabled: false }, flag2: true, flag3: { isEnabled: true, config: { key: "config-1", payload: { something: "else" }, }, }, }; }; const flags = client.getFlags(context); expect(flags).toStrictEqual({ flag1: { key: "flag1", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: true, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, flag3: { key: "flag3", isEnabled: true, config: { key: "config-1", payload: { something: "else" }, }, track: expect.any(Function), }, }); }); }); describe("getFlagsForBootstrap", () => { let client: ReflagClient; beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, ...flagDefinitions, }, }); client = new ReflagClient(validOptions); client["rateLimiter"].clearStale(true); httpClient.post.mockResolvedValue({ ok: true, status: 200, body: { success: true }, }); }); it("should return raw flags without wrapper functions", async () => { httpClient.post.mockClear(); // not interested in updates await client.initialize(); const result = client.getFlagsForBootstrap({ company, user, other: otherContext, enableTracking: true, }); expect(result).toStrictEqual({ context: { company, user, other: otherContext, enableTracking: true, }, flags: { flag1: { key: "flag1", isEnabled: true, targetingVersion: 1, config: { key: "config-1", payload: { something: "else", }, targetingVersion: 1, missingContextFields: [], ruleEvaluationResults: [true], }, ruleEvaluationResults: [true], missingContextFields: [], }, flag2: { key: "flag2", isEnabled: false, targetingVersion: 2, config: { key: undefined, payload: undefined, targetingVersion: undefined, missingContextFields: [], ruleEvaluationResults: [], }, ruleEvaluationResults: [false], missingContextFields: ["attributeKey"], }, }, }); // Should not have track function like regular getFlags expect(result.flags.flag1).not.toHaveProperty("track"); expect(result.flags.flag2).not.toHaveProperty("track"); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); }); it("should return raw flags when only user is defined", async () => { httpClient.post.mockClear(); // not interested in updates await client.initialize(); const flags = client.getFlagsForBootstrap({ user }); expect(flags).toStrictEqual({ context: { user, enableTracking: true, }, flags: { flag1: { key: "flag1", isEnabled: false, targetingVersion: 1, config: { key: undefined, payload: undefined, targetingVersion: 1, missingContextFields: ["company.id"], ruleEvaluationResults: [false], }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], }, flag2: { key: "flag2", isEnabled: false, targetingVersion: 2, config: { key: undefined, payload: undefined, targetingVersion: undefined, missingContextFields: [], ruleEvaluationResults: [], }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], }, }, }); // Should not have track function expect(flags.flags.flag1).not.toHaveProperty("track"); expect(flags.flags.flag2).not.toHaveProperty("track"); await client.flush(); expect(httpClient.post).toHaveBeenCalledTimes(1); }); it("should return raw flags when only company is defined", async () => { await client.initialize(); const flags = client.getFlagsForBootstrap({ company }); expect(flags).toStrictEqual({ context: { company, enableTracking: true, }, flags: { flag1: { key: "flag1", isEnabled: true, targetingVersion: 1, config: { key: "config-1", payload: { something: "else", }, targetingVersion: 1, missingContextFields: [], ruleEvaluationResults: [true], }, ruleEvaluationResults: [true], missingContextFields: [], }, flag2: { key: "flag2", isEnabled: false, targetingVersion: 2, config: { key: undefined, payload: undefined, targetingVersion: undefined, missingContextFields: [], ruleEvaluationResults: [], }, ruleEvaluationResults: [false], missingContextFields: ["attributeKey"], }, }, }); // Should not have track function expect(flags.flags.flag1).not.toHaveProperty("track"); expect(flags.flags.flag2).not.toHaveProperty("track"); }); it("should return raw flags when only other context is defined", async () => { await client.initialize(); const flags = client.getFlagsForBootstrap({ other: otherContext }); expect(flags).toStrictEqual({ context: { other: otherContext, enableTracking: true, }, flags: { flag1: { key: "flag1", isEnabled: false, targetingVersion: 1, config: { key: undefined, payload: undefined, targetingVersion: 1, missingContextFields: ["company.id"], ruleEvaluationResults: [false], }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], }, flag2: { key: "flag2", isEnabled: false, targetingVersion: 2, config: { key: undefined, payload: undefined, targetingVersion: undefined, missingContextFields: [], ruleEvaluationResults: [], }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], }, }, }); // Should not have track function expect(flags.flags.flag1).not.toHaveProperty("track"); expect(flags.flags.flag2).not.toHaveProperty("track"); }); it("should return fallback flags when client is not initialized", async () => { const flags = client.getFlagsForBootstrap({ company, user, other: otherContext, enableTracking: true, }); // Should return the fallback flags defined in validOptions expect(flags).toStrictEqual({ context: { company, user, other: otherContext, enableTracking: true, }, flags: { key: { isEnabled: true, key: "key", }, }, }); }); it("should return fallback flags when flag definitions are not available", async () => { httpClient.get.mockResolvedValueOnce({ ok: true, status: 200, body: { success: true, features: [], // No flag definitions }, }); await client.initialize(); const flags = client.getFlagsForBootstrap({ company, user, other: otherContext, }); expect(flags).toStrictEqual({ context: { company, user, other: otherContext, enableTracking: true, }, flags: {}, }); }); it("should handle enableTracking parameter", async () => { await client.initialize(); // Test with enableTracking: true (default) const flagsWithTracking = client.getFlagsForBootstrap({ company, user, other: otherContext, enableTracking: true, }); // Test with enableTracking: false const flagsWithoutTracking = client.getFlagsForBootstrap({ company, user, other: otherContext, enableTracking: true, }); // Both should return the same raw flag structure expect(flagsWithTracking).toStrictEqual(flagsWithoutTracking); // Neither should have track functions expect(flagsWithTracking.flags.flag1).not.toHaveProperty("track"); expect(flagsWithoutTracking.flags.flag1).not.toHaveProperty("track"); }); it("should properly define the rate limiter key", async () => { const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed"); await client.initialize(); client.getFlagsForBootstrap({ user, company, other: otherContext }); expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0="); }); it("should work in offline mode", async () => { const offlineClient = new ReflagClient({ ...validOptions, offline: true, }); const flags = offlineClient.getFlagsForBootstrap({ company, user, other: otherContext, enableTracking: true, }); expect(flags).toStrictEqual({ context: { company, user, other: otherContext, enableTracking: true, }, flags: {}, }); }); it("should use fallback flags when provided and no definitions available", async () => { const fallbackTestFlags = { fallbackFlag: { key: "fallbackFlag", isEnabled: true, config: { key: "fallback-config", payload: { test: "data" } }, }, }; const clientWithFallback = new ReflagClient({ ...validOptions, fallbackFlags: fallbackTestFlags, }); // Don't initialize to simulate no flag definitions const flags = clientWithFallback.getFlagsForBootstrap({ company, user, other: otherContext, enableTracking: true, }); expect(flags).toStrictEqual({ context: { company, user, other: otherContext, enableTracking: true, }, flags: fallbackTestFlags, }); }); }); }); describe("getFlagsRemote", () => { let client: ReflagClient; beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, remoteContextUsed: true, features: { flag1: { key: "flag1", targetingVersion: 1, isEnabled: true, config: { key: "config-1", version: 3, default: true, payload: { something: "else" }, missingContextFields: ["funny"], }, missingContextFields: ["something", "funny"], }, flag2: { key: "flag2", targetingVersion: 2, isEnabled: false, missingContextFields: ["another"], }, flag3: { key: "flag3", targetingVersion: 5, isEnabled: true, }, }, }, }); client = new ReflagClient(validOptions); }); afterEach(() => { httpClient.get.mockClear(); }); it("should return evaluated flags", async () => { const result = await client.getFlagsRemote("c1", "u1", { other: otherContext, }); expect(result).toStrictEqual({ flag1: { key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else" }, }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, flag3: { key: "flag3", isEnabled: true, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1", expectedHeaders, API_TIMEOUT_MS, ); }); it("should not try to append the context if it's empty", async () => { await client.getFlagsRemote(); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( "https://api.example.com/features/evaluated?", expectedHeaders, API_TIMEOUT_MS, ); }); }); describe("getFlagRemote", () => { let client: ReflagClient; beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, remoteContextUsed: true, features: { flag1: { key: "flag1", targetingVersion: 1, isEnabled: true, config: { key: "config-1", version: 3, default: true, payload: { something: "else" }, missingContextFields: ["two"], }, missingContextFields: ["one", "two"], }, }, }, }); client = new ReflagClient(validOptions); }); afterEach(() => { httpClient.get.mockClear(); }); it("should return evaluated flag", async () => { const result = await client.getFlagRemote("flag1", "c1", "u1", { other: otherContext, }); expect(result).toStrictEqual({ key: "flag1", isEnabled: true, track: expect.any(Function), config: { key: "config-1", payload: { something: "else" }, }, }); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=flag1", expectedHeaders, API_TIMEOUT_MS, ); }); it("should not try to append the context if it's empty", async () => { await client.getFlagRemote("flag1"); expect(httpClient.get).toHaveBeenCalledWith( "https://api.example.com/features/evaluated?key=flag1", expectedHeaders, API_TIMEOUT_MS, ); }); }); describe("offline mode", () => { let client: ReflagClient; beforeEach(async () => { client = new ReflagClient({ ...validOptions, offline: true, }); await client.initialize(); }); it("should send not send or fetch anything", async () => { client.getFlags({}); expect(httpClient.get).toHaveBeenCalledTimes(0); expect(httpClient.post).toHaveBeenCalledTimes(0); }); }); describe("BoundReflagClient", () => { beforeAll(() => { const response = { status: 200, body: { success: true }, }; httpClient.post.mockResolvedValue(response); httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, ...flagDefinitions, }, }); }); const client = new ReflagClient(validOptions); beforeEach(async () => { await flushPromises(); await client.flush(); vi.mocked(httpClient.post).mockClear(); client["rateLimiter"].clearStale(true); }); it("should create a client instance", () => { expect(client).toBeInstanceOf(ReflagClient); }); it("should return a new client instance with merged attributes", () => { const userOverride = { sex: "male", age: 30 }; const companyOverride = { employees: 200, bankrupt: false }; const otherOverride = { key: "new-value" }; const other = { key: "value" }; const newClient = client .bindClient({ user, company, other, }) .bindClient({ user: { id: user.id, ...userOverride }, company: { id: company.id, ...companyOverride }, other: otherOverride, }); expect(newClient["_options"]).toEqual({ user: { ...user, ...userOverride }, company: { ...company, ...companyOverride }, other: { ...other, ...otherOverride }, enableTracking: true, }); }); it("should allow using expected methods when bound to user", async () => { const boundClient = client.bindClient({ user }); expect(boundClient.user).toEqual(user); expect( boundClient.bindClient({ other: otherContext }).otherContext, ).toEqual(otherContext); boundClient.getFlags(); await boundClient.track("flag"); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ expect.objectContaining({ type: "user" }), { event: "flag", type: "event", userId: "user123", }, ], ); }); it("should add company ID from the context if not explicitly supplied", async () => { const boundClient = client.bindClient({ user, company }); boundClient.getFlags(); await boundClient.track("flag"); await client.flush(); expect(httpClient.post).toHaveBeenCalledWith( BULK_ENDPOINT, expectedHeaders, [ expect.objectContaining({ type: "company" }), expect.objectContaining({ type: "user" }), { companyId: "company123", event: "flag", type: "event", userId: "user123", }, ], ); }); it("should disable tracking within the client if `enableTracking` is `false`", async () => { const boundClient = client.bindClient({ user, company, enableTracking: false, }); const { track } = boundClient.getFlag("flag2"); await track(); await boundClient.track("flag1"); await client.flush(); expect(httpClient.post).not.toHaveBeenCalled(); }); it("should allow using expected methods", async () => { const boundClient = client.bindClient({ other: { key: "value" } }); expect(boundClient.otherContext).toEqual({ key: "value", }); await client.initialize(); boundClient.getFlags(); boundClient.getFlag("flag1"); await boundClient.flush(); }); it("should return raw flags for bootstrap from bound client", async () => { // Ensure client is properly initialized await client.initialize(); const boundClient = client.bindClient({ user, company, other: otherContext, enableTracking: true, }); const result = boundClient.getFlagsForBootstrap(); expect(result).toStrictEqual({ context: { user, company, other: otherContext, enableTracking: true, }, flags: { flag1: { key: "flag1", isEnabled: true, targetingVersion: 1, config: { key: "config-1", payload: { something: "else", }, targetingVersion: 1, missingContextFields: [], ruleEvaluationResults: [true], }, ruleEvaluationResults: [true], missingContextFields: [], }, flag2: { key: "flag2", isEnabled: false, targetingVersion: 2, config: { key: undefined, payload: undefined, targetingVersion: undefined, missingContextFields: [], ruleEvaluationResults: [], }, ruleEvaluationResults: [false], missingContextFields: ["attributeKey"], }, }, }); // Should not have track function like regular getFlags expect(result.flags.flag1).not.toHaveProperty("track"); expect(result.flags.flag2).not.toHaveProperty("track"); }); describe("getFlagRemote/getFlagsRemote", () => { beforeEach(async () => { httpClient.get.mockClear(); httpClient.get.mockResolvedValue({ ok: true, status: 200, body: { success: true, remoteContextUsed: true, features: { flag1: { key: "flag1", targetingVersion: 1, isEnabled: true, config: { key: "config-1", version: 3, default: true, payload: { something: "else" }, missingContextFields: ["else"], }, }, flag2: { key: "flag2", targetingVersion: 2, isEnabled: false, missingContextFields: ["something"], }, }, }, }); }); it("should return evaluated flags", async () => { const boundClient = client.bindClient({ user, company, other: otherContext, }); const result = await boundClient.getFlagsRemote(); expect(result).toStrictEqual({ flag1: { key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else" } }, track: expect.any(Function), }, flag2: { key: "flag2", isEnabled: false, config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( "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", expectedHeaders, API_TIMEOUT_MS, ); }); it("should return evaluated flag", async () => { const boundClient = client.bindClient({ user, company, other: otherContext, }); const result = await boundClient.getFlagRemote("flag1"); expect(result).toStrictEqual({ key: "flag1", isEnabled: true, config: { key: "config-1", payload: { something: "else" } }, track: expect.any(Function), }); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( "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", expectedHeaders, API_TIMEOUT_MS, ); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/reflagcom/bucket-javascript-sdk'

If you have feedback or need assistance with the MCP directory API, please join our Discord server