Skip to content

Commit

Permalink
chore(featureflags): add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
dhawal1248 committed Jan 17, 2025
1 parent 85bb407 commit 74a391e
Show file tree
Hide file tree
Showing 8 changed files with 453 additions and 112 deletions.
88 changes: 48 additions & 40 deletions featureflags/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,39 @@ type client interface {
SetDefaultTraits(traits map[string]string)
}

var (
ffclient client
clientOnce sync.Once
)
var ffclient client

// getFeatureFlagClient returns the singleton feature flag client instance
func getFeatureFlagClient() client {
clientOnce.Do(func() {
// read the api key from env vars and create the default cache config
apiKey := os.Getenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY")
if apiKey == "" {
panic("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY is not set")
}
defaultCacheConfig := cache.CacheConfig{
Enabled: true,
TTLInSeconds: 60,
}
initFeatureFlagClientOnce()
return ffclient
}

// create the provider
provider, err := provider.NewProvider(provider.ProviderConfig{
Type: "flagsmith",
ApiKey: apiKey,
})
if err != nil {
panic(err)
}
ffclient = &clientImpl{
provider: provider,
cache: cache.NewMemoryCache(defaultCacheConfig),
}
var initFeatureFlagClientOnce = sync.OnceFunc(initFeatureFlagClient)

func initFeatureFlagClient() {
// read the api key from env vars and create the default cache config
apiKey := os.Getenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY")
if apiKey == "" {
panic("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY is not set")
}
defaultCacheConfig := cache.CacheConfig{
Enabled: true,
TTLInSeconds: 60,
}

// create the provider
provider, err := provider.NewProvider(provider.ProviderConfig{
Type: "flagsmith",
ApiKey: apiKey,
})
return ffclient
if err != nil {
panic(err)
}
ffclient = &clientImpl{
provider: provider,
cache: cache.NewMemoryCache(defaultCacheConfig),
}
}

type clientImpl struct {
Expand All @@ -61,48 +62,40 @@ type clientImpl struct {
// IsFeatureEnabled checks if a feature is enabled for a workspace
// Note: Result may be stale if returned from cache. Use IsFeatureEnabledLatest if stale values are not acceptable.
func (c *clientImpl) IsFeatureEnabled(workspaceID string, feature string) (bool, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, false)
featureval, err := c.getFeatureValue(workspaceID, feature, false)
if err != nil {
return false, err
}
featureval, ok := ff[feature]
if !ok {
return false, newFeatureError(fmt.Sprintf("feature %s does not exist", feature))
}
return featureval.Enabled, nil
}

// IsFeatureEnabledLatest checks if a feature is enabled for a workspace, bypassing the cache
// Note: This method always fetches fresh values from the provider(bypassing the cache), which may impact performance.
func (c *clientImpl) IsFeatureEnabledLatest(workspaceID string, feature string) (bool, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, true)
featureval, err := c.getFeatureValue(workspaceID, feature, true)
if err != nil {
return false, err
}
return ff[feature].Enabled, nil
return featureval.Enabled, nil
}

// GetFeatureValue gets the value of a feature for a workspace
// Note: Result may be stale if returned from cache. Use GetFeatureValueLatest if stale values are not acceptable.
func (c *clientImpl) GetFeatureValue(workspaceID string, feature string) (provider.FeatureValue, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, false)
featureval, err := c.getFeatureValue(workspaceID, feature, false)
if err != nil {
return provider.FeatureValue{}, err
}
// create a copy of the feature value and return it
featureval := *ff[feature]
return featureval, nil
}

// GetFeatureValueLatest gets the value of a feature for a workspace, bypassing the cache
// Note: This method always fetches fresh values from the provider(bypassing the cache), which may impact performance.
func (c *clientImpl) GetFeatureValueLatest(workspaceID string, feature string) (provider.FeatureValue, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, true)
featureval, err := c.getFeatureValue(workspaceID, feature, true)
if err != nil {
return provider.FeatureValue{}, err
}
// create a copy of the feature value and return it
featureval := *ff[feature]
return featureval, nil
}

