@terreno/feature-flags 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import {afterEach, describe, expect, it} from "bun:test";
2
+ import {FeatureFlag} from "../featureFlagModel";
3
+
4
+ describe("FeatureFlag model", () => {
5
+ afterEach(async () => {
6
+ await FeatureFlag.deleteMany({});
7
+ });
8
+
9
+ it("creates a boolean flag with defaults", async () => {
10
+ const flag = await FeatureFlag.create({
11
+ key: "new-checkout-flow",
12
+ name: "New Checkout Flow",
13
+ });
14
+
15
+ expect(flag.key).toBe("new-checkout-flow");
16
+ expect(flag.name).toBe("New Checkout Flow");
17
+ expect(flag.enabled).toBe(false);
18
+ expect(flag.archived).toBe(false);
19
+ expect(flag.type).toBe("boolean");
20
+ expect(flag.rolloutPercentage).toBe(100);
21
+ expect(flag.description).toBe("");
22
+ expect(flag.rules).toEqual([]);
23
+ expect(flag.variants).toEqual([]);
24
+ });
25
+
26
+ it("rejects unknown fields (strict: 'throw')", async () => {
27
+ await expect(
28
+ FeatureFlag.create({
29
+ key: "strict-test",
30
+ name: "Strict Test",
31
+ unknownField: true,
32
+ } as unknown as Record<string, unknown>)
33
+ ).rejects.toBeDefined();
34
+ });
35
+
36
+ it("requires a key and name", async () => {
37
+ await expect(
38
+ FeatureFlag.create({
39
+ name: "Missing key",
40
+ } as unknown as Record<string, unknown>)
41
+ ).rejects.toBeDefined();
42
+
43
+ await expect(
44
+ FeatureFlag.create({
45
+ key: "missing-name",
46
+ } as unknown as Record<string, unknown>)
47
+ ).rejects.toBeDefined();
48
+ });
49
+
50
+ it("enforces unique keys", async () => {
51
+ await FeatureFlag.syncIndexes();
52
+
53
+ await FeatureFlag.create({key: "dup-key", name: "First"});
54
+ await expect(FeatureFlag.create({key: "dup-key", name: "Second"})).rejects.toBeDefined();
55
+ });
56
+
57
+ it("clamps rolloutPercentage to the 0-100 range", async () => {
58
+ await expect(
59
+ FeatureFlag.create({
60
+ key: "over-rollout",
61
+ name: "Over",
62
+ rolloutPercentage: 150,
63
+ })
64
+ ).rejects.toBeDefined();
65
+
66
+ await expect(
67
+ FeatureFlag.create({
68
+ key: "under-rollout",
69
+ name: "Under",
70
+ rolloutPercentage: -5,
71
+ })
72
+ ).rejects.toBeDefined();
73
+ });
74
+
75
+ it("accepts variant flags with variants that sum to 100", async () => {
76
+ const flag = await FeatureFlag.create({
77
+ key: "variant-flag",
78
+ name: "Variant Flag",
79
+ type: "variant",
80
+ variants: [
81
+ {key: "control", weight: 40},
82
+ {key: "variant-a", weight: 60},
83
+ ],
84
+ });
85
+
86
+ expect(flag.type).toBe("variant");
87
+ expect(flag.variants.length).toBe(2);
88
+ });
89
+
90
+ it("rejects variant flags with no variants", async () => {
91
+ await expect(
92
+ FeatureFlag.create({
93
+ key: "variant-no-variants",
94
+ name: "No Variants",
95
+ type: "variant",
96
+ variants: [],
97
+ })
98
+ ).rejects.toMatchObject({title: "Variant flags must have at least one variant"});
99
+ });
100
+
101
+ it("rejects variant flags whose variant weights do not sum to 100", async () => {
102
+ await expect(
103
+ FeatureFlag.create({
104
+ key: "variant-bad-weights",
105
+ name: "Bad Weights",
106
+ type: "variant",
107
+ variants: [
108
+ {key: "control", weight: 10},
109
+ {key: "variant-a", weight: 10},
110
+ ],
111
+ })
112
+ ).rejects.toMatchObject({title: "Variant weights must sum to 100"});
113
+ });
114
+
115
+ it("allows boolean flags without variants", async () => {
116
+ const flag = await FeatureFlag.create({
117
+ key: "boolean-no-variants",
118
+ name: "Boolean",
119
+ type: "boolean",
120
+ });
121
+ expect(flag.variants).toEqual([]);
122
+ });
123
+
124
+ it("trims whitespace on keys", async () => {
125
+ const flag = await FeatureFlag.create({
126
+ key: " padded-key ",
127
+ name: "Padded",
128
+ });
129
+ expect(flag.key).toBe("padded-key");
130
+ });
131
+
132
+ it("stores complex rules including segments and field rules", async () => {
133
+ const flag = await FeatureFlag.create({
134
+ key: "complex-rules",
135
+ name: "Complex",
136
+ rules: [
137
+ {enabled: true, segment: "beta"},
138
+ {enabled: true, field: "plan", operator: "in", value: ["pro", "enterprise"]},
139
+ {enabled: false, field: "email", operator: "contains", value: "@example.com"},
140
+ ],
141
+ });
142
+ expect(flag.rules.length).toBe(3);
143
+ expect(flag.rules[0].segment).toBe("beta");
144
+ expect(flag.rules[1].operator).toBe("in");
145
+ expect(flag.rules[1].value).toEqual(["pro", "enterprise"]);
146
+ expect(flag.rules[2].enabled).toBe(false);
147
+ });
148
+
149
+ it("rejects invalid rule operators via the enum constraint", async () => {
150
+ await expect(
151
+ FeatureFlag.create({
152
+ key: "bad-op",
153
+ name: "Bad Operator",
154
+ rules: [{enabled: true, field: "plan", operator: "bogus", value: "pro"}],
155
+ })
156
+ ).rejects.toBeDefined();
157
+ });
158
+
159
+ it("rejects invalid type values via the enum constraint", async () => {
160
+ await expect(
161
+ FeatureFlag.create({
162
+ key: "bad-type",
163
+ name: "Bad Type",
164
+ type: "nonsense",
165
+ } as unknown as Record<string, unknown>)
166
+ ).rejects.toBeDefined();
167
+ });
168
+
169
+ it("supports the isDeleted soft-delete plugin", async () => {
170
+ const flag = await FeatureFlag.create({
171
+ key: "soft-delete",
172
+ name: "Soft",
173
+ });
174
+
175
+ expect(flag.deleted).toBe(false);
176
+
177
+ flag.deleted = true;
178
+ await flag.save();
179
+
180
+ const found = await FeatureFlag.findOne({key: "soft-delete"});
181
+ expect(found).toBeNull();
182
+
183
+ const foundWithDeleted = await FeatureFlag.findOne({deleted: true, key: "soft-delete"});
184
+ expect(foundWithDeleted?._id.toString()).toBe(flag._id.toString());
185
+ });
186
+
187
+ it("sets created/updated timestamps on save", async () => {
188
+ const flag = await FeatureFlag.create({
189
+ key: "timestamps",
190
+ name: "Timestamps",
191
+ });
192
+ expect(flag.created).toBeInstanceOf(Date);
193
+ expect(flag.updated).toBeInstanceOf(Date);
194
+ });
195
+
196
+ it("supports findExactlyOne and findOneOrNone plugins", async () => {
197
+ await FeatureFlag.create({key: "existing-flag", name: "Exists"});
198
+
199
+ const exact = await FeatureFlag.findExactlyOne({key: "existing-flag"});
200
+ expect(exact.name).toBe("Exists");
201
+
202
+ const maybe = await FeatureFlag.findOneOrNone({key: "missing-flag"});
203
+ expect(maybe).toBeNull();
204
+ });
205
+ });
@@ -0,0 +1,221 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import {
3
+ addAuthRoutes,
4
+ apiErrorMiddleware,
5
+ apiUnauthorizedMiddleware,
6
+ setupAuth,
7
+ type UserModel as UserModelType,
8
+ } from "@terreno/api";
9
+ import {authAsUser, getBaseServer, setupDb, UserModel} from "@terreno/api/src/tests";
10
+ import type express from "express";
11
+ import supertest from "supertest";
12
+ import type TestAgent from "supertest/lib/agent";
13
+
14
+ import {FeatureFlag} from "../featureFlagModel";
15
+ import {FeatureFlagsApp} from "../featureFlagsApp";
16
+ import type {SegmentFunction} from "../types";
17
+
18
+ const buildApp = (options?: {
19
+ basePath?: string;
20
+ segments?: Record<string, SegmentFunction>;
21
+ }): express.Application => {
22
+ const app = getBaseServer();
23
+ setupAuth(app, UserModel as unknown as UserModelType);
24
+ addAuthRoutes(app, UserModel as unknown as UserModelType);
25
+
26
+ const plugin = new FeatureFlagsApp(options);
27
+ plugin.register(app);
28
+
29
+ app.use(apiUnauthorizedMiddleware);
30
+ app.use(apiErrorMiddleware);
31
+ return app;
32
+ };
33
+
34
+ describe("FeatureFlagsApp", () => {
35
+ let app: express.Application;
36
+ let adminAgent: TestAgent;
37
+ let notAdminAgent: TestAgent;
38
+
39
+ beforeEach(async () => {
40
+ await setupDb();
41
+ await FeatureFlag.deleteMany({});
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await FeatureFlag.deleteMany({});
46
+ });
47
+
48
+ describe("construction", () => {
49
+ it("defaults basePath to /feature-flags and segments to an empty object", async () => {
50
+ app = buildApp();
51
+ adminAgent = await authAsUser(app, "admin");
52
+
53
+ const res = await adminAgent.get("/feature-flags/segments").expect(200);
54
+ expect(res.body.data).toEqual([]);
55
+ });
56
+
57
+ it("respects a custom basePath", async () => {
58
+ app = buildApp({basePath: "/flags"});
59
+ adminAgent = await authAsUser(app, "admin");
60
+
61
+ await adminAgent.get("/flags/segments").expect(200);
62
+ await adminAgent.get("/feature-flags/segments").expect(404);
63
+ });
64
+
65
+ it("accepts an empty options object", async () => {
66
+ app = buildApp({});
67
+ adminAgent = await authAsUser(app, "admin");
68
+
69
+ const res = await adminAgent.get("/feature-flags/segments").expect(200);
70
+ expect(res.body.data).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("admin CRUD routes", () => {
75
+ beforeEach(async () => {
76
+ app = buildApp();
77
+ adminAgent = await authAsUser(app, "admin");
78
+ notAdminAgent = await authAsUser(app, "notAdmin");
79
+ });
80
+
81
+ it("lets admins create, list, read, update, and delete flags", async () => {
82
+ const createRes = await adminAgent
83
+ .post("/feature-flags/flags")
84
+ .send({enabled: true, key: "new-flag", name: "New Flag"})
85
+ .expect(201);
86
+
87
+ const flagId: string = createRes.body.data._id;
88
+ expect(createRes.body.data.key).toBe("new-flag");
89
+
90
+ const listRes = await adminAgent.get("/feature-flags/flags").expect(200);
91
+ expect(listRes.body.data.length).toBe(1);
92
+
93
+ const readRes = await adminAgent.get(`/feature-flags/flags/${flagId}`).expect(200);
94
+ expect(readRes.body.data.key).toBe("new-flag");
95
+
96
+ const updateRes = await adminAgent
97
+ .patch(`/feature-flags/flags/${flagId}`)
98
+ .send({rolloutPercentage: 50})
99
+ .expect(200);
100
+ expect(updateRes.body.data.rolloutPercentage).toBe(50);
101
+
102
+ await adminAgent.delete(`/feature-flags/flags/${flagId}`).expect(204);
103
+ const finalList = await adminAgent.get("/feature-flags/flags").expect(200);
104
+ expect(finalList.body.data.length).toBe(0);
105
+ });
106
+
107
+ it("rejects non-admins from the CRUD routes", async () => {
108
+ // modelRouter responds with 405 Method Not Allowed when permissions fail
109
+ await notAdminAgent.get("/feature-flags/flags").expect(405);
110
+ await notAdminAgent
111
+ .post("/feature-flags/flags")
112
+ .send({key: "nope", name: "Nope"})
113
+ .expect(405);
114
+ });
115
+
116
+ it("rejects unauthenticated requests to CRUD routes", async () => {
117
+ const unauth = supertest(app);
118
+ await unauth.get("/feature-flags/flags").expect(401);
119
+ });
120
+ });
121
+
122
+ describe("GET /feature-flags/evaluate", () => {
123
+ beforeEach(async () => {
124
+ const segments: Record<string, SegmentFunction> = {
125
+ admins: (user: unknown) => (user as {admin?: boolean}).admin === true,
126
+ };
127
+ app = buildApp({segments});
128
+ });
129
+
130
+ it("returns 401 for unauthenticated callers", async () => {
131
+ const unauth = supertest(app);
132
+ await unauth.get("/feature-flags/evaluate").expect(401);
133
+ });
134
+
135
+ it("returns all enabled, non-archived flag values for the current user", async () => {
136
+ await FeatureFlag.create({
137
+ enabled: true,
138
+ key: "rolled-out",
139
+ name: "Rolled out",
140
+ rolloutPercentage: 100,
141
+ });
142
+ await FeatureFlag.create({
143
+ enabled: false,
144
+ key: "disabled-flag",
145
+ name: "Disabled",
146
+ rolloutPercentage: 100,
147
+ });
148
+ await FeatureFlag.create({
149
+ archived: true,
150
+ enabled: true,
151
+ key: "archived-flag",
152
+ name: "Archived",
153
+ rolloutPercentage: 100,
154
+ });
155
+ await FeatureFlag.create({
156
+ enabled: true,
157
+ key: "admins-only",
158
+ name: "Admins",
159
+ rolloutPercentage: 0,
160
+ rules: [{enabled: true, segment: "admins"}],
161
+ });
162
+
163
+ adminAgent = await authAsUser(app, "admin");
164
+
165
+ const res = await adminAgent.get("/feature-flags/evaluate").expect(200);
166
+ expect(res.body.data["rolled-out"]).toBe(true);
167
+ expect(res.body.data["admins-only"]).toBe(true);
168
+ expect(res.body.data["disabled-flag"]).toBeUndefined();
169
+ expect(res.body.data["archived-flag"]).toBeUndefined();
170
+ });
171
+
172
+ it("includes an admin's own evaluated flags", async () => {
173
+ await FeatureFlag.create({
174
+ enabled: true,
175
+ key: "user-only",
176
+ name: "User only",
177
+ rolloutPercentage: 0,
178
+ rules: [{enabled: true, field: "admin", operator: "eq", value: true}],
179
+ });
180
+
181
+ adminAgent = await authAsUser(app, "admin");
182
+ const res = await adminAgent.get("/feature-flags/evaluate").expect(200);
183
+ expect(res.body.data["user-only"]).toBe(true);
184
+
185
+ notAdminAgent = await authAsUser(app, "notAdmin");
186
+ const notAdminRes = await notAdminAgent.get("/feature-flags/evaluate").expect(200);
187
+ // notAdmin isn't admin, rolloutPercentage is 0 → should evaluate false
188
+ expect(notAdminRes.body.data["user-only"]).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe("GET /feature-flags/segments", () => {
193
+ beforeEach(async () => {
194
+ app = buildApp({
195
+ segments: {
196
+ admins: () => true,
197
+ "beta-testers": () => false,
198
+ },
199
+ });
200
+ });
201
+
202
+ it("returns the registered segment keys for admins", async () => {
203
+ adminAgent = await authAsUser(app, "admin");
204
+
205
+ const res = await adminAgent.get("/feature-flags/segments").expect(200);
206
+ expect(res.body.data.sort()).toEqual(["admins", "beta-testers"]);
207
+ });
208
+
209
+ it("rejects non-admin users", async () => {
210
+ notAdminAgent = await authAsUser(app, "notAdmin");
211
+
212
+ const res = await notAdminAgent.get("/feature-flags/segments").expect(403);
213
+ expect(res.body.title).toInclude("Only admins can view segments");
214
+ });
215
+
216
+ it("requires authentication", async () => {
217
+ const unauth = supertest(app);
218
+ await unauth.get("/feature-flags/segments").expect(401);
219
+ });
220
+ });
221
+ });
package/src/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type {FindExactlyOnePlugin, FindOneOrNonePlugin} from "@terreno/api";
2
+ import type mongoose from "mongoose";
3
+
4
+ export interface FeatureFlagVariant {
5
+ key: string;
6
+ weight: number;
7
+ }
8
+
9
+ export interface FeatureFlagRule {
10
+ field?: string;
11
+ operator?: "eq" | "neq" | "in" | "nin" | "gt" | "lt" | "contains";
12
+ value?: unknown;
13
+ segment?: string;
14
+ enabled?: boolean;
15
+ variant?: string;
16
+ }
17
+
18
+ export type FeatureFlagType = "boolean" | "variant";
19
+
20
+ export type FeatureFlagDocument = mongoose.Document<mongoose.Types.ObjectId> & {
21
+ key: string;
22
+ name: string;
23
+ description: string;
24
+ enabled: boolean;
25
+ type: FeatureFlagType;
26
+ variants: FeatureFlagVariant[];
27
+ rules: FeatureFlagRule[];
28
+ rolloutPercentage: number;
29
+ archived: boolean;
30
+ created: Date;
31
+ updated: Date;
32
+ deleted: boolean;
33
+ };
34
+
35
+ export type FeatureFlagModel = mongoose.Model<FeatureFlagDocument> &
36
+ FindExactlyOnePlugin<FeatureFlagDocument> &
37
+ FindOneOrNonePlugin<FeatureFlagDocument>;
38
+
39
+ export type SegmentFunction = (user: unknown) => boolean;
40
+
41
+ export interface FeatureFlagsOptions {
42
+ basePath?: string;
43
+ segments?: Record<string, SegmentFunction>;
44
+ }
45
+
46
+ export type EvaluationResult = Record<string, boolean | string | null>;
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "allowSyntheticDefaultImports": true,
5
+ "baseUrl": "./",
6
+ "declaration": true,
7
+ "downlevelIteration": true,
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "jsx": "react",
11
+ "lib": ["es2017", "dom", "es2017.object"],
12
+ "module": "commonjs",
13
+ "moduleResolution": "node",
14
+ "noEmit": false,
15
+ "noImplicitAny": false,
16
+ "noImplicitReturns": true,
17
+ "noImplicitThis": true,
18
+ "noUnusedLocals": false,
19
+ "noUnusedParameters": false,
20
+ "outDir": "./dist",
21
+ "preserveConstEnums": true,
22
+ "removeComments": false,
23
+ "skipLibCheck": true,
24
+ "strict": true,
25
+ "strictFunctionTypes": true,
26
+ "strictNullChecks": true,
27
+ "target": "es5"
28
+ },
29
+ "exclude": ["node_modules"],
30
+ "include": ["src/**/*.ts*", "src/*.ts"]
31
+ }