better-auth-feature-flags
Version:
Ship features safely with feature flags, A/B testing, and progressive rollouts - Better Auth plugin for modern release management
189 lines (164 loc) • 6.92 kB
text/typescript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import { describe, expect, it } from "bun:test";
import { featureFlagsClient } from "./client";
type FetchCall = { url: string; options: any };
function createFetchRecorder() {
const calls: FetchCall[] = [];
const fetch = (url: string, options: any) => {
calls.push({ url, options });
// Minimal successful response shape per endpoint
if (url.endsWith("/feature-flags/evaluate")) {
return Promise.resolve({ data: { value: true, reason: "default" } });
}
if (url.endsWith("/feature-flags/evaluate-batch")) {
return Promise.resolve({
data: { flags: {}, context: {}, evaluatedAt: new Date().toISOString() },
});
}
if (url.endsWith("/feature-flags/bootstrap")) {
return Promise.resolve({ data: { flags: {} } });
}
if (url.endsWith("/feature-flags/events")) {
return Promise.resolve({ data: { success: true, eventId: "e1" } });
}
if (url.endsWith("/feature-flags/events/batch")) {
return Promise.resolve({
data: { success: 1, failed: 0, batchId: "b1" },
});
}
return Promise.resolve({ data: {} });
};
return { fetch, calls };
}
describe("Feature Flags client request payloads (new API)", () => {
it("sends { flagKey, default? } for evaluate", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient({ defaults: { a: false } });
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.evaluate("a" as any);
const call = calls.find((c) => c.url.endsWith("/feature-flags/evaluate"));
expect(call).toBeTruthy();
expect(call!.options.method).toBe("POST");
expect(call!.options.body.flagKey).toBe("a");
expect(call!.options.body).not.toHaveProperty("flag");
expect(call!.options.body.default).toBe(false);
expect(call!.options.body).not.toHaveProperty("fallback");
expect(call!.options.body).not.toHaveProperty("shape");
});
it("sends { flagKeys, defaults? } for evaluate-batch and forwards select option", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient();
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.evaluateMany(["x", "y"] as any, {
defaults: { x: 1 } as any,
// Ensure select is forwarded by client
select: "value" as any,
});
const call = calls.find((c) =>
c.url.endsWith("/feature-flags/evaluate-batch"),
);
expect(call).toBeTruthy();
expect(call!.options.method).toBe("POST");
expect(call!.options.body.flagKeys).toEqual(["x", "y"]);
expect(call!.options.body).not.toHaveProperty("flags");
expect(call!.options.body.defaults).toEqual({ x: 1 });
expect(call!.options.body).not.toHaveProperty("fallbacks");
expect(call!.options.body.select).toBe("value");
});
it("sends include/prefix/environment for bootstrap and forwards select", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient();
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.bootstrap({
include: ["a"],
prefix: "p:",
environment: "prod",
select: "value" as any,
});
const call = calls.find((c) => c.url.endsWith("/feature-flags/bootstrap"));
expect(call).toBeTruthy();
expect(call!.options.body.include).toEqual(["a"]);
expect(call!.options.body.prefix).toBe("p:");
expect(call!.options.body.environment).toBe("prod");
expect(call!.options.body.select).toBe("value");
});
it("includes track and select parameters when provided", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient();
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.evaluate("test-flag" as any, {
track: false,
select: ["value", "reason"],
debug: true,
});
const call = calls.find((c) => c.url.endsWith("/feature-flags/evaluate"));
expect(call).toBeTruthy();
expect(call!.options.body.flagKey).toBe("test-flag");
expect(call!.options.body.track).toBe(false);
expect(call!.options.body.select).toEqual(["value", "reason"]);
expect(call!.options.body.debug).toBe(true);
});
it("includes track and select in batch evaluation", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient();
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.evaluateMany(["flag1", "flag2"] as any, {
track: false,
select: ["value", "variant"],
});
const call = calls.find((c) =>
c.url.endsWith("/feature-flags/evaluate-batch"),
);
expect(call).toBeTruthy();
expect(call!.options.body.flagKeys).toEqual(["flag1", "flag2"]);
expect(call!.options.body.track).toBe(false);
expect(call!.options.body.select).toEqual(["value", "variant"]);
});
it("sends { flagKey, event, properties } for single event", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient();
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.track("f1" as any, "click", { x: 1 });
const call = calls.find((c) => c.url.endsWith("/feature-flags/events"));
expect(call).toBeTruthy();
expect(call!.options.body.flagKey).toBe("f1");
expect(call!.options.body.event).toBe("click");
expect(call!.options.body.properties).toEqual({ x: 1 });
expect(call!.options.body).not.toHaveProperty("flag");
expect(call!.options.body).not.toHaveProperty("name");
expect(call!.options.body).not.toHaveProperty("data");
});
it("sends { events:[{ flagKey, event, properties }], batchId } for batch events", async () => {
const { fetch, calls } = createFetchRecorder();
const plugin = featureFlagsClient();
const actions = plugin.getActions(fetch, {}, {});
await actions.featureFlags.trackBatch(
[
{ flag: "f1" as any, event: "view", data: { a: 1 } },
{ flag: "f2" as any, event: "click", data: 2 },
],
{ idempotencyKey: "b-1" },
);
const call = calls.find((c) =>
c.url.endsWith("/feature-flags/events/batch"),
);
expect(call).toBeTruthy();
expect(call!.options.body.idempotencyKey).toBe("b-1");
const events = call!.options.body.events;
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
flagKey: "f1",
event: "view",
properties: { a: 1 },
});
expect(events[1]).toMatchObject({
flagKey: "f2",
event: "click",
properties: 2,
});
expect(events[0]).not.toHaveProperty("flag");
expect(events[0]).not.toHaveProperty("name");
expect(events[0]).not.toHaveProperty("data");
});
});