Skip to content

Commit 9cc1e9d

Browse files
committed
Add client reset functionality for FSC CMAB test isolation
1 parent a6db9b5 commit 9cc1e9d

7 files changed

Lines changed: 269 additions & 0 deletions

File tree

pkg/handlers/reset.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package handlers //
18+
package handlers
19+
20+
import (
21+
"errors"
22+
"net/http"
23+
24+
"github.com/go-chi/render"
25+
26+
"github.com/optimizely/agent/pkg/middleware"
27+
)
28+
29+
// ResetClient handles the /v1/reset endpoint from FSC tests
30+
// This clears the client cache to ensure clean state between test scenarios,
31+
// particularly important for CMAB cache testing
32+
func ResetClient(w http.ResponseWriter, r *http.Request) {
33+
// Get SDK key from header
34+
sdkKey := r.Header.Get("X-Optimizely-SDK-Key")
35+
if sdkKey == "" {
36+
RenderError(errors.New("SDK key required for reset"), http.StatusBadRequest, w, r)
37+
return
38+
}
39+
40+
// Get the cache from context
41+
cache, err := middleware.GetOptlyCache(r)
42+
if err != nil {
43+
RenderError(errors.New("cache not available"), http.StatusInternalServerError, w, r)
44+
return
45+
}
46+
47+
// Get logger for debugging
48+
logger := middleware.GetLogger(r)
49+
logger.Debug().Str("sdkKey", sdkKey).Msg("Resetting client for FSC test")
50+
51+
// Reset the client using the cache interface
52+
if optlyCache, ok := cache.(interface{ ResetClient(string) }); ok {
53+
optlyCache.ResetClient(sdkKey)
54+
} else {
55+
RenderError(errors.New("cache reset not supported"), http.StatusInternalServerError, w, r)
56+
return
57+
}
58+
59+
// Return success
60+
render.JSON(w, r, map[string]interface{}{"result": true})
61+
}

pkg/handlers/reset_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package handlers //
18+
package handlers
19+
20+
import (
21+
"context"
22+
"net/http"
23+
"net/http/httptest"
24+
"testing"
25+
26+
"github.com/optimizely/agent/pkg/middleware"
27+
"github.com/optimizely/agent/pkg/optimizely"
28+
"github.com/optimizely/agent/pkg/optimizely/optimizelytest"
29+
30+
"github.com/go-chi/chi/v5"
31+
"github.com/stretchr/testify/mock"
32+
"github.com/stretchr/testify/suite"
33+
)
34+
35+
type MockCache struct {
36+
mock.Mock
37+
}
38+
39+
func (m *MockCache) GetClient(key string) (*optimizely.OptlyClient, error) {
40+
args := m.Called(key)
41+
return args.Get(0).(*optimizely.OptlyClient), args.Error(1)
42+
}
43+
44+
func (m *MockCache) UpdateConfigs(_ string) {
45+
}
46+
47+
func (m *MockCache) SetUserProfileService(sdkKey, userProfileService string) {
48+
m.Called(sdkKey, userProfileService)
49+
}
50+
51+
func (m *MockCache) SetODPCache(sdkKey, odpCache string) {
52+
m.Called(sdkKey, odpCache)
53+
}
54+
55+
func (m *MockCache) ResetClient(sdkKey string) {
56+
m.Called(sdkKey)
57+
}
58+
59+
type ResetTestSuite struct {
60+
suite.Suite
61+
oc *optimizely.OptlyClient
62+
tc *optimizelytest.TestClient
63+
mux *chi.Mux
64+
cache *MockCache
65+
}
66+
67+
func (suite *ResetTestSuite) ClientCtx(next http.Handler) http.Handler {
68+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+
ctx := context.WithValue(r.Context(), middleware.OptlyClientKey, suite.oc)
70+
ctx = context.WithValue(ctx, middleware.OptlyCacheKey, suite.cache)
71+
next.ServeHTTP(w, r.WithContext(ctx))
72+
})
73+
}
74+
75+
func (suite *ResetTestSuite) SetupTest() {
76+
testClient := optimizelytest.NewClient()
77+
suite.tc = testClient
78+
suite.oc = &optimizely.OptlyClient{OptimizelyClient: testClient.OptimizelyClient}
79+
80+
mockCache := new(MockCache)
81+
mockCache.On("ResetClient", "test-sdk-key").Return()
82+
suite.cache = mockCache
83+
84+
mux := chi.NewMux()
85+
mux.Use(suite.ClientCtx)
86+
mux.Post("/reset", ResetClient)
87+
suite.mux = mux
88+
}
89+
90+
func (suite *ResetTestSuite) TestResetClient() {
91+
req := httptest.NewRequest("POST", "/reset", nil)
92+
req.Header.Set("X-Optimizely-SDK-Key", "test-sdk-key")
93+
recorder := httptest.NewRecorder()
94+
95+
suite.mux.ServeHTTP(recorder, req)
96+
97+
suite.Equal(http.StatusOK, recorder.Code)
98+
suite.Contains(recorder.Header().Get("content-type"), "application/json")
99+
suite.Contains(recorder.Body.String(), `"result":true`)
100+
101+
// Verify ResetClient was called with correct SDK key
102+
suite.cache.AssertCalled(suite.T(), "ResetClient", "test-sdk-key")
103+
}
104+
105+
func (suite *ResetTestSuite) TestResetClientMissingSDKKey() {
106+
req := httptest.NewRequest("POST", "/reset", nil)
107+
recorder := httptest.NewRecorder()
108+
109+
suite.mux.ServeHTTP(recorder, req)
110+
111+
suite.Equal(http.StatusBadRequest, recorder.Code)
112+
suite.Contains(recorder.Body.String(), "SDK key required for reset")
113+
}
114+
115+
func (suite *ResetTestSuite) TestResetClientCacheNotAvailable() {
116+
// Create a context without cache
117+
req := httptest.NewRequest("POST", "/reset", nil)
118+
req.Header.Set("X-Optimizely-SDK-Key", "test-sdk-key")
119+
recorder := httptest.NewRecorder()
120+
121+
// Use middleware that doesn't include cache
122+
mux := chi.NewMux()
123+
mux.Use(func(next http.Handler) http.Handler {
124+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125+
ctx := context.WithValue(r.Context(), middleware.OptlyClientKey, suite.oc)
126+
// Note: no cache in context
127+
next.ServeHTTP(w, r.WithContext(ctx))
128+
})
129+
})
130+
mux.Post("/reset", ResetClient)
131+
132+
mux.ServeHTTP(recorder, req)
133+
134+
suite.Equal(http.StatusInternalServerError, recorder.Code)
135+
suite.Contains(recorder.Body.String(), "cache not available")
136+
}
137+
138+
func TestResetTestSuite(t *testing.T) {
139+
suite.Run(t, new(ResetTestSuite))
140+
}

