@terreno/feature-flags 0.19.0 → 0.20.0
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.
- package/dist/featureFlagModel.js +38 -1
- package/dist/featureFlagsApp.d.ts +1 -0
- package/dist/featureFlagsApp.js +124 -6
- package/dist/flagConfiguration.d.ts +11 -0
- package/dist/flagConfiguration.js +74 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +6 -1
- package/dist/openFeatureProvider.d.ts +22 -0
- package/dist/openFeatureProvider.js +150 -0
- package/dist/tests/featureFlagModel.test.js +71 -0
- package/dist/tests/featureFlagsApp.liveUpdates.test.d.ts +1 -0
- package/dist/tests/featureFlagsApp.liveUpdates.test.js +203 -0
- package/dist/tests/featureFlagsApp.test.js +149 -0
- package/dist/tests/openFeatureProvider.test.d.ts +1 -0
- package/dist/tests/openFeatureProvider.test.js +395 -0
- package/dist/types.d.ts +31 -0
- package/package.json +3 -2
- package/src/featureFlagModel.ts +40 -1
- package/src/featureFlagsApp.ts +124 -4
- package/src/flagConfiguration.ts +57 -0
- package/src/index.ts +6 -0
- package/src/openFeatureProvider.ts +141 -0
- package/src/tests/featureFlagModel.test.ts +46 -0
- package/src/tests/featureFlagsApp.liveUpdates.test.ts +114 -0
- package/src/tests/featureFlagsApp.test.ts +101 -0
- package/src/tests/openFeatureProvider.test.ts +289 -0
- package/src/types.ts +35 -0
package/src/featureFlagsApp.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import {OpenFeature} from "@openfeature/server-sdk";
|
|
1
2
|
import {
|
|
2
3
|
APIError,
|
|
3
4
|
asyncHandler,
|
|
4
5
|
authenticateMiddleware,
|
|
6
|
+
logger,
|
|
5
7
|
type ModelRouterOptions,
|
|
6
8
|
modelRouter,
|
|
7
9
|
type OpenApiMiddleware,
|
|
@@ -10,9 +12,31 @@ import {
|
|
|
10
12
|
} from "@terreno/api";
|
|
11
13
|
import type express from "express";
|
|
12
14
|
import type {Model} from "mongoose";
|
|
15
|
+
import mongoose from "mongoose";
|
|
13
16
|
import {evaluateAllFlags} from "./evaluate";
|
|
14
17
|
import {FeatureFlag} from "./featureFlagModel";
|
|
15
|
-
import
|
|
18
|
+
import {buildFlagDefinition} from "./flagConfiguration";
|
|
19
|
+
import {MongoFeatureFlagProvider} from "./openFeatureProvider";
|
|
20
|
+
import type {
|
|
21
|
+
FeatureFlagDocument,
|
|
22
|
+
FeatureFlagsLiveUpdatesOptions,
|
|
23
|
+
FeatureFlagsOptions,
|
|
24
|
+
FlagConfigurationResponse,
|
|
25
|
+
FlagDefinition,
|
|
26
|
+
SegmentFunction,
|
|
27
|
+
} from "./types";
|
|
28
|
+
|
|
29
|
+
let evaluateDeprecationWarned = false;
|
|
30
|
+
|
|
31
|
+
const resolveSocketServer = (
|
|
32
|
+
live: FeatureFlagsLiveUpdatesOptions
|
|
33
|
+
): import("./types").FeatureFlagsSocketEmitter | null => {
|
|
34
|
+
const server = live.socketIoServer;
|
|
35
|
+
if (typeof server === "function") {
|
|
36
|
+
return server();
|
|
37
|
+
}
|
|
38
|
+
return server;
|
|
39
|
+
};
|
|
16
40
|
|
|
17
41
|
/**
|
|
18
42
|
* TerrenoPlugin that provides feature flags and A/B testing.
|
|
@@ -35,6 +59,7 @@ import type {FeatureFlagDocument, FeatureFlagsOptions, SegmentFunction} from "./
|
|
|
35
59
|
export class FeatureFlagsApp implements TerrenoPlugin {
|
|
36
60
|
private options: FeatureFlagsOptions;
|
|
37
61
|
private segments: Record<string, SegmentFunction>;
|
|
62
|
+
private mongoProvider?: MongoFeatureFlagProvider;
|
|
38
63
|
|
|
39
64
|
constructor(options?: FeatureFlagsOptions) {
|
|
40
65
|
this.options = options ?? {};
|
|
@@ -43,6 +68,8 @@ export class FeatureFlagsApp implements TerrenoPlugin {
|
|
|
43
68
|
|
|
44
69
|
register(app: express.Application, openApi?: unknown): void {
|
|
45
70
|
const basePath = this.options.basePath ?? "/feature-flags";
|
|
71
|
+
const openFeatureDomain = this.options.openFeatureDomain ?? "feature-flags";
|
|
72
|
+
|
|
46
73
|
const routerOptions: ModelRouterOptions<FeatureFlagDocument> = {
|
|
47
74
|
...(openApi ? {openApi: openApi as OpenApiMiddleware} : {}),
|
|
48
75
|
permissions: this.options.permissions ?? {
|
|
@@ -55,17 +82,54 @@ export class FeatureFlagsApp implements TerrenoPlugin {
|
|
|
55
82
|
sort: "-created",
|
|
56
83
|
};
|
|
57
84
|
|
|
58
|
-
|
|
85
|
+
this.mongoProvider = new MongoFeatureFlagProvider({
|
|
86
|
+
flagModel: FeatureFlag,
|
|
87
|
+
segments: this.segments,
|
|
88
|
+
});
|
|
89
|
+
OpenFeature.setProvider(openFeatureDomain, this.mongoProvider);
|
|
90
|
+
|
|
59
91
|
app.use(
|
|
60
92
|
`${basePath}/flags`,
|
|
61
93
|
modelRouter(FeatureFlag as Model<FeatureFlagDocument>, routerOptions)
|
|
62
94
|
);
|
|
63
95
|
|
|
64
|
-
|
|
96
|
+
app.get(
|
|
97
|
+
`${basePath}/flagConfiguration`,
|
|
98
|
+
authenticateMiddleware(),
|
|
99
|
+
asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
100
|
+
const user = req.user as {_id?: unknown; id?: string} | undefined;
|
|
101
|
+
if (!user) {
|
|
102
|
+
throw new APIError({status: 401, title: "Authentication required"});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const targetingKey = String(user._id ?? user.id);
|
|
106
|
+
const flags = await FeatureFlag.find({archived: {$ne: true}, enabled: true});
|
|
107
|
+
const config: Record<string, FlagDefinition> = {};
|
|
108
|
+
|
|
109
|
+
for (const flag of flags) {
|
|
110
|
+
config[flag.key] = buildFlagDefinition(flag, targetingKey, user, this.segments);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body: FlagConfigurationResponse = {data: config};
|
|
114
|
+
return res.json(body);
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
|
|
65
118
|
app.get(
|
|
66
119
|
`${basePath}/evaluate`,
|
|
67
120
|
authenticateMiddleware(),
|
|
68
121
|
asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
122
|
+
if (!evaluateDeprecationWarned) {
|
|
123
|
+
evaluateDeprecationWarned = true;
|
|
124
|
+
logger.warn(
|
|
125
|
+
"[feature-flags] GET /evaluate is deprecated; migrate clients to GET /flagConfiguration or use @terreno/rtk useFeatureFlags / useTerrenoFeatureFlags."
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sunset = new Date(Date.now() + 90 * 86400000).toUTCString();
|
|
130
|
+
res.setHeader("Deprecation", "true");
|
|
131
|
+
res.setHeader("Sunset", sunset);
|
|
132
|
+
|
|
69
133
|
const user = req.user as {_id?: unknown; id?: string} | undefined;
|
|
70
134
|
if (!user) {
|
|
71
135
|
throw new APIError({status: 401, title: "Authentication required"});
|
|
@@ -78,7 +142,6 @@ export class FeatureFlagsApp implements TerrenoPlugin {
|
|
|
78
142
|
})
|
|
79
143
|
);
|
|
80
144
|
|
|
81
|
-
// GET /feature-flags/segments — list registered segment names (admin only)
|
|
82
145
|
app.get(
|
|
83
146
|
`${basePath}/segments`,
|
|
84
147
|
authenticateMiddleware(),
|
|
@@ -94,5 +157,62 @@ export class FeatureFlagsApp implements TerrenoPlugin {
|
|
|
94
157
|
return res.json({data: Object.keys(this.segments)});
|
|
95
158
|
})
|
|
96
159
|
);
|
|
160
|
+
|
|
161
|
+
const live = this.options.liveUpdates;
|
|
162
|
+
if (live && this.mongoProvider) {
|
|
163
|
+
const eventName = live.eventName ?? "featureFlagsChanged";
|
|
164
|
+
let stream: {
|
|
165
|
+
close: () => Promise<void> | void;
|
|
166
|
+
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
|
|
167
|
+
} | null = null;
|
|
168
|
+
let didRetry = false;
|
|
169
|
+
|
|
170
|
+
const cleanupStream = (): void => {
|
|
171
|
+
try {
|
|
172
|
+
stream?.close();
|
|
173
|
+
} catch {
|
|
174
|
+
/* ignore close errors */
|
|
175
|
+
}
|
|
176
|
+
stream = null;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const bindStream = (): void => {
|
|
180
|
+
try {
|
|
181
|
+
stream = FeatureFlag.watch([], {fullDocument: "updateLookup"});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
`[feature-flags] FeatureFlag.watch() failed — live updates require MongoDB as a replica set (even single-node). ${String(err)}`
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
stream.on("change", (change) => {
|
|
190
|
+
const key = (change as {fullDocument?: {key?: string}}).fullDocument?.key;
|
|
191
|
+
const ioResolved = resolveSocketServer(live);
|
|
192
|
+
if (ioResolved) {
|
|
193
|
+
ioResolved.emit(eventName, {key});
|
|
194
|
+
}
|
|
195
|
+
this.mongoProvider?.emitConfigurationChanged();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
stream.on("error", (err) => {
|
|
199
|
+
logger.warn(`[feature-flags] change stream error: ${String(err)}`);
|
|
200
|
+
cleanupStream();
|
|
201
|
+
if (!didRetry) {
|
|
202
|
+
didRetry = true;
|
|
203
|
+
bindStream();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
logger.warn("[feature-flags] change stream disabled after repeated errors");
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
bindStream();
|
|
211
|
+
|
|
212
|
+
const onConnectionClose = (): void => {
|
|
213
|
+
cleanupStream();
|
|
214
|
+
};
|
|
215
|
+
mongoose.connection.on("close", onConnectionClose);
|
|
216
|
+
}
|
|
97
217
|
}
|
|
98
218
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {evaluateFlag} from "./evaluate";
|
|
2
|
+
import type {FeatureFlagDocument, FlagDefinition, SegmentFunction} from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the OpenFeature defaultVariant for a flag document, including legacy
|
|
6
|
+
* documents that omit the field.
|
|
7
|
+
*/
|
|
8
|
+
export const effectiveDefaultVariantForFlag = (flag: FeatureFlagDocument): string => {
|
|
9
|
+
if (flag.defaultVariant) {
|
|
10
|
+
return flag.defaultVariant;
|
|
11
|
+
}
|
|
12
|
+
if (flag.type === "boolean") {
|
|
13
|
+
return "off";
|
|
14
|
+
}
|
|
15
|
+
return flag.variants[0]?.key ?? "off";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Builds a {@link FlagDefinition} for the `/flagConfiguration` wire shape from a
|
|
20
|
+
* persisted flag and the resolved evaluation for one user.
|
|
21
|
+
*/
|
|
22
|
+
export const buildFlagDefinition = (
|
|
23
|
+
flag: FeatureFlagDocument,
|
|
24
|
+
targetingKey: string,
|
|
25
|
+
user: unknown,
|
|
26
|
+
segments: Record<string, SegmentFunction>
|
|
27
|
+
): FlagDefinition => {
|
|
28
|
+
const evaluated = evaluateFlag(flag, targetingKey, user, segments);
|
|
29
|
+
|
|
30
|
+
if (flag.type === "boolean") {
|
|
31
|
+
const active = evaluated === true;
|
|
32
|
+
return {
|
|
33
|
+
defaultVariant: active ? "on" : "off",
|
|
34
|
+
disabled: false,
|
|
35
|
+
variants: {off: false, on: true},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const variantKeys = flag.variants.map((v) => v.key);
|
|
40
|
+
let resolved: string;
|
|
41
|
+
if (typeof evaluated === "string" && variantKeys.includes(evaluated)) {
|
|
42
|
+
resolved = evaluated;
|
|
43
|
+
} else {
|
|
44
|
+
resolved = effectiveDefaultVariantForFlag(flag);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const variants: Record<string, string> = {};
|
|
48
|
+
for (const k of variantKeys) {
|
|
49
|
+
variants[k] = k;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
defaultVariant: resolved,
|
|
54
|
+
disabled: false,
|
|
55
|
+
variants,
|
|
56
|
+
};
|
|
57
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export {deterministicHash, evaluateAllFlags, evaluateFlag} from "./evaluate";
|
|
2
2
|
export {FeatureFlag, featureFlagAdminConfig} from "./featureFlagModel";
|
|
3
3
|
export {FeatureFlagsApp} from "./featureFlagsApp";
|
|
4
|
+
export {buildFlagDefinition, effectiveDefaultVariantForFlag} from "./flagConfiguration";
|
|
5
|
+
export {MongoFeatureFlagProvider} from "./openFeatureProvider";
|
|
4
6
|
export type {
|
|
5
7
|
EvaluationResult,
|
|
6
8
|
FeatureFlagDocument,
|
|
7
9
|
FeatureFlagModel,
|
|
8
10
|
FeatureFlagRule,
|
|
11
|
+
FeatureFlagsLiveUpdatesOptions,
|
|
9
12
|
FeatureFlagsOptions,
|
|
13
|
+
FeatureFlagsSocketEmitter,
|
|
10
14
|
FeatureFlagType,
|
|
11
15
|
FeatureFlagVariant,
|
|
16
|
+
FlagConfigurationResponse,
|
|
17
|
+
FlagDefinition,
|
|
12
18
|
SegmentFunction,
|
|
13
19
|
} from "./types";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ErrorCode,
|
|
3
|
+
type EvaluationContext,
|
|
4
|
+
type JsonValue,
|
|
5
|
+
type Logger,
|
|
6
|
+
OpenFeatureEventEmitter,
|
|
7
|
+
type Provider,
|
|
8
|
+
type ResolutionDetails,
|
|
9
|
+
ServerProviderEvents,
|
|
10
|
+
StandardResolutionReasons,
|
|
11
|
+
} from "@openfeature/server-sdk";
|
|
12
|
+
import {evaluateFlag} from "./evaluate";
|
|
13
|
+
import type {FeatureFlagModel, SegmentFunction} from "./types";
|
|
14
|
+
|
|
15
|
+
export interface MongoFeatureFlagProviderOptions {
|
|
16
|
+
flagModel: FeatureFlagModel;
|
|
17
|
+
segments?: Record<string, SegmentFunction>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class MongoFeatureFlagProvider implements Provider {
|
|
21
|
+
readonly metadata = {name: "MongoFeatureFlagProvider"} as const;
|
|
22
|
+
readonly runsOn = "server" as const;
|
|
23
|
+
readonly events = new OpenFeatureEventEmitter();
|
|
24
|
+
|
|
25
|
+
private readonly flagModel: FeatureFlagModel;
|
|
26
|
+
private readonly segments: Record<string, SegmentFunction>;
|
|
27
|
+
|
|
28
|
+
constructor(options: MongoFeatureFlagProviderOptions) {
|
|
29
|
+
this.flagModel = options.flagModel;
|
|
30
|
+
this.segments = options.segments ?? {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async resolveBooleanEvaluation(
|
|
34
|
+
flagKey: string,
|
|
35
|
+
defaultValue: boolean,
|
|
36
|
+
context: EvaluationContext,
|
|
37
|
+
_logger: Logger
|
|
38
|
+
): Promise<ResolutionDetails<boolean>> {
|
|
39
|
+
return this.resolve<boolean>(flagKey, defaultValue, context, "boolean", _logger);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async resolveStringEvaluation(
|
|
43
|
+
flagKey: string,
|
|
44
|
+
defaultValue: string,
|
|
45
|
+
context: EvaluationContext,
|
|
46
|
+
_logger: Logger
|
|
47
|
+
): Promise<ResolutionDetails<string>> {
|
|
48
|
+
return this.resolve<string>(flagKey, defaultValue, context, "variant", _logger);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async resolveNumberEvaluation(
|
|
52
|
+
_flagKey: string,
|
|
53
|
+
defaultValue: number,
|
|
54
|
+
_context: EvaluationContext,
|
|
55
|
+
_logger: Logger
|
|
56
|
+
): Promise<ResolutionDetails<number>> {
|
|
57
|
+
return {
|
|
58
|
+
errorCode: ErrorCode.FLAG_NOT_FOUND,
|
|
59
|
+
reason: StandardResolutionReasons.ERROR,
|
|
60
|
+
value: defaultValue,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async resolveObjectEvaluation<T extends JsonValue>(
|
|
65
|
+
_flagKey: string,
|
|
66
|
+
defaultValue: T,
|
|
67
|
+
_context: EvaluationContext,
|
|
68
|
+
_logger: Logger
|
|
69
|
+
): Promise<ResolutionDetails<T>> {
|
|
70
|
+
return {
|
|
71
|
+
errorCode: ErrorCode.FLAG_NOT_FOUND,
|
|
72
|
+
reason: StandardResolutionReasons.ERROR,
|
|
73
|
+
value: defaultValue,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
emitConfigurationChanged(): void {
|
|
78
|
+
this.events.emit(ServerProviderEvents.ConfigurationChanged, {flagsChanged: []});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async resolve<T extends boolean | string>(
|
|
82
|
+
flagKey: string,
|
|
83
|
+
defaultValue: T,
|
|
84
|
+
context: EvaluationContext,
|
|
85
|
+
expectedType: "boolean" | "variant",
|
|
86
|
+
_logger: Logger
|
|
87
|
+
): Promise<ResolutionDetails<T>> {
|
|
88
|
+
const flag = await this.flagModel.findOneOrNone({archived: {$ne: true}, key: flagKey});
|
|
89
|
+
if (!flag) {
|
|
90
|
+
return {
|
|
91
|
+
errorCode: ErrorCode.FLAG_NOT_FOUND,
|
|
92
|
+
reason: StandardResolutionReasons.ERROR,
|
|
93
|
+
value: defaultValue,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (flag.type !== expectedType) {
|
|
98
|
+
return {
|
|
99
|
+
errorCode: ErrorCode.TYPE_MISMATCH,
|
|
100
|
+
reason: StandardResolutionReasons.ERROR,
|
|
101
|
+
value: defaultValue,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!flag.enabled) {
|
|
106
|
+
return {
|
|
107
|
+
reason: StandardResolutionReasons.DISABLED,
|
|
108
|
+
value: defaultValue,
|
|
109
|
+
variant: flag.defaultVariant,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const targetingKey = context.targetingKey ?? "";
|
|
114
|
+
const userFromContext = (context as {user?: unknown}).user;
|
|
115
|
+
const user = userFromContext !== undefined ? userFromContext : context;
|
|
116
|
+
const result = evaluateFlag(flag, targetingKey, user, this.segments);
|
|
117
|
+
|
|
118
|
+
if (expectedType === "boolean") {
|
|
119
|
+
const value = result as boolean as T;
|
|
120
|
+
return {
|
|
121
|
+
reason: StandardResolutionReasons.TARGETING_MATCH,
|
|
122
|
+
value,
|
|
123
|
+
variant: value ? "on" : "off",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (result === null) {
|
|
128
|
+
return {
|
|
129
|
+
reason: StandardResolutionReasons.DISABLED,
|
|
130
|
+
value: defaultValue,
|
|
131
|
+
variant: flag.defaultVariant,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
reason: StandardResolutionReasons.TARGETING_MATCH,
|
|
137
|
+
value: result as T,
|
|
138
|
+
variant: result as string,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -202,6 +202,51 @@ describe("FeatureFlag model", () => {
|
|
|
202
202
|
const maybe = await FeatureFlag.findOneOrNone({key: "missing-flag"});
|
|
203
203
|
expect(maybe).toBeNull();
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
it("auto-fills defaultVariant to off for boolean flags when omitted", async () => {
|
|
207
|
+
const flag = await FeatureFlag.create({
|
|
208
|
+
key: "default-variant-bool",
|
|
209
|
+
name: "Default variant bool",
|
|
210
|
+
});
|
|
211
|
+
expect(flag.defaultVariant).toBe("off");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("auto-fills defaultVariant to the first variant key when omitted on variant flags", async () => {
|
|
215
|
+
const flag = await FeatureFlag.create({
|
|
216
|
+
key: "default-variant-var",
|
|
217
|
+
name: "Default variant var",
|
|
218
|
+
type: "variant",
|
|
219
|
+
variants: [
|
|
220
|
+
{key: "control", weight: 40},
|
|
221
|
+
{key: "treatment", weight: 60},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
expect(flag.defaultVariant).toBe("control");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("accepts explicit defaultVariant on for boolean flags", async () => {
|
|
228
|
+
const flag = await FeatureFlag.create({
|
|
229
|
+
defaultVariant: "on",
|
|
230
|
+
enabled: true,
|
|
231
|
+
key: "explicit-on-bool",
|
|
232
|
+
name: "Explicit on",
|
|
233
|
+
rolloutPercentage: 100,
|
|
234
|
+
type: "boolean",
|
|
235
|
+
});
|
|
236
|
+
expect(flag.defaultVariant).toBe("on");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("rejects boolean defaultVariant values other than on or off", async () => {
|
|
240
|
+
await expect(
|
|
241
|
+
FeatureFlag.create({
|
|
242
|
+
defaultVariant: "maybe",
|
|
243
|
+
enabled: true,
|
|
244
|
+
key: "bad-default-variant",
|
|
245
|
+
name: "Bad default",
|
|
246
|
+
type: "boolean",
|
|
247
|
+
})
|
|
248
|
+
).rejects.toMatchObject({title: "Boolean flags must use defaultVariant 'on' or 'off'"});
|
|
249
|
+
});
|
|
205
250
|
});
|
|
206
251
|
|
|
207
252
|
describe("featureFlagAdminConfig", () => {
|
|
@@ -215,6 +260,7 @@ describe("featureFlagAdminConfig", () => {
|
|
|
215
260
|
"type",
|
|
216
261
|
"enabled",
|
|
217
262
|
"archived",
|
|
263
|
+
"defaultVariant",
|
|
218
264
|
"created",
|
|
219
265
|
]);
|
|
220
266
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from "bun:test";
|
|
2
|
+
import EventEmitter from "node:events";
|
|
3
|
+
import {OpenFeature} from "@openfeature/server-sdk";
|
|
4
|
+
import {
|
|
5
|
+
addAuthRoutes,
|
|
6
|
+
apiErrorMiddleware,
|
|
7
|
+
apiUnauthorizedMiddleware,
|
|
8
|
+
logger,
|
|
9
|
+
setupAuth,
|
|
10
|
+
type UserModel as UserModelType,
|
|
11
|
+
} from "@terreno/api";
|
|
12
|
+
import {getBaseServer, setupDb, UserModel} from "@terreno/api/src/tests";
|
|
13
|
+
import type express from "express";
|
|
14
|
+
|
|
15
|
+
import {FeatureFlag} from "../featureFlagModel";
|
|
16
|
+
import {FeatureFlagsApp} from "../featureFlagsApp";
|
|
17
|
+
|
|
18
|
+
class FakeChangeStream extends EventEmitter {
|
|
19
|
+
public close = mock((): Promise<void> => Promise.resolve());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const buildAppWithLive = (options: {
|
|
23
|
+
eventName?: string;
|
|
24
|
+
io: {emit: ReturnType<typeof mock>};
|
|
25
|
+
}): express.Application => {
|
|
26
|
+
const app = getBaseServer();
|
|
27
|
+
setupAuth(app, UserModel as unknown as UserModelType);
|
|
28
|
+
addAuthRoutes(app, UserModel as unknown as UserModelType);
|
|
29
|
+
|
|
30
|
+
const plugin = new FeatureFlagsApp({
|
|
31
|
+
liveUpdates: {
|
|
32
|
+
eventName: options.eventName,
|
|
33
|
+
socketIoServer: () => options.io,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
plugin.register(app);
|
|
37
|
+
|
|
38
|
+
app.use(apiUnauthorizedMiddleware);
|
|
39
|
+
app.use(apiErrorMiddleware);
|
|
40
|
+
return app;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("FeatureFlagsApp live updates", () => {
|
|
44
|
+
let watchSpy: ReturnType<typeof spyOn>;
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
await setupDb();
|
|
48
|
+
await FeatureFlag.deleteMany({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await FeatureFlag.deleteMany({});
|
|
53
|
+
watchSpy?.mockRestore();
|
|
54
|
+
await OpenFeature.clearProviders();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("emits the default socket event and payload on change stream events", async () => {
|
|
58
|
+
const stream = new FakeChangeStream();
|
|
59
|
+
watchSpy = spyOn(FeatureFlag, "watch").mockReturnValue(stream as never);
|
|
60
|
+
|
|
61
|
+
const io = {emit: mock(() => {})};
|
|
62
|
+
buildAppWithLive({io});
|
|
63
|
+
|
|
64
|
+
stream.emit("change", {fullDocument: {key: "todo-summary-card"}});
|
|
65
|
+
|
|
66
|
+
expect(io.emit).toHaveBeenCalledWith("featureFlagsChanged", {key: "todo-summary-card"});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("respects a custom liveUpdates.eventName", async () => {
|
|
70
|
+
const stream = new FakeChangeStream();
|
|
71
|
+
watchSpy = spyOn(FeatureFlag, "watch").mockReturnValue(stream as never);
|
|
72
|
+
|
|
73
|
+
const io = {emit: mock(() => {})};
|
|
74
|
+
buildAppWithLive({eventName: "flagsUpdated", io});
|
|
75
|
+
|
|
76
|
+
stream.emit("change", {fullDocument: {key: "k"}});
|
|
77
|
+
|
|
78
|
+
expect(io.emit).toHaveBeenCalledWith("flagsUpdated", {key: "k"});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("logs a warn on stream error, retries once, then disables after a second error", async () => {
|
|
82
|
+
const warnSpy = spyOn(logger, "warn").mockImplementation(() => {});
|
|
83
|
+
|
|
84
|
+
const stream1 = new FakeChangeStream();
|
|
85
|
+
const stream2 = new FakeChangeStream();
|
|
86
|
+
let call = 0;
|
|
87
|
+
watchSpy = spyOn(FeatureFlag, "watch").mockImplementation(() => {
|
|
88
|
+
call += 1;
|
|
89
|
+
if (call === 1) {
|
|
90
|
+
return stream1 as never;
|
|
91
|
+
}
|
|
92
|
+
return stream2 as never;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const io = {emit: mock(() => {})};
|
|
96
|
+
buildAppWithLive({io});
|
|
97
|
+
|
|
98
|
+
stream1.emit("error", new Error("first"));
|
|
99
|
+
await new Promise<void>((resolve) => {
|
|
100
|
+
setTimeout(resolve, 0);
|
|
101
|
+
});
|
|
102
|
+
stream2.emit("error", new Error("second"));
|
|
103
|
+
await new Promise<void>((resolve) => {
|
|
104
|
+
setTimeout(resolve, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const disabledMsg = warnSpy.mock.calls.find(
|
|
108
|
+
(c) => typeof c[0] === "string" && c[0].includes("change stream disabled")
|
|
109
|
+
);
|
|
110
|
+
expect(disabledMsg).toBeDefined();
|
|
111
|
+
|
|
112
|
+
warnSpy.mockRestore();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {OpenFeature} from "@openfeature/server-sdk";
|
|
2
3
|
import {
|
|
3
4
|
addAuthRoutes,
|
|
4
5
|
apiErrorMiddleware,
|
|
@@ -43,6 +44,7 @@ describe("FeatureFlagsApp", () => {
|
|
|
43
44
|
|
|
44
45
|
afterEach(async () => {
|
|
45
46
|
await FeatureFlag.deleteMany({});
|
|
47
|
+
await OpenFeature.clearProviders();
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
describe("construction", () => {
|
|
@@ -187,6 +189,105 @@ describe("FeatureFlagsApp", () => {
|
|
|
187
189
|
// notAdmin isn't admin, rolloutPercentage is 0 → should evaluate false
|
|
188
190
|
expect(notAdminRes.body.data["user-only"]).toBe(false);
|
|
189
191
|
});
|
|
192
|
+
|
|
193
|
+
it("includes Deprecation and Sunset headers on GET /evaluate", async () => {
|
|
194
|
+
await FeatureFlag.create({
|
|
195
|
+
enabled: true,
|
|
196
|
+
key: "hdr-flag",
|
|
197
|
+
name: "Hdr",
|
|
198
|
+
rolloutPercentage: 100,
|
|
199
|
+
});
|
|
200
|
+
adminAgent = await authAsUser(app, "admin");
|
|
201
|
+
const res = await adminAgent.get("/feature-flags/evaluate").expect(200);
|
|
202
|
+
expect(res.headers.deprecation).toBe("true");
|
|
203
|
+
expect(res.headers.sunset).toBeDefined();
|
|
204
|
+
expect(res.body.data["hdr-flag"]).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("GET /feature-flags/flagConfiguration", () => {
|
|
209
|
+
beforeEach(async () => {
|
|
210
|
+
const segments: Record<string, SegmentFunction> = {
|
|
211
|
+
admins: (user: unknown) => (user as {admin?: boolean}).admin === true,
|
|
212
|
+
};
|
|
213
|
+
app = buildApp({segments});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns 401 when unauthenticated", async () => {
|
|
217
|
+
const unauth = supertest(app);
|
|
218
|
+
await unauth.get("/feature-flags/flagConfiguration").expect(401);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns OpenFeature-shaped entries for enabled flags and omits disabled and archived", async () => {
|
|
222
|
+
await FeatureFlag.create({
|
|
223
|
+
enabled: true,
|
|
224
|
+
key: "bool-active",
|
|
225
|
+
name: "Bool",
|
|
226
|
+
rolloutPercentage: 100,
|
|
227
|
+
type: "boolean",
|
|
228
|
+
});
|
|
229
|
+
await FeatureFlag.create({
|
|
230
|
+
defaultVariant: "compact",
|
|
231
|
+
enabled: true,
|
|
232
|
+
key: "profile",
|
|
233
|
+
name: "Profile",
|
|
234
|
+
rules: [],
|
|
235
|
+
type: "variant",
|
|
236
|
+
variants: [
|
|
237
|
+
{key: "compact", weight: 50},
|
|
238
|
+
{key: "detailed", weight: 50},
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
await FeatureFlag.create({
|
|
242
|
+
enabled: false,
|
|
243
|
+
key: "disabled-one",
|
|
244
|
+
name: "Off",
|
|
245
|
+
rolloutPercentage: 100,
|
|
246
|
+
type: "boolean",
|
|
247
|
+
});
|
|
248
|
+
await FeatureFlag.create({
|
|
249
|
+
archived: true,
|
|
250
|
+
enabled: true,
|
|
251
|
+
key: "archived-one",
|
|
252
|
+
name: "Archived",
|
|
253
|
+
rolloutPercentage: 100,
|
|
254
|
+
type: "boolean",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
adminAgent = await authAsUser(app, "admin");
|
|
258
|
+
const res = await adminAgent.get("/feature-flags/flagConfiguration").expect(200);
|
|
259
|
+
const data = res.body.data as Record<
|
|
260
|
+
string,
|
|
261
|
+
{defaultVariant: string; disabled: boolean; variants: Record<string, boolean | string>}
|
|
262
|
+
>;
|
|
263
|
+
|
|
264
|
+
expect(data["bool-active"]).toEqual({
|
|
265
|
+
defaultVariant: "on",
|
|
266
|
+
disabled: false,
|
|
267
|
+
variants: {off: false, on: true},
|
|
268
|
+
});
|
|
269
|
+
expect(data.profile?.disabled).toBe(false);
|
|
270
|
+
expect(["compact", "detailed"].includes(data.profile?.defaultVariant ?? "")).toBe(true);
|
|
271
|
+
expect(data.profile?.variants.compact).toBe("compact");
|
|
272
|
+
expect(data["disabled-one"]).toBeUndefined();
|
|
273
|
+
expect(data["archived-one"]).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("uses resolved boolean variant as defaultVariant (not the persisted schema default alone)", async () => {
|
|
277
|
+
await FeatureFlag.create({
|
|
278
|
+
defaultVariant: "off",
|
|
279
|
+
enabled: true,
|
|
280
|
+
key: "resolved-bool",
|
|
281
|
+
name: "Resolved",
|
|
282
|
+
rolloutPercentage: 100,
|
|
283
|
+
rules: [],
|
|
284
|
+
type: "boolean",
|
|
285
|
+
});
|
|
286
|
+
adminAgent = await authAsUser(app, "admin");
|
|
287
|
+
const res = await adminAgent.get("/feature-flags/flagConfiguration").expect(200);
|
|
288
|
+
expect(res.body.data["resolved-bool"].defaultVariant).toBe("on");
|
|
289
|
+
expect(res.body.data["resolved-bool"].variants.on).toBe(true);
|
|
290
|
+
});
|
|
190
291
|
});
|
|
191
292
|
|
|
192
293
|
describe("GET /feature-flags/segments", () => {
|