Expand Down Expand Up @@ -142,6 +135,21 @@ func (c *clientImpl) getAllFeatures(workspaceID string, traits map[string]string
return ff, nil
}

func (c *clientImpl) getFeatureValue(workspaceID string, feature string, skipCache bool) (provider.FeatureValue, error) {
ff, err := c.getAllFeatures(workspaceID, c.defaultTraits, skipCache)
if err != nil {
return provider.FeatureValue{}, err
}
featureval, ok := ff[feature]
if !ok {
return provider.FeatureValue{}, newFeatureError(fmt.Sprintf("feature %s does not exist", feature))
}
// create a copy of the feature value and return it
// return a copy since the feature value might be stored in a cache and should be immutable
featurevalCopy := *featureval
return featurevalCopy, nil
}

func (c *clientImpl) refreshFeatureFlags(workspaceID string) error {
// fetch the feature flags from the provider
ff, err := c.provider.GetFeatureFlags(provider.ProviderParams{
Expand Down
202 changes: 202 additions & 0 deletions featureflags/client_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,203 @@
package featureflags_test

import (
"os"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/rudderlabs/rudder-go-kit/featureflags"
"github.com/rudderlabs/rudder-go-kit/featureflags/cache"
"github.com/rudderlabs/rudder-go-kit/featureflags/provider"
)

func setupFeatureFlagClient() {
os.Setenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY", "test-key")
// call getFeatureFlagClient to initialize the client
// we will throw away the initialized client and set a custom client in the tests
featureflags.GetFeatureFlagClient()
os.Unsetenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY")
}

func TestGetFeatureFlagClient(t *testing.T) {

t.Run("panics when api key not set", func(t *testing.T) {
os.Unsetenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY")
defer func() {
r := recover()
require.NotNil(t, r)
require.Contains(t, r.(string), "FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY is not set")
featureflags.ResetFeatureFlagClient()
}()
featureflags.GetFeatureFlagClient()

})

t.Run("creates singleton client", func(t *testing.T) {
os.Setenv("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY", "test-key")

// Get client first time
client1 := featureflags.GetFeatureFlagClient()
require.NotNil(t, client1)

// Get client second time - should be same instance
client2 := featureflags.GetFeatureFlagClient()
require.NotNil(t, client2)
require.Equal(t, client1, client2)

featureflags.ResetFeatureFlagClient()

})
}

type mockProvider struct {
features map[string]*provider.FeatureValue
err error
}

func (m *mockProvider) GetFeatureFlags(params provider.ProviderParams) (map[string]*provider.FeatureValue, error) {
if m.err != nil {
return nil, m.err
}
return m.features, nil
}

func (m *mockProvider) Name() string {
return "mock"
}

func TestClientImpl_IsFeatureEnabled(t *testing.T) {

setupFeatureFlagClient()
currentTime := time.Now()
mockFeatures := map[string]*provider.FeatureValue{
"feature1": {
Enabled: true,
Value: "value1",
LastUpdatedAt: &currentTime,
},
"feature2": {
Enabled: false,
Value: "value2",
LastUpdatedAt: &currentTime,
},
}

mockCache := cache.NewMemoryCache(cache.CacheConfig{
Enabled: true,
TTLInSeconds: 60,
})

client := &featureflags.ClientImpl{
Provider: &mockProvider{features: mockFeatures},
Cache: mockCache,
}
featureflags.SetFeatureFlagClient(client)

t.Run("returns true for enabled feature", func(t *testing.T) {
enabled, err := featureflags.IsFeatureEnabled("workspace1", "feature1")
require.NoError(t, err)
require.True(t, enabled)
})

t.Run("returns false for disabled feature", func(t *testing.T) {
enabled, err := featureflags.IsFeatureEnabled("workspace1", "feature2")
require.NoError(t, err)
require.False(t, enabled)
})

t.Run("returns error for non-existent feature", func(t *testing.T) {
enabled, err := featureflags.IsFeatureEnabled("workspace1", "non-existent")
require.Error(t, err)
require.False(t, enabled)
})
}

func TestClientImpl_GetFeatureValue(t *testing.T) {
setupFeatureFlagClient()
currentTime := time.Now()
mockFeatures := map[string]*provider.FeatureValue{
"feature1": {
Enabled: true,
Value: "value1",
LastUpdatedAt: &currentTime,
},
}

mockCache := cache.NewMemoryCache(cache.CacheConfig{
Enabled: true,
TTLInSeconds: 60,
})

client := &featureflags.ClientImpl{
Provider: &mockProvider{features: mockFeatures},
Cache: mockCache,
}
featureflags.SetFeatureFlagClient(client)
t.Run("returns feature value", func(t *testing.T) {
value, err := featureflags.GetFeatureValue("workspace1", "feature1")
require.NoError(t, err)
require.Equal(t, "value1", value.Value)
require.True(t, value.Enabled)
})

t.Run("returns error for non-existent feature", func(t *testing.T) {
value, err := featureflags.GetFeatureValue("workspace1", "non-existent")
require.Error(t, err)
require.Empty(t, value)
})
}

type mockProviderWithGetFeatureFlagsFunc struct {
getFeatureFlagsFunc func(params provider.ProviderParams) (map[string]*provider.FeatureValue, error)
}

func (m *mockProviderWithGetFeatureFlagsFunc) GetFeatureFlags(params provider.ProviderParams) (map[string]*provider.FeatureValue, error) {
return m.getFeatureFlagsFunc(params)
}

func (m *mockProviderWithGetFeatureFlagsFunc) Name() string {
return "mock"
}

func TestClientImpl_SetDefaultTraits(t *testing.T) {
setupFeatureFlagClient()
var capturedParams provider.ProviderParams
mockProv := &mockProviderWithGetFeatureFlagsFunc{
getFeatureFlagsFunc: func(params provider.ProviderParams) (map[string]*provider.FeatureValue, error) {
capturedParams = params
return map[string]*provider.FeatureValue{
"feature1": {
Enabled: true,
Value: "value1",
},
}, nil
},
}
mockCache := cache.NewMemoryCache(cache.CacheConfig{
Enabled: false,
TTLInSeconds: 60,
})

client := &featureflags.ClientImpl{
Provider: mockProv,
Cache: mockCache,
}
featureflags.SetFeatureFlagClient(client)

traits := map[string]string{
"trait1": "value1",
"trait2": "value2",
}

// Set default traits
featureflags.SetDefaultTraits(traits)

// Call a method that should use the traits
_, err := featureflags.GetFeatureValue("workspace1", "feature1")
require.NoError(t, err)

// Verify the provider was called with the correct traits
require.Equal(t, traits, capturedParams.Traits)
}
35 changes: 35 additions & 0 deletions featureflags/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package featureflags

import (
"sync"

"github.com/rudderlabs/rudder-go-kit/featureflags/cache"
"github.com/rudderlabs/rudder-go-kit/featureflags/provider"
)

// This file is used to expose internal functions/types for testing purposes.
// These will only be available during testing.

// ClientImpl exposes the internal clientImpl type for testing
type ClientImpl struct {
Provider provider.Provider
Cache cache.Cache
DefaultTraits map[string]string
}

func SetFeatureFlagClient(c *ClientImpl) {
ffclient = &clientImpl{
provider: c.Provider,
cache: c.Cache,
defaultTraits: c.DefaultTraits,
}
}

// GetFeatureFlagClient exposes the internal getFeatureFlagClient function for testing
var GetFeatureFlagClient = getFeatureFlagClient

func ResetFeatureFlagClient() {
ffclient = nil
initFeatureFlagClientOnce = sync.OnceFunc(initFeatureFlagClient)
}

Loading

0 comments on commit 74a391e

Please sign in to comment.