import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
import { version } from "../package.json";
import { FLAGS_EXPIRE_MS } from "../src/config";
import { FlagsClient, RawFlag } from "../src/flag/flags";
import { HttpClient } from "../src/httpClient";
import { flagsResult } from "./mocks/handlers";
import { newCache, TEST_STALE_MS } from "./flagCache.test";
import { testLogger } from "./testLogger";
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
});
afterAll(() => {
vi.useRealTimers();
});
function flagsClientFactory() {
const { cache } = newCache();
const httpClient = new HttpClient("pk", {
baseUrl: "https://front.reflag.com",
});
vi.spyOn(httpClient, "get");
vi.spyOn(httpClient, "post");
return {
cache,
httpClient,
newFlagsClient: function newFlagsClient(
context?: Record<string, any>,
options?: { staleWhileRevalidate?: boolean; fallbackFlags?: any },
) {
return new FlagsClient(
httpClient,
{
user: { id: "123" },
company: { id: "456" },
other: { eventId: "big-conference1" },
...context,
},
testLogger,
{
cache,
...options,
},
);
},
};
}
describe("FlagsClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("fetches flags", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
const flagsClient = newFlagsClient();
let updated = false;
flagsClient.onUpdated(() => {
updated = true;
});
await flagsClient.initialize();
expect(flagsClient.getFlags()).toEqual(flagsResult);
expect(updated).toBe(true);
expect(httpClient.get).toBeCalledTimes(1);
const calls = vi.mocked(httpClient.get).mock.calls.at(0)!;
const { params, path, timeoutMs } = calls[0];
const paramsObj = Object.fromEntries(new URLSearchParams(params));
expect(paramsObj).toEqual({
"reflag-sdk-version": "browser-sdk/" + version,
"context.user.id": "123",
"context.company.id": "456",
"context.other.eventId": "big-conference1",
publishableKey: "pk",
});
expect(path).toEqual("/features/evaluated");
expect(timeoutMs).toEqual(5000);
});
test("warns about missing context fields", async () => {
const { newFlagsClient } = flagsClientFactory();
const flagsClient = newFlagsClient();
await flagsClient.initialize();
expect(testLogger.warn).toHaveBeenCalledTimes(1);
expect(testLogger.warn).toHaveBeenCalledWith(
"[Flags] flag targeting rules might not be correctly evaluated due to missing context fields.",
{
flagA: ["field1", "field2"],
"flagB.config": ["field3"],
},
);
vi.advanceTimersByTime(TEST_STALE_MS + 1);
expect(testLogger.warn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60 * 1000);
await flagsClient.initialize();
expect(testLogger.warn).toHaveBeenCalledTimes(2);
});
test("ignores undefined context", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
const flagsClient = newFlagsClient({
user: undefined,
company: undefined,
other: undefined,
});
await flagsClient.initialize();
expect(flagsClient.getFlags()).toEqual(flagsResult);
expect(httpClient.get).toBeCalledTimes(1);
const calls = vi.mocked(httpClient.get).mock.calls.at(0);
const { params, path, timeoutMs } = calls![0];
const paramsObj = Object.fromEntries(new URLSearchParams(params));
expect(paramsObj).toEqual({
"reflag-sdk-version": "browser-sdk/" + version,
publishableKey: "pk",
});
expect(path).toEqual("/features/evaluated");
expect(timeoutMs).toEqual(5000);
});
test("return fallback flags on failure (string list)", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
vi.mocked(httpClient.get).mockRejectedValue(
new Error("Failed to fetch flags"),
);
const flagsClient = newFlagsClient(undefined, {
fallbackFlags: ["huddle"],
});
await flagsClient.initialize();
expect(flagsClient.getFlags()).toStrictEqual({
huddle: {
isEnabled: true,
config: undefined,
key: "huddle",
isEnabledOverride: null,
},
});
});
test("return fallback flags on failure (record)", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
vi.mocked(httpClient.get).mockRejectedValue(
new Error("Failed to fetch flags"),
);
const flagsClient = newFlagsClient(undefined, {
fallbackFlags: {
huddle: {
key: "john",
payload: { something: "else" },
},
zoom: true,
},
});
await flagsClient.initialize();
expect(flagsClient.getFlags()).toStrictEqual({
huddle: {
isEnabled: true,
config: { key: "john", payload: { something: "else" } },
key: "huddle",
isEnabledOverride: null,
},
zoom: {
isEnabled: true,
config: undefined,
key: "zoom",
isEnabledOverride: null,
},
});
});
test("caches response", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
const flagsClient1 = newFlagsClient();
await flagsClient1.initialize();
expect(httpClient.get).toBeCalledTimes(1);
const flagsClient2 = newFlagsClient();
await flagsClient2.initialize();
const flags = flagsClient2.getFlags();
expect(flags).toEqual(flagsResult);
expect(httpClient.get).toBeCalledTimes(1);
});
test("use cache when unable to fetch flags", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
const flagsClient = newFlagsClient({ staleWhileRevalidate: false });
await flagsClient.initialize(); // cache them initially
vi.mocked(httpClient.get).mockRejectedValue(
new Error("Failed to fetch flags"),
);
expect(httpClient.get).toBeCalledTimes(1);
vi.advanceTimersByTime(TEST_STALE_MS + 1);
// fail this time
await flagsClient.fetchFlags();
expect(httpClient.get).toBeCalledTimes(2);
const staleFlags = flagsClient.getFlags();
expect(staleFlags).toEqual(flagsResult);
});
test("stale-while-revalidate should cache but start new fetch", async () => {
const response = {
success: true,
features: {
flagB: {
isEnabled: true,
key: "flagB",
targetingVersion: 1,
} satisfies RawFlag,
},
};
const { newFlagsClient, httpClient } = flagsClientFactory();
vi.mocked(httpClient.get).mockResolvedValue({
status: 200,
ok: true,
json: function () {
return Promise.resolve(response);
},
} as Response);
const client = newFlagsClient({
staleWhileRevalidate: true,
});
expect(httpClient.get).toHaveBeenCalledTimes(0);
await client.initialize();
expect(client.getFlags()).toEqual({
flagB: {
isEnabled: true,
key: "flagB",
targetingVersion: 1,
isEnabledOverride: null,
} satisfies RawFlag,
});
expect(httpClient.get).toHaveBeenCalledTimes(1);
const client2 = newFlagsClient({
staleWhileRevalidate: true,
});
// change the response so we can validate that we'll serve the stale cache
vi.mocked(httpClient.get).mockResolvedValue({
status: 200,
ok: true,
json: () =>
Promise.resolve({
success: true,
features: {
flagA: {
isEnabled: true,
key: "flagA",
targetingVersion: 1,
},
},
}),
} as Response);
vi.advanceTimersByTime(TEST_STALE_MS + 1);
await client2.initialize();
// new fetch was fired in the background
expect(httpClient.get).toHaveBeenCalledTimes(2);
await vi.waitFor(() =>
expect(client2.getFlags()).toEqual({
flagA: {
isEnabled: true,
targetingVersion: 1,
key: "flagA",
isEnabledOverride: null,
} satisfies RawFlag,
}),
);
});
test("expires cache eventually", async () => {
// change the response so we can validate that we'll serve the stale cache
const { newFlagsClient, httpClient } = flagsClientFactory();
const client = newFlagsClient();
await client.initialize();
const a = client.getFlags();
vi.advanceTimersByTime(FLAGS_EXPIRE_MS + 1);
vi.mocked(httpClient.get).mockResolvedValue({
status: 200,
ok: true,
json: () =>
Promise.resolve({
success: true,
features: {
flagB: { isEnabled: true, key: "flagB" },
},
}),
} as Response);
const client2 = newFlagsClient();
await client2.initialize();
const b = client2.getFlags();
expect(httpClient.get).toHaveBeenCalledTimes(2);
expect(a).not.toEqual(b);
});
test("handled overrides", async () => {
// change the response so we can validate that we'll serve the stale cache
const { newFlagsClient } = flagsClientFactory();
// localStorage.clear();
const client = newFlagsClient();
await client.initialize();
let updated = false;
client.onUpdated(() => {
updated = true;
});
expect(client.getFlags().flagA.isEnabled).toBe(true);
expect(client.getFlags().flagA.isEnabledOverride).toBe(null);
expect(updated).toBe(false);
client.setFlagOverride("flagA", false);
expect(updated).toBe(true);
expect(client.getFlags().flagA.isEnabled).toBe(true);
expect(client.getFlags().flagA.isEnabledOverride).toBe(false);
});
test("ignores overrides for flags not returned by API", async () => {
// change the response so we can validate that we'll serve the stale cache
const { newFlagsClient } = flagsClientFactory();
// localStorage.clear();
const client = newFlagsClient(undefined);
await client.initialize();
let updated = false;
client.onUpdated(() => {
updated = true;
});
expect(client.getFlags().flagB.isEnabled).toBe(true);
expect(client.getFlags().flagB.isEnabledOverride).toBe(null);
// Setting an override for a flag that doesn't exist in fetched flags
// should not trigger an update since the merged flags don't change
client.setFlagOverride("flagC", true);
expect(updated).toBe(false);
expect(client.getFlags().flagC).toBeUndefined();
});
describe("pre-fetched flags", () => {
test("should have flags available when bootstrapped flags are provided in constructor", () => {
const { httpClient } = flagsClientFactory();
const preFetchedFlags = {
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
},
configFlag: {
key: "configFlag",
isEnabled: false,
targetingVersion: 2,
config: {
key: "config1",
version: 1,
payload: { value: "test" },
},
},
};
const flagsClient = new FlagsClient(
httpClient,
{
user: { id: "123" },
company: { id: "456" },
other: { eventId: "big-conference1" },
},
testLogger,
{
bootstrappedFlags: preFetchedFlags,
},
);
// Should be bootstrapped but not initialized until initialize() is called
expect(flagsClient["bootstrapped"]).toBe(true);
expect(flagsClient["initialized"]).toBe(false);
// Should have the flags available even before initialize()
expect(flagsClient.getFlags()).toEqual({
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
isEnabledOverride: null,
},
configFlag: {
key: "configFlag",
isEnabled: false,
targetingVersion: 2,
config: {
key: "config1",
version: 1,
payload: { value: "test" },
},
isEnabledOverride: null,
},
});
});
test("should skip fetching when already initialized with pre-fetched flags", async () => {
const { httpClient } = flagsClientFactory();
vi.spyOn(httpClient, "get");
const preFetchedFlags = {
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
},
};
const flagsClient = new FlagsClient(
httpClient,
{
user: { id: "123" },
company: { id: "456" },
other: { eventId: "big-conference1" },
},
testLogger,
{
bootstrappedFlags: preFetchedFlags,
},
);
// Call initialize() after flags are already provided
await flagsClient.initialize();
// Should not have made any HTTP requests since already initialized
expect(httpClient.get).not.toHaveBeenCalled();
// Should still have the flags available
expect(flagsClient.getFlags()).toEqual({
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
isEnabledOverride: null,
},
});
});
test("should trigger onUpdated when pre-fetched flags are set", async () => {
const { httpClient } = flagsClientFactory();
const preFetchedFlags = {
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
},
};
const flagsClient = new FlagsClient(
httpClient,
{
user: { id: "123" },
company: { id: "456" },
other: { eventId: "big-conference1" },
},
testLogger,
{
bootstrappedFlags: preFetchedFlags,
},
);
let updateTriggered = false;
flagsClient.onUpdated(() => {
updateTriggered = true;
});
// Trigger the flags updated event by setting context (which should still fetch)
await flagsClient.setContext({
user: { id: "456" },
company: { id: "789" },
other: { eventId: "other-conference" },
});
expect(updateTriggered).toBe(true);
});
test("should work with fallback flags when initialization fails", async () => {
const { httpClient } = flagsClientFactory();
vi.spyOn(httpClient, "get").mockRejectedValue(
new Error("Failed to fetch flags"),
);
const preFetchedFlags = {
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
},
};
const flagsClient = new FlagsClient(
httpClient,
{
user: { id: "123" },
company: { id: "456" },
other: { eventId: "big-conference1" },
},
testLogger,
{
bootstrappedFlags: preFetchedFlags,
fallbackFlags: ["fallbackFlag"],
},
);
// Should be bootstrapped but not initialized until initialize() is called
expect(flagsClient["bootstrapped"]).toBe(true);
expect(flagsClient["initialized"]).toBe(false);
expect(flagsClient.getFlags()).toEqual({
testFlag: {
key: "testFlag",
isEnabled: true,
targetingVersion: 1,
isEnabledOverride: null,
},
});
// Calling initialize should not fetch since already bootstrapped
await flagsClient.initialize();
expect(httpClient.get).not.toHaveBeenCalled();
expect(flagsClient["initialized"]).toBe(true);
});
});
});