pkg/middleware/cached.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const OptlyFeatureKey = contextKey("featureKey")
4141
// OptlyExperimentKey is the context key used by ExperimentCtx for setting an Experiment
4242
const OptlyExperimentKey = contextKey("experimentKey")
4343

44+
// OptlyCacheKey is the context key for the OptlyCache
45+
const OptlyCacheKey = contextKey("optlyCache")
46+
4447
// OptlySDKHeader is the header key for an ad-hoc SDK key
4548
const OptlySDKHeader = "X-Optimizely-SDK-Key"
4649

@@ -98,6 +101,7 @@ func (mw *CachedOptlyMiddleware) ClientCtx(next http.Handler) http.Handler {
98101
}
99102

100103
ctx := context.WithValue(r.Context(), OptlyClientKey, optlyClient)
104+
ctx = context.WithValue(ctx, OptlyCacheKey, mw.Cache)
101105
next.ServeHTTP(w, r.WithContext(ctx))
102106
})
103107
}

pkg/middleware/utils.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ func GetOptlyClient(r *http.Request) (*optimizely.OptlyClient, error) {
4747
return optlyClient, nil
4848
}
4949

50+
// GetOptlyCache is a utility to extract the OptlyCache from the http request context.
51+
func GetOptlyCache(r *http.Request) (optimizely.Cache, error) {
52+
cache, ok := r.Context().Value(OptlyCacheKey).(optimizely.Cache)
53+
if !ok || cache == nil {
54+
return nil, fmt.Errorf("optlyCache not available")
55+
}
56+
57+
return cache, nil
58+
}
59+
5060
// GetLogger gets the logger with some info coming from http request
5161
func GetLogger(r *http.Request) *zerolog.Logger {
5262
reqID := r.Header.Get(OptlyRequestHeader)

pkg/optimizely/cache.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,24 @@ func getServiceWithType(serviceType, sdkKey string, serviceMap cmap.ConcurrentMa
432432
}
433433
return nil
434434
}
435+
436+
// ResetClient removes the optimizely client from cache to ensure clean state for testing
437+
// This is primarily used by FSC tests to clear CMAB cache between test scenarios
438+
func (c *OptlyCache) ResetClient(sdkKey string) {
439+
// Remove the client from the cache
440+
if val, exists := c.optlyMap.Get(sdkKey); exists {
441+
c.optlyMap.Remove(sdkKey)
442+
443+
// Close the client to clean up resources
444+
if client, ok := val.(*OptlyClient); ok {
445+
client.Close()
446+
}
447+
448+
message := "Reset Optimizely client for testing"
449+
if ShouldIncludeSDKKey {
450+
log.Info().Str("sdkKey", sdkKey).Msg(message)
451+
} else {
452+
log.Info().Msg(message)
453+
}
454+
}
455+
}

pkg/optimizely/cache_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,33 @@ func (suite *CacheTestSuite) TestNilCreatorAddedforODPCache() {
310310
suite.Nil(odpCache)
311311
}
312312

313+
func (suite *CacheTestSuite) TestResetClient() {
314+
// First, get a client to put it in the cache
315+
client, err := suite.cache.GetClient("test-sdk-key")
316+
suite.NoError(err)
317+
suite.NotNil(client)
318+
319+
// Verify client is in cache
320+
cachedClient, exists := suite.cache.optlyMap.Get("test-sdk-key")
321+
suite.True(exists)
322+
suite.NotNil(cachedClient)
323+
324+
// Reset the client
325+
suite.cache.ResetClient("test-sdk-key")
326+
327+
// Verify client is removed from cache
328+
_, exists = suite.cache.optlyMap.Get("test-sdk-key")
329+
suite.False(exists)
330+
}
331+
332+
func (suite *CacheTestSuite) TestResetClientNonExistent() {
333+
// Reset a client that doesn't exist - should not panic
334+
suite.cache.ResetClient("non-existent-key")
335+
336+
// Verify no clients are in cache
337+
suite.Equal(0, suite.cache.optlyMap.Count())
338+
}
339+
313340
// In order for 'go test' to run this suite, we need to create
314341
// a normal test function and pass our suite to suite.Run
315342
func TestCacheTestSuite(t *testing.T) {

pkg/routers/api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type APIOptions struct {
4949
overrideHandler http.HandlerFunc
5050
lookupHandler http.HandlerFunc
5151
saveHandler http.HandlerFunc
52+
resetHandler http.HandlerFunc
5253
sendOdpEventHandler http.HandlerFunc
5354
nStreamHandler http.HandlerFunc
5455
oAuthHandler http.HandlerFunc
@@ -80,6 +81,7 @@ func NewDefaultAPIRouter(optlyCache optimizely.Cache, conf config.AgentConfig, m
8081
if !conf.API.EnableOverrides {
8182
overrideHandler = forbiddenHandler("Overrides not enabled")
8283
}
84+
resetHandler := handlers.ResetClient
8385

8486
nStreamHandler := forbiddenHandler("Notification stream not enabled")
8587
if conf.API.EnableNotifications {
@@ -102,6 +104,7 @@ func NewDefaultAPIRouter(optlyCache optimizely.Cache, conf config.AgentConfig, m
102104
overrideHandler: overrideHandler,
103105
lookupHandler: handlers.Lookup,
104106
saveHandler: handlers.Save,
107+
resetHandler: resetHandler,
105108
trackHandler: handlers.TrackEvent,
106109
sendOdpEventHandler: handlers.SendOdpEvent,
107110
sdkMiddleware: mw.ClientCtx,
@@ -131,6 +134,7 @@ func WithAPIRouter(opt *APIOptions, r chi.Router) {
131134
overrideTimer := middleware.Metricize("override", opt.metricsRegistry)
132135
lookupTimer := middleware.Metricize("lookup", opt.metricsRegistry)
133136
saveTimer := middleware.Metricize("save", opt.metricsRegistry)
137+
resetTimer := middleware.Metricize("reset", opt.metricsRegistry)
134138
trackTimer := middleware.Metricize("track-event", opt.metricsRegistry)
135139
sendOdpEventTimer := middleware.Metricize("send-odp-event", opt.metricsRegistry)
136140
createAccesstokenTimer := middleware.Metricize("create-api-access-token", opt.metricsRegistry)
@@ -144,6 +148,7 @@ func WithAPIRouter(opt *APIOptions, r chi.Router) {
144148
overrideTracer := middleware.AddTracing("overrideHandler", "Override")
145149
lookupTracer := middleware.AddTracing("lookupHandler", "Lookup")
146150
saveTracer := middleware.AddTracing("saveHandler", "Save")
151+
resetTracer := middleware.AddTracing("resetHandler", "Reset")
147152
sendOdpEventTracer := middleware.AddTracing("sendOdpEventHandler", "SendOdpEvent")
148153
nStreamTracer := middleware.AddTracing("notificationHandler", "SendNotificationEvent")
149154
authTracer := middleware.AddTracing("authHandler", "AuthToken")
@@ -164,6 +169,7 @@ func WithAPIRouter(opt *APIOptions, r chi.Router) {
164169
r.With(decideTimer, opt.oAuthMiddleware, contentTypeMiddleware, decideTracer).Post("/decide", opt.decideHandler)
165170
r.With(trackTimer, opt.oAuthMiddleware, contentTypeMiddleware, trackTracer).Post("/track", opt.trackHandler)
166171
r.With(overrideTimer, opt.oAuthMiddleware, contentTypeMiddleware, overrideTracer).Post("/override", opt.overrideHandler)
172+
r.With(resetTimer, opt.oAuthMiddleware, contentTypeMiddleware, resetTracer).Post("/reset", opt.resetHandler)
167173
r.With(lookupTimer, opt.oAuthMiddleware, contentTypeMiddleware, lookupTracer).Post("/lookup", opt.lookupHandler)
168174
r.With(saveTimer, opt.oAuthMiddleware, contentTypeMiddleware, saveTracer).Post("/save", opt.saveHandler)
169175
r.With(sendOdpEventTimer, opt.oAuthMiddleware, contentTypeMiddleware, sendOdpEventTracer).Post("/send-odp-event", opt.sendOdpEventHandler)

0 commit comments

Comments
 (0)