Skip to content

Commit 80cce20

Browse files
test: Sticky resolve strategy base tests
1 parent b067ab4 commit 80cce20

File tree

1 file changed

+362
-0
lines changed

1 file changed

+362
-0
lines changed
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
package confidence
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"testing"
8+
9+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/proto/resolver"
10+
"github.com/tetratelabs/wazero"
11+
"google.golang.org/protobuf/types/known/structpb"
12+
)
13+
14+
// ============================================================================
15+
// Mock implementations for testing
16+
// ============================================================================
17+
18+
// mockResolverFallback implements ResolverFallback for testing
19+
type mockResolverFallback struct {
20+
resolveFunc func(ctx context.Context, request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error)
21+
closeCalled bool
22+
}
23+
24+
func (m *mockResolverFallback) Resolve(ctx context.Context, request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error) {
25+
if m.resolveFunc != nil {
26+
return m.resolveFunc(ctx, request)
27+
}
28+
return &resolver.ResolveFlagsResponse{}, nil
29+
}
30+
31+
func (m *mockResolverFallback) Close() {
32+
m.closeCalled = true
33+
}
34+
35+
// Compile-time check
36+
var _ ResolverFallback = (*mockResolverFallback)(nil)
37+
38+
// mockMaterializationRepository implements MaterializationRepository for testing
39+
type mockMaterializationRepository struct {
40+
mu sync.Mutex
41+
storage map[string]map[string]*MaterializationInfo // unit -> materialization -> info
42+
loadFunc func(ctx context.Context, unit, materialization string) (map[string]*MaterializationInfo, error)
43+
storeFunc func(ctx context.Context, unit string, assignments map[string]*MaterializationInfo) error
44+
loadCallCount int
45+
storeCallCount int
46+
closeCalled bool
47+
}
48+
49+
func newMockMaterializationRepository() *mockMaterializationRepository {
50+
return &mockMaterializationRepository{
51+
storage: make(map[string]map[string]*MaterializationInfo),
52+
}
53+
}
54+
55+
func (m *mockMaterializationRepository) LoadMaterializedAssignmentsForUnit(ctx context.Context, unit, materialization string) (map[string]*MaterializationInfo, error) {
56+
m.mu.Lock()
57+
defer m.mu.Unlock()
58+
m.loadCallCount++
59+
60+
if m.loadFunc != nil {
61+
return m.loadFunc(ctx, unit, materialization)
62+
}
63+
64+
result := make(map[string]*MaterializationInfo)
65+
if unitData, ok := m.storage[unit]; ok {
66+
for k, v := range unitData {
67+
result[k] = v
68+
}
69+
}
70+
return result, nil
71+
}
72+
73+
func (m *mockMaterializationRepository) StoreAssignment(ctx context.Context, unit string, assignments map[string]*MaterializationInfo) error {
74+
m.mu.Lock()
75+
defer m.mu.Unlock()
76+
m.storeCallCount++
77+
78+
if m.storeFunc != nil {
79+
return m.storeFunc(ctx, unit, assignments)
80+
}
81+
82+
if m.storage[unit] == nil {
83+
m.storage[unit] = make(map[string]*MaterializationInfo)
84+
}
85+
for k, v := range assignments {
86+
m.storage[unit][k] = v
87+
}
88+
return nil
89+
}
90+
91+
func (m *mockMaterializationRepository) Close() {
92+
m.closeCalled = true
93+
}
94+
95+
// Compile-time check
96+
var _ MaterializationRepository = (*mockMaterializationRepository)(nil)
97+
98+
// ============================================================================
99+
// Integration tests with SwapWasmResolverApi
100+
// ============================================================================
101+
102+
func TestSwapWasmResolverApi_WithResolverFallback_MissingMaterializations(t *testing.T) {
103+
ctx := context.Background()
104+
runtime := createTestWasmRuntime(ctx, t)
105+
defer runtime.Close(ctx)
106+
107+
// Create state with a flag that requires materializations
108+
stickyState := createStateWithStickyFlag()
109+
accountId := "test-account"
110+
111+
// Track if fallback was called
112+
fallbackCalled := false
113+
expectedResponse := &resolver.ResolveFlagsResponse{
114+
ResolvedFlags: []*resolver.ResolvedFlag{
115+
{
116+
Flag: "flags/sticky-test-flag",
117+
Variant: "flags/sticky-test-flag/variants/on",
118+
Value: &structpb.Struct{
119+
Fields: map[string]*structpb.Value{
120+
"enabled": structpb.NewBoolValue(true),
121+
},
122+
},
123+
},
124+
},
125+
}
126+
127+
fallback := &mockResolverFallback{
128+
resolveFunc: func(ctx context.Context, request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error) {
129+
fallbackCalled = true
130+
// Verify the request contains the expected flag
131+
if len(request.Flags) != 1 || request.Flags[0] != "flags/sticky-test-flag" {
132+
t.Errorf("Unexpected flags in fallback request: %v", request.Flags)
133+
}
134+
return expectedResponse, nil
135+
},
136+
}
137+
138+
flagLogger := NewNoOpWasmFlagLogger()
139+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testLogger(), fallback)
140+
if err != nil {
141+
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
142+
}
143+
defer swap.Close(ctx)
144+
145+
// Initialize with test state
146+
if err := swap.UpdateStateAndFlushLogs(stickyState, accountId); err != nil {
147+
t.Fatalf("Failed to initialize swap with state: %v", err)
148+
}
149+
150+
// Create request with fail-fast enabled (triggers fallback)
151+
stickyRequest := &resolver.ResolveWithStickyRequest{
152+
ResolveRequest: &resolver.ResolveFlagsRequest{
153+
Flags: []string{"flags/sticky-test-flag"},
154+
Apply: true,
155+
ClientSecret: "test-secret",
156+
EvaluationContext: &structpb.Struct{
157+
Fields: map[string]*structpb.Value{
158+
"user_id": structpb.NewStringValue("test-user-123"),
159+
},
160+
},
161+
},
162+
MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap),
163+
FailFastOnSticky: true,
164+
NotProcessSticky: false,
165+
}
166+
167+
response, err := swap.ResolveWithSticky(ctx, stickyRequest)
168+
if err != nil {
169+
t.Fatalf("Unexpected error: %v", err)
170+
}
171+
172+
if !fallbackCalled {
173+
t.Error("Expected fallback to be called when materializations are missing")
174+
}
175+
176+
if response == nil {
177+
t.Fatal("Expected non-nil response")
178+
}
179+
180+
if len(response.ResolvedFlags) != 1 {
181+
t.Errorf("Expected 1 resolved flag, got %d", len(response.ResolvedFlags))
182+
}
183+
}
184+
185+
func TestSwapWasmResolverApi_WithMaterializationRepository_LoadsAndRetries(t *testing.T) {
186+
ctx := context.Background()
187+
runtime := createTestWasmRuntime(ctx, t)
188+
defer runtime.Close(ctx)
189+
190+
// Create state with a flag that requires materializations
191+
stickyState := createStateWithStickyFlag()
192+
accountId := "test-account"
193+
194+
repo := newMockMaterializationRepository()
195+
// Pre-populate with materialization data
196+
repo.storage["test-user-123"] = map[string]*MaterializationInfo{
197+
"experiment_v1": {
198+
UnitInMaterialization: true,
199+
RuleToVariant: map[string]string{"flags/sticky-test-flag/rules/sticky-rule": "flags/sticky-test-flag/variants/on"},
200+
},
201+
}
202+
203+
flagLogger := NewNoOpWasmFlagLogger()
204+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testLogger(), repo)
205+
if err != nil {
206+
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
207+
}
208+
defer swap.Close(ctx)
209+
210+
// Initialize with test state
211+
if err := swap.UpdateStateAndFlushLogs(stickyState, accountId); err != nil {
212+
t.Fatalf("Failed to initialize swap with state: %v", err)
213+
}
214+
215+
// Create request without materializations - should trigger load from repo
216+
stickyRequest := &resolver.ResolveWithStickyRequest{
217+
ResolveRequest: &resolver.ResolveFlagsRequest{
218+
Flags: []string{"flags/sticky-test-flag"},
219+
Apply: true,
220+
ClientSecret: "test-secret",
221+
EvaluationContext: &structpb.Struct{
222+
Fields: map[string]*structpb.Value{
223+
"user_id": structpb.NewStringValue("test-user-123"),
224+
},
225+
},
226+
},
227+
MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap),
228+
FailFastOnSticky: false, // Don't fail fast, load from repo
229+
NotProcessSticky: false,
230+
}
231+
232+
response, err := swap.ResolveWithSticky(ctx, stickyRequest)
233+
if err != nil {
234+
t.Fatalf("Unexpected error: %v", err)
235+
}
236+
237+
// Verify repository was called to load materializations
238+
if repo.loadCallCount == 0 {
239+
t.Error("Expected repository LoadMaterializedAssignmentsForUnit to be called")
240+
}
241+
242+
if response == nil {
243+
t.Fatal("Expected non-nil response")
244+
}
245+
246+
// The flag should be resolved with the variant from the materialization
247+
if len(response.ResolvedFlags) != 1 {
248+
t.Errorf("Expected 1 resolved flag, got %d", len(response.ResolvedFlags))
249+
}
250+
251+
if response.ResolvedFlags[0].Variant != "flags/sticky-test-flag/variants/on" {
252+
t.Errorf("Expected variant 'flags/sticky-test-flag/variants/on', got '%s'", response.ResolvedFlags[0].Variant)
253+
}
254+
}
255+
256+
func TestSwapWasmResolverApi_NoStrategy_MissingMaterializations_ReturnsError(t *testing.T) {
257+
ctx := context.Background()
258+
runtime := createTestWasmRuntime(ctx, t)
259+
defer runtime.Close(ctx)
260+
261+
// Create state with a flag that requires materializations
262+
stickyState := createStateWithStickyFlag()
263+
accountId := "test-account"
264+
265+
// No sticky strategy configured
266+
flagLogger := NewNoOpWasmFlagLogger()
267+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testLogger(), nil)
268+
if err != nil {
269+
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
270+
}
271+
defer swap.Close(ctx)
272+
273+
// Initialize with test state
274+
if err := swap.UpdateStateAndFlushLogs(stickyState, accountId); err != nil {
275+
t.Fatalf("Failed to initialize swap with state: %v", err)
276+
}
277+
278+
stickyRequest := &resolver.ResolveWithStickyRequest{
279+
ResolveRequest: &resolver.ResolveFlagsRequest{
280+
Flags: []string{"flags/sticky-test-flag"},
281+
Apply: true,
282+
ClientSecret: "test-secret",
283+
EvaluationContext: &structpb.Struct{
284+
Fields: map[string]*structpb.Value{
285+
"user_id": structpb.NewStringValue("test-user-123"),
286+
},
287+
},
288+
},
289+
MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap),
290+
FailFastOnSticky: true,
291+
NotProcessSticky: false,
292+
}
293+
294+
_, err = swap.ResolveWithSticky(ctx, stickyRequest)
295+
if err == nil {
296+
t.Fatal("Expected error when no sticky strategy and materializations missing")
297+
}
298+
299+
expectedErrMsg := "missing materializations and no sticky resolve strategy configured"
300+
if err.Error() != expectedErrMsg {
301+
t.Errorf("Expected error message '%s', got '%s'", expectedErrMsg, err.Error())
302+
}
303+
}
304+
305+
func TestSwapWasmResolverApi_ResolverFallback_Error(t *testing.T) {
306+
ctx := context.Background()
307+
runtime := createTestWasmRuntime(ctx, t)
308+
defer runtime.Close(ctx)
309+
310+
// Create state with a flag that requires materializations
311+
stickyState := createStateWithStickyFlag()
312+
accountId := "test-account"
313+
314+
expectedError := errors.New("fallback service unavailable")
315+
fallback := &mockResolverFallback{
316+
resolveFunc: func(ctx context.Context, request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error) {
317+
return nil, expectedError
318+
},
319+
}
320+
321+
flagLogger := NewNoOpWasmFlagLogger()
322+
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testLogger(), fallback)
323+
if err != nil {
324+
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
325+
}
326+
defer swap.Close(ctx)
327+
328+
// Initialize with test state
329+
if err := swap.UpdateStateAndFlushLogs(stickyState, accountId); err != nil {
330+
t.Fatalf("Failed to initialize swap with state: %v", err)
331+
}
332+
333+
stickyRequest := &resolver.ResolveWithStickyRequest{
334+
ResolveRequest: &resolver.ResolveFlagsRequest{
335+
Flags: []string{"flags/sticky-test-flag"},
336+
Apply: true,
337+
ClientSecret: "test-secret",
338+
EvaluationContext: &structpb.Struct{
339+
Fields: map[string]*structpb.Value{
340+
"user_id": structpb.NewStringValue("test-user-123"),
341+
},
342+
},
343+
},
344+
MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap),
345+
FailFastOnSticky: true,
346+
NotProcessSticky: false,
347+
}
348+
349+
_, err = swap.ResolveWithSticky(ctx, stickyRequest)
350+
if err == nil {
351+
t.Fatal("Expected error when fallback fails")
352+
}
353+
354+
if !errors.Is(err, expectedError) {
355+
t.Errorf("Expected error to contain '%v', got '%v'", expectedError, err)
356+
}
357+
}
358+
359+
// Helper to create test WASM runtime
360+
func createTestWasmRuntime(ctx context.Context, t *testing.T) wazero.Runtime {
361+
return wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig())
362+
}

0 commit comments

Comments
 (0)