181 lines
6.4 KiB
TypeScript
181 lines
6.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { evaluateFlag, getFeatureFlagVariant, type StoredFeatureFlag } from "./evaluate.js";
|
|
|
|
function mkFlag(overrides: Partial<StoredFeatureFlag> = {}): StoredFeatureFlag {
|
|
return {
|
|
id: overrides.id ?? "flag_1",
|
|
name: overrides.name ?? "test-flag",
|
|
enabled: overrides.enabled ?? true,
|
|
rules: overrides.rules ?? null,
|
|
variants: overrides.variants ?? [],
|
|
enabledFrom: overrides.enabledFrom ?? null,
|
|
enabledUntil: overrides.enabledUntil ?? null,
|
|
parentFlagId: overrides.parentFlagId ?? null,
|
|
};
|
|
}
|
|
|
|
function mkLoader(flags: StoredFeatureFlag[]) {
|
|
const byKey = new Map<string, StoredFeatureFlag>();
|
|
for (const f of flags) {
|
|
byKey.set(f.name, f);
|
|
byKey.set(f.id, f);
|
|
}
|
|
return async (key: string) => byKey.get(key) ?? null;
|
|
}
|
|
|
|
describe("evaluateFlag — basics", () => {
|
|
it("flag-not-found", async () => {
|
|
const r = await evaluateFlag("missing", { flagName: "missing" }, { load: mkLoader([]) });
|
|
expect(r).toEqual({ enabled: false, reason: "flag-not-found" });
|
|
});
|
|
it("disabled", async () => {
|
|
const r = await evaluateFlag(
|
|
"test-flag",
|
|
{ flagName: "test-flag" },
|
|
{ load: mkLoader([mkFlag({ enabled: false })]) },
|
|
);
|
|
expect(r.enabled).toBe(false);
|
|
expect(r.reason).toBe("disabled");
|
|
});
|
|
it("enabled with no rules → matched", async () => {
|
|
const r = await evaluateFlag(
|
|
"test-flag",
|
|
{ flagName: "test-flag" },
|
|
{ load: mkLoader([mkFlag({})]) },
|
|
);
|
|
expect(r).toEqual({ enabled: true, reason: "matched" });
|
|
});
|
|
});
|
|
|
|
describe("evaluateFlag — time window", () => {
|
|
it("future enabledFrom → time-window-future", async () => {
|
|
const r = await evaluateFlag(
|
|
"test-flag",
|
|
{ flagName: "test-flag", now: new Date("2026-01-01T00:00:00Z") },
|
|
{
|
|
load: mkLoader([mkFlag({ enabledFrom: new Date("2030-01-01T00:00:00Z") })]),
|
|
},
|
|
);
|
|
expect(r.reason).toBe("time-window-future");
|
|
expect(r.enabled).toBe(false);
|
|
});
|
|
it("past enabledUntil → time-window-expired", async () => {
|
|
const r = await evaluateFlag(
|
|
"test-flag",
|
|
{ flagName: "test-flag", now: new Date("2026-01-01T00:00:00Z") },
|
|
{
|
|
load: mkLoader([mkFlag({ enabledUntil: new Date("2020-01-01T00:00:00Z") })]),
|
|
},
|
|
);
|
|
expect(r.reason).toBe("time-window-expired");
|
|
});
|
|
});
|
|
|
|
describe("evaluateFlag — dependency resolution", () => {
|
|
it("parent disabled → child returns parent-disabled", async () => {
|
|
const parent = mkFlag({ id: "parent_1", name: "parent", enabled: false });
|
|
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "parent_1" });
|
|
const r = await evaluateFlag("child", { flagName: "child" }, { load: mkLoader([parent, child]) });
|
|
expect(r.reason).toBe("parent-disabled");
|
|
expect(r.enabled).toBe(false);
|
|
});
|
|
it("parent missing → parent-not-found", async () => {
|
|
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "ghost" });
|
|
const r = await evaluateFlag("child", { flagName: "child" }, { load: mkLoader([child]) });
|
|
expect(r.reason).toBe("parent-not-found");
|
|
});
|
|
it("parent rules fail → parent-disabled bubbles up", async () => {
|
|
const parent = mkFlag({
|
|
id: "parent_1",
|
|
name: "parent",
|
|
rules: { type: "allowlist", userIds: ["other_user"] },
|
|
});
|
|
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "parent_1" });
|
|
const r = await evaluateFlag(
|
|
"child",
|
|
{ flagName: "child", userId: "u_self" },
|
|
{ load: mkLoader([parent, child]) },
|
|
);
|
|
expect(r.reason).toBe("parent-disabled");
|
|
});
|
|
it("parent enabled → child evaluates own rules", async () => {
|
|
const parent = mkFlag({ id: "parent_1", name: "parent" });
|
|
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "parent_1" });
|
|
const r = await evaluateFlag("child", { flagName: "child" }, { load: mkLoader([parent, child]) });
|
|
expect(r.enabled).toBe(true);
|
|
});
|
|
it("loop guard — child→parent→child returns parent-disabled, no infinite recursion", async () => {
|
|
const a = mkFlag({ id: "a", name: "a", parentFlagId: "b" });
|
|
const b = mkFlag({ id: "b", name: "b", parentFlagId: "a" });
|
|
const r = await evaluateFlag("a", { flagName: "a" }, { load: mkLoader([a, b]) });
|
|
expect(r.reason).toBe("parent-disabled");
|
|
});
|
|
});
|
|
|
|
describe("evaluateFlag — rules", () => {
|
|
it("rule mismatch → no-match", async () => {
|
|
const r = await evaluateFlag(
|
|
"test-flag",
|
|
{ flagName: "test-flag", planTier: "free" },
|
|
{
|
|
load: mkLoader([
|
|
mkFlag({ rules: { type: "plan-tier", plans: ["enterprise"] } }),
|
|
]),
|
|
},
|
|
);
|
|
expect(r.reason).toBe("no-match");
|
|
});
|
|
it("rule match → matched", async () => {
|
|
const r = await evaluateFlag(
|
|
"test-flag",
|
|
{ flagName: "test-flag", planTier: "enterprise" },
|
|
{
|
|
load: mkLoader([
|
|
mkFlag({ rules: { type: "plan-tier", plans: ["enterprise"] } }),
|
|
]),
|
|
},
|
|
);
|
|
expect(r.enabled).toBe(true);
|
|
expect(r.reason).toBe("matched");
|
|
});
|
|
});
|
|
|
|
describe("evaluateFlag — variants", () => {
|
|
it("single variant always wins", async () => {
|
|
const r = await evaluateFlag(
|
|
"ab",
|
|
{ flagName: "ab", userId: "u" },
|
|
{ load: mkLoader([mkFlag({ name: "ab", variants: [{ key: "only" }] })]) },
|
|
);
|
|
expect(r.variant?.key).toBe("only");
|
|
});
|
|
it("multiple variants are sticky per user", async () => {
|
|
const variants = [
|
|
{ key: "A", weight: 1 },
|
|
{ key: "B", weight: 1 },
|
|
{ key: "C", weight: 1 },
|
|
];
|
|
const flag = mkFlag({ name: "vv", variants });
|
|
const r1 = await evaluateFlag("vv", { flagName: "vv", userId: "u_42" }, { load: mkLoader([flag]) });
|
|
const r2 = await evaluateFlag("vv", { flagName: "vv", userId: "u_42" }, { load: mkLoader([flag]) });
|
|
expect(r1.variant?.key).toBe(r2.variant?.key);
|
|
expect(r1.variant?.key).toMatch(/^[ABC]$/);
|
|
});
|
|
it("getFeatureFlagVariant returns null when disabled", async () => {
|
|
const v = await getFeatureFlagVariant(
|
|
"x",
|
|
{ flagName: "x" },
|
|
{ load: mkLoader([mkFlag({ name: "x", enabled: false, variants: [{ key: "A" }] })]) },
|
|
);
|
|
expect(v).toBeNull();
|
|
});
|
|
it("getFeatureFlagVariant returns the variant when matched", async () => {
|
|
const v = await getFeatureFlagVariant(
|
|
"x",
|
|
{ flagName: "x", userId: "u" },
|
|
{ load: mkLoader([mkFlag({ name: "x", variants: [{ key: "A" }] })]) },
|
|
);
|
|
expect(v?.key).toBe("A");
|
|
});
|
|
});
|