| package featuregate_test |
|
|
| import ( |
| "context" |
| "errors" |
| "net/http" |
| "testing" |
|
|
| "github.com/stretchr/testify/assert" |
| "github.com/stretchr/testify/require" |
|
|
| "github.com/openmeterio/openmeter/pkg/featuregate" |
| "github.com/openmeterio/openmeter/pkg/framework/commonhttp" |
| "github.com/openmeterio/openmeter/pkg/framework/operation" |
| ) |
|
|
| |
| type stubGate struct { |
| result bool |
| err error |
| callCount int |
| } |
|
|
| func (s *stubGate) EvaluateBool(_, _ string, _ bool) (bool, error) { |
| s.callCount++ |
| return s.result, s.err |
| } |
|
|
| func TestFlags_Validate(t *testing.T) { |
| t.Parallel() |
|
|
| tests := []struct { |
| name string |
| flags featuregate.Flags |
| wantErr bool |
| }{ |
| { |
| name: "valid key", |
| flags: featuregate.Flags{featuregate.FeatureFlag("om_ff_credits_enabled"): "my-flag"}, |
| wantErr: false, |
| }, |
| { |
| name: "unknown key", |
| flags: featuregate.Flags{featuregate.FeatureFlag("unknown_key"): "my-flag"}, |
| wantErr: true, |
| }, |
| { |
| name: "empty flags", |
| flags: featuregate.Flags{}, |
| wantErr: true, |
| }, |
| } |
|
|
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| err := tc.flags.Validate() |
| if tc.wantErr { |
| require.Error(t, err) |
| } else { |
| require.NoError(t, err) |
| } |
| }) |
| } |
| } |
|
|
| func TestFeatureGateChecker_Enabled(t *testing.T) { |
| t.Parallel() |
|
|
| tests := []struct { |
| name string |
| gate featuregate.Gate |
| flag string |
| want bool |
| wantErr bool |
| }{ |
| { |
| name: "nil gate returns true", |
| gate: nil, |
| flag: "some-flag", |
| want: true, |
| }, |
| { |
| name: "empty flag returns true", |
| gate: &stubGate{result: false}, |
| flag: "", |
| want: true, |
| }, |
| { |
| name: "gate returns true", |
| gate: &stubGate{result: true}, |
| flag: "my-flag", |
| want: true, |
| }, |
| { |
| name: "gate returns false", |
| gate: &stubGate{result: false}, |
| flag: "my-flag", |
| want: false, |
| }, |
| { |
| name: "gate returns error", |
| gate: &stubGate{err: errors.New("gate error")}, |
| flag: "my-flag", |
| wantErr: true, |
| }, |
| } |
|
|
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| checker := featuregate.NewFeatureGateChecker(tc.gate, featuregate.Flags{}, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}) |
| got, err := checker.Enabled("test-ns", tc.flag) |
| if tc.wantErr { |
| require.Error(t, err) |
| return |
| } |
| require.NoError(t, err) |
| assert.Equal(t, tc.want, got) |
| }) |
| } |
| } |
|
|
| func TestFeatureGateChecker_Enabled_Caching(t *testing.T) { |
| t.Parallel() |
|
|
| gate := &stubGate{result: true} |
| checker := featuregate.NewFeatureGateChecker(gate, featuregate.Flags{}, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}) |
|
|
| |
| got, err := checker.Enabled("test-ns", "my-flag") |
| require.NoError(t, err) |
| assert.True(t, got) |
| assert.Equal(t, 1, gate.callCount) |
|
|
| |
| got, err = checker.Enabled("test-ns", "my-flag") |
| require.NoError(t, err) |
| assert.True(t, got) |
| assert.Equal(t, 1, gate.callCount, "gate should not be called again on cache hit") |
| } |
|
|
| func TestFeatureGateChecker_Validate(t *testing.T) { |
| t.Parallel() |
|
|
| t.Run("nil receiver", func(t *testing.T) { |
| var checker *featuregate.FeatureGateChecker |
| require.Error(t, checker.Validate()) |
| }) |
|
|
| t.Run("nil gate", func(t *testing.T) { |
| checker := featuregate.NewFeatureGateChecker(nil, featuregate.Flags{}, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}) |
| require.Error(t, checker.Validate()) |
| }) |
|
|
| t.Run("valid checker", func(t *testing.T) { |
| checker := featuregate.NewFeatureGateChecker( |
| &stubGate{}, |
| featuregate.Flags{featuregate.CtxKeyCredits: "val"}, |
| map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}, |
| ) |
| require.NoError(t, checker.Validate()) |
| }) |
| } |
|
|
| func TestNewMiddleware(t *testing.T) { |
| t.Parallel() |
|
|
| creditsKey := featuregate.FeatureFlag("om_ff_credits_enabled") |
| flags := featuregate.Flags{creditsKey: "credits-flag"} |
|
|
| t.Run("populates context with flag value", func(t *testing.T) { |
| gate := &stubGate{result: false} |
| checker := featuregate.NewFeatureGateChecker(gate, flags, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}) |
|
|
| getNS := func(ctx context.Context) (string, bool) { return "test-ns", true } |
|
|
| var capturedCtx context.Context |
| next := operation.Operation[string, string](func(ctx context.Context, _ string) (string, error) { |
| capturedCtx = ctx |
| return "ok", nil |
| }) |
|
|
| mw := featuregate.NewMiddleware[string, string](getNS, checker) |
| op := mw(next) |
|
|
| _, err := op(context.Background(), "req") |
| require.NoError(t, err) |
|
|
| creditEnabled := featuregate.ContextResolver().Credits(capturedCtx) |
| assert.False(t, creditEnabled) |
| }) |
|
|
| t.Run("returns 500 when namespace not found", func(t *testing.T) { |
| checker := featuregate.NewFeatureGateChecker(&stubGate{result: true}, flags, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}) |
|
|
| getNS := func(ctx context.Context) (string, bool) { return "", false } |
|
|
| next := operation.Operation[string, string](func(ctx context.Context, _ string) (string, error) { |
| return "ok", nil |
| }) |
|
|
| mw := featuregate.NewMiddleware[string, string](getNS, checker) |
| op := mw(next) |
|
|
| _, err := op(context.Background(), "req") |
| require.Error(t, err) |
|
|
| var httpErr commonhttp.ErrorWithHTTPStatusCode |
| require.ErrorAs(t, err, &httpErr) |
| assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) |
| }) |
|
|
| t.Run("propagates gate error", func(t *testing.T) { |
| gateErr := errors.New("gate unavailable") |
| checker := featuregate.NewFeatureGateChecker(&stubGate{err: gateErr}, flags, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}) |
|
|
| getNS := func(ctx context.Context) (string, bool) { return "test-ns", true } |
|
|
| called := false |
| next := operation.Operation[string, string](func(ctx context.Context, _ string) (string, error) { |
| called = true |
| return "ok", nil |
| }) |
|
|
| mw := featuregate.NewMiddleware[string, string](getNS, checker) |
| op := mw(next) |
|
|
| _, err := op(context.Background(), "req") |
| require.Error(t, err) |
| assert.False(t, called, "next should not be called when gate errors") |
| }) |
| } |
|
|
| func TestContextResolver_Credits(t *testing.T) { |
| t.Parallel() |
|
|
| creditsKey := featuregate.FeatureFlag("om_ff_credits_enabled") |
|
|
| t.Run("no value in context", func(t *testing.T) { |
| val := featuregate.ContextResolver().Credits(context.Background()) |
| assert.True(t, val) |
| }) |
|
|
| t.Run("context value true", func(t *testing.T) { |
| ctx := context.WithValue(context.Background(), creditsKey, true) |
| val := featuregate.ContextResolver().Credits(ctx) |
| assert.True(t, val) |
| }) |
|
|
| t.Run("context value false", func(t *testing.T) { |
| ctx := context.WithValue(context.Background(), creditsKey, false) |
| val := featuregate.ContextResolver().Credits(ctx) |
| assert.False(t, val) |
| }) |
| } |
|
|