diff --git a/openfeature-provider/go/confidence/local_resolver_provider.go b/openfeature-provider/go/confidence/local_resolver_provider.go index 513c4b17..96f1433c 100644 --- a/openfeature-provider/go/confidence/local_resolver_provider.go +++ b/openfeature-provider/go/confidence/local_resolver_provider.go @@ -221,8 +221,16 @@ func (p *LocalResolverProvider) ObjectEvaluation( // Get resolver API from factory resolverAPI := p.factory.GetSwapResolverAPI() - // Resolve flags - response, err := resolverAPI.Resolve(request) + // Create ResolveWithSticky request + stickyRequest := &resolver.ResolveWithStickyRequest{ + ResolveRequest: request, + MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap), + FailFastOnSticky: true, + NotProcessSticky: false, + } + + // Resolve flags with sticky support + stickyResponse, err := resolverAPI.ResolveWithSticky(stickyRequest) if err != nil { log.Printf("Failed to resolve flag '%s': %v", flagPath, err) return openfeature.InterfaceResolutionDetail{ @@ -234,6 +242,31 @@ func (p *LocalResolverProvider) ObjectEvaluation( } } + // Extract the actual resolve response from the sticky response + var response *resolver.ResolveFlagsResponse + switch result := stickyResponse.ResolveResult.(type) { + case *resolver.ResolveWithStickyResponse_Success_: + response = result.Success.Response + case *resolver.ResolveWithStickyResponse_MissingMaterializations_: + log.Printf("Missing materializations for flag '%s'", flagPath) + return openfeature.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + ResolutionError: openfeature.NewGeneralResolutionError("missing materializations"), + }, + } + default: + log.Printf("Unexpected resolve result type for flag '%s'", flagPath) + return openfeature.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + ResolutionError: openfeature.NewGeneralResolutionError("unexpected resolve result"), + }, + } + } + // Check if flag was found if len(response.ResolvedFlags) == 0 { log.Printf("No active flag '%s' was found", flagPath) diff --git a/openfeature-provider/go/confidence/local_resolver_provider_resolve_test.go b/openfeature-provider/go/confidence/local_resolver_provider_resolve_test.go new file mode 100644 index 00000000..b90a126c --- /dev/null +++ b/openfeature-provider/go/confidence/local_resolver_provider_resolve_test.go @@ -0,0 +1,243 @@ +package confidence + +import ( + "context" + "testing" + + "github.com/open-feature/go-sdk/openfeature" + adminv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/proto/confidence/flags/admin/v1" + iamv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/proto/confidence/iam/v1" + "github.com/tetratelabs/wazero" + "google.golang.org/protobuf/proto" +) + +func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) { + ctx := context.Background() + runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig()) + defer runtime.Close(ctx) + + // Create minimal state with wrong client secret + state := &adminv1.ResolverState{ + Flags: []*adminv1.Flag{}, + ClientCredentials: []*iamv1.ClientCredential{ + { + Credential: &iamv1.ClientCredential_ClientSecret_{ + ClientSecret: &iamv1.ClientCredential_ClientSecret{ + Secret: "wrong-secret", + }, + }, + }, + }, + } + stateBytes, _ := proto.Marshal(state) + + flagLogger := NewNoOpWasmFlagLogger() + swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, stateBytes, "test-account") + if err != nil { + t.Fatalf("Failed to create SwapWasmResolverApi: %v", err) + } + defer swap.Close(ctx) + + factory := &LocalResolverFactory{ + resolverAPI: swap, + } + + // Use different client secret that won't match + provider := NewLocalResolverProvider(factory, "test-secret") + + evalCtx := openfeature.FlattenedContext{ + "user_id": "test-user", + } + + t.Run("StringEvaluation returns default on error", func(t *testing.T) { + defaultValue := "default-value" + result := provider.StringEvaluation(ctx, "non-existent-flag.field", defaultValue, evalCtx) + + if result.Value != defaultValue { + t.Errorf("Expected default value %v, got %v", defaultValue, result.Value) + } + + if result.Reason != openfeature.ErrorReason { + t.Errorf("Expected ErrorReason, got %v", result.Reason) + } + + if result.ResolutionError.Error() == "" { + t.Error("Expected ResolutionError to not be empty") + } + + t.Logf("✓ StringEvaluation correctly returned default value: %s", defaultValue) + }) +} + +func TestLocalResolverProvider_ReturnsCorrectValue(t *testing.T) { + ctx := context.Background() + runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig()) + defer runtime.Close(ctx) + + // Load real test state + testState := loadTestResolverState(t) + testAcctID := loadTestAccountID(t) + + flagLogger := NewNoOpWasmFlagLogger() + swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testState, testAcctID) + if err != nil { + t.Fatalf("Failed to create SwapWasmResolverApi: %v", err) + } + defer swap.Close(ctx) + + factory := &LocalResolverFactory{ + resolverAPI: swap, + } + + // Use the correct client secret from test data + provider := NewLocalResolverProvider(factory, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF") + + evalCtx := openfeature.FlattenedContext{ + "visitor_id": "tutorial_visitor", + } + + t.Run("StringEvaluation returns correct variant value", func(t *testing.T) { + defaultValue := "default-message" + result := provider.StringEvaluation(ctx, "tutorial-feature.message", defaultValue, evalCtx) + + // The exciting-welcome variant has a specific message + expectedMessage := "We are very excited to welcome you to Confidence! This is a message from the tutorial flag." + + if result.Value != expectedMessage { + t.Errorf("Expected value '%s', got '%s'", expectedMessage, result.Value) + } + + if result.Reason != openfeature.TargetingMatchReason { + t.Errorf("Expected TargetingMatchReason, got %v", result.Reason) + } + + if result.ResolutionError.Error() != "" { + t.Errorf("Expected no error, got %v", result.ResolutionError) + } + }) + + t.Run("ObjectEvaluation returns correct variant structure", func(t *testing.T) { + defaultValue := map[string]interface{}{ + "message": "default", + "title": "default", + } + result := provider.ObjectEvaluation(ctx, "tutorial-feature", defaultValue, evalCtx) + + if result.Value == nil { + t.Fatal("Expected result value to not be nil") + } + + resultMap, ok := result.Value.(map[string]interface{}) + if !ok { + t.Fatalf("Expected result value to be a map, got %T", result.Value) + } + + expectedMessage := "We are very excited to welcome you to Confidence! This is a message from the tutorial flag." + expectedTitle := "Welcome to Confidence!" + + if resultMap["message"] != expectedMessage { + t.Errorf("Expected message '%s', got '%v'", expectedMessage, resultMap["message"]) + } + + if resultMap["title"] != expectedTitle { + t.Errorf("Expected title '%s', got '%v'", expectedTitle, resultMap["title"]) + } + + if result.Reason != openfeature.TargetingMatchReason { + t.Errorf("Expected TargetingMatchReason, got %v", result.Reason) + } + + if result.ResolutionError.Error() != "" { + t.Errorf("Expected no error, got %v", result.ResolutionError) + } + }) +} + +func TestLocalResolverProvider_MissingMaterializations(t *testing.T) { + ctx := context.Background() + runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig()) + defer runtime.Close(ctx) + + t.Run("Provider returns resolved value for flag without sticky rules", func(t *testing.T) { + // Load real test state + testState := loadTestResolverState(t) + testAcctID := loadTestAccountID(t) + + flagLogger := NewNoOpWasmFlagLogger() + swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testState, testAcctID) + if err != nil { + t.Fatalf("Failed to create SwapWasmResolverApi: %v", err) + } + defer swap.Close(ctx) + + factory := &LocalResolverFactory{ + resolverAPI: swap, + } + + provider := NewLocalResolverProvider(factory, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF") + + evalCtx := openfeature.FlattenedContext{ + "visitor_id": "tutorial_visitor", + } + + // The tutorial-feature flag in the test data doesn't have materialization requirements + // so resolving with empty materializations should succeed + defaultValue := "default" + result := provider.StringEvaluation(ctx, "tutorial-feature.message", defaultValue, evalCtx) + + if result.ResolutionError.Error() != "" { + t.Errorf("Expected successful resolve for flag without sticky rules, got error: %v", result.ResolutionError) + } + + if result.Value == defaultValue { + t.Error("Expected resolved value, got default value") + } + + if result.Reason != openfeature.TargetingMatchReason { + t.Errorf("Expected TargetingMatchReason, got %v", result.Reason) + } + }) + + t.Run("Provider returns missing materializations error message", func(t *testing.T) { + // Create state with a flag that requires materializations + stickyState := createStateWithStickyFlag() + accountId := "test-account" + + flagLogger := NewNoOpWasmFlagLogger() + swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, stickyState, accountId) + if err != nil { + t.Fatalf("Failed to create SwapWasmResolverApi: %v", err) + } + defer swap.Close(ctx) + + factory := &LocalResolverFactory{ + resolverAPI: swap, + } + + provider := NewLocalResolverProvider(factory, "test-secret") + + evalCtx := openfeature.FlattenedContext{ + "user_id": "test-user-123", + } + + defaultValue := false + result := provider.BooleanEvaluation(ctx, "sticky-test-flag.enabled", defaultValue, evalCtx) + + if result.Value != defaultValue { + t.Errorf("Expected default value %v when materializations missing, got %v", defaultValue, result.Value) + } + + if result.Reason != openfeature.ErrorReason { + t.Errorf("Expected ErrorReason when materializations missing, got %v", result.Reason) + } + + if result.ResolutionError.Error() == "" { + t.Error("Expected ResolutionError when materializations missing") + } + + expectedErrorMsg := "missing materializations" + if result.ResolutionError.Error() != "GENERAL: missing materializations" { + t.Errorf("Expected error message 'GENERAL: %s', got: %v", expectedErrorMsg, result.ResolutionError) + } + }) +} diff --git a/openfeature-provider/go/confidence/resolver_api.go b/openfeature-provider/go/confidence/resolver_api.go index f39c06e5..9e3c6b86 100644 --- a/openfeature-provider/go/confidence/resolver_api.go +++ b/openfeature-provider/go/confidence/resolver_api.go @@ -27,12 +27,11 @@ type ResolverApi struct { runtime wazero.Runtime // WASM exports - wasmMsgAlloc api.Function - wasmMsgFree api.Function - wasmMsgGuestSetResolverState api.Function - wasmMsgGuestFlushLogs api.Function - wasmMsgGuestResolve api.Function - wasmMsgGuestResolveSimple api.Function + wasmMsgAlloc api.Function + wasmMsgFree api.Function + wasmMsgGuestSetResolverState api.Function + wasmMsgGuestFlushLogs api.Function + wasmMsgGuestResolveWithSticky api.Function // Flag logger for writing logs flagLogger WasmFlagLogger @@ -117,23 +116,23 @@ func NewResolverApiFromCompiled(ctx context.Context, runtime wazero.Runtime, com wasmMsgFree := instance.ExportedFunction("wasm_msg_free") wasmMsgGuestSetResolverState := instance.ExportedFunction("wasm_msg_guest_set_resolver_state") wasmMsgGuestFlushLogs := instance.ExportedFunction("wasm_msg_guest_flush_logs") - wasmMsgGuestResolve := instance.ExportedFunction("wasm_msg_guest_resolve") + wasmMsgGuestResolveWithSticky := instance.ExportedFunction("wasm_msg_guest_resolve_with_sticky") - if wasmMsgAlloc == nil || wasmMsgFree == nil || wasmMsgGuestSetResolverState == nil || wasmMsgGuestFlushLogs == nil || wasmMsgGuestResolve == nil { + if wasmMsgAlloc == nil || wasmMsgFree == nil || wasmMsgGuestSetResolverState == nil || wasmMsgGuestFlushLogs == nil || wasmMsgGuestResolveWithSticky == nil { panic("Required WASM exports not found") } return &ResolverApi{ - instance: instance, - module: compiledModule, - runtime: runtime, - wasmMsgAlloc: wasmMsgAlloc, - wasmMsgFree: wasmMsgFree, - wasmMsgGuestSetResolverState: wasmMsgGuestSetResolverState, - wasmMsgGuestFlushLogs: wasmMsgGuestFlushLogs, - wasmMsgGuestResolve: wasmMsgGuestResolve, - flagLogger: flagLogger, - firstResolve: true, + instance: instance, + module: compiledModule, + runtime: runtime, + wasmMsgAlloc: wasmMsgAlloc, + wasmMsgFree: wasmMsgFree, + wasmMsgGuestSetResolverState: wasmMsgGuestSetResolverState, + wasmMsgGuestFlushLogs: wasmMsgGuestFlushLogs, + wasmMsgGuestResolveWithSticky: wasmMsgGuestResolveWithSticky, + flagLogger: flagLogger, + firstResolve: true, } } @@ -240,8 +239,8 @@ func (r *ResolverApi) SetResolverState(state []byte, accountId string) error { // ErrInstanceClosed is returned when the WASM instance is being closed/swapped var ErrInstanceClosed = errors.New("WASM instance is closed or being replaced") -// Resolve resolves flags using the WASM module -func (r *ResolverApi) Resolve(request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error) { +// ResolveWithSticky resolves flags with sticky targeting support using the WASM module +func (r *ResolverApi) ResolveWithSticky(request *resolver.ResolveWithStickyRequest) (*resolver.ResolveWithStickyResponse, error) { // Acquire lock first, then check isClosing flag to prevent race condition // where instance could be marked as closing between check and lock acquisition. // If closing, return immediately with ErrInstanceClosed to prevent using stale instance. @@ -257,17 +256,17 @@ func (r *ResolverApi) Resolve(request *resolver.ResolveFlagsRequest) (*resolver. reqPtr := r.transferRequest(request) // Call the WASM function - results, err := r.wasmMsgGuestResolve.Call(ctx, uint64(reqPtr)) + results, err := r.wasmMsgGuestResolveWithSticky.Call(ctx, uint64(reqPtr)) if err != nil { - return nil, fmt.Errorf("failed to call wasm_msg_guest_resolve: %w", err) + return nil, fmt.Errorf("failed to call wasm_msg_guest_resolve_with_sticky: %w", err) } // Consume the response respPtr := uint32(results[0]) - response := &resolver.ResolveFlagsResponse{} + response := &resolver.ResolveWithStickyResponse{} err = r.consumeResponse(respPtr, response) if err != nil { - log.Printf("Resolve failed with error: %v", err) + log.Printf("ResolveWithSticky failed with error: %v", err) return nil, err } diff --git a/openfeature-provider/go/confidence/swap_wasm_resolver_api.go b/openfeature-provider/go/confidence/swap_wasm_resolver_api.go index a27cc476..cd0ce292 100644 --- a/openfeature-provider/go/confidence/swap_wasm_resolver_api.go +++ b/openfeature-provider/go/confidence/swap_wasm_resolver_api.go @@ -78,16 +78,15 @@ func (s *SwapWasmResolverApi) UpdateStateAndFlushLogs(state []byte, accountId st oldInstance.Close(ctx) return nil } - -func (s *SwapWasmResolverApi) Resolve(request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error) { +func (s *SwapWasmResolverApi) ResolveWithSticky(request *resolver.ResolveWithStickyRequest) (*resolver.ResolveWithStickyResponse, error) { // Lock to ensure resolve doesn't happen during swap instance := s.currentInstance.Load().(*ResolverApi) - response, err := instance.Resolve(request) + response, err := instance.ResolveWithSticky(request) // If instance is closed, retry with the current instance (which may have been swapped) if err != nil && errors.Is(err, ErrInstanceClosed) { instance = s.currentInstance.Load().(*ResolverApi) - return instance.Resolve(request) + return instance.ResolveWithSticky(request) } return response, err diff --git a/openfeature-provider/go/confidence/swap_wasm_resolver_api_test.go b/openfeature-provider/go/confidence/swap_wasm_resolver_api_test.go index 5a118e24..9ffe5319 100644 --- a/openfeature-provider/go/confidence/swap_wasm_resolver_api_test.go +++ b/openfeature-provider/go/confidence/swap_wasm_resolver_api_test.go @@ -16,7 +16,6 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -// Helper to load test data from the data directory func loadTestResolverState(t *testing.T) []byte { dataPath := filepath.Join("..", "..", "..", "data", "resolver_state_current.pb") data, err := os.ReadFile(dataPath) @@ -37,10 +36,19 @@ func loadTestAccountID(t *testing.T) string { // Helper function to create minimal valid resolver state for testing func createMinimalResolverState() []byte { + clientName := "clients/test-client" + credentialName := "clients/test-client/credentials/test-credential" + state := &adminv1.ResolverState{ Flags: []*adminv1.Flag{}, + Clients: []*iamv1.Client{ + { + Name: clientName, + }, + }, ClientCredentials: []*iamv1.ClientCredential{ { + Name: credentialName, // Must start with client name Credential: &iamv1.ClientCredential_ClientSecret_{ ClientSecret: &iamv1.ClientCredential_ClientSecret{ Secret: "test-secret", @@ -56,6 +64,132 @@ func createMinimalResolverState() []byte { return data } +// Helper to create a resolver state with a flag that requires materializations +func createStateWithStickyFlag() []byte { + state := &adminv1.ResolverState{ + Flags: []*adminv1.Flag{ + { + Name: "flags/sticky-test-flag", + Variants: []*adminv1.Flag_Variant{ + { + Name: "flags/sticky-test-flag/variants/on", + Value: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "enabled": structpb.NewBoolValue(true), + }, + }, + }, + { + Name: "flags/sticky-test-flag/variants/off", + Value: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "enabled": structpb.NewBoolValue(false), + }, + }, + }, + }, + State: adminv1.Flag_ACTIVE, + // Associate this flag with the test client + Clients: []string{"clients/test-client"}, + Rules: []*adminv1.Flag_Rule{ + { + Name: "flags/sticky-test-flag/rules/sticky-rule", + Segment: "segments/always-true", + TargetingKeySelector: "user_id", + Enabled: true, + AssignmentSpec: &adminv1.Flag_Rule_AssignmentSpec{ + BucketCount: 10000, + Assignments: []*adminv1.Flag_Rule_Assignment{ + { + AssignmentId: "variant-assignment", + Assignment: &adminv1.Flag_Rule_Assignment_Variant{ + Variant: &adminv1.Flag_Rule_Assignment_VariantAssignment{ + Variant: "flags/sticky-test-flag/variants/on", + }, + }, + BucketRanges: []*adminv1.Flag_Rule_BucketRange{ + { + Upper: 10000, + }, + }, + }, + }, + }, + // This rule requires a materialization named "experiment_v1" + MaterializationSpec: &adminv1.Flag_Rule_MaterializationSpec{ + ReadMaterialization: "experiment_v1", + WriteMaterialization: "experiment_v1", + Mode: &adminv1.Flag_Rule_MaterializationSpec_MaterializationReadMode{ + MaterializationMustMatch: false, + SegmentTargetingCanBeIgnored: false, + }, + }, + }, + }, + }, + }, + SegmentsNoBitsets: []*adminv1.Segment{ + { + Name: "segments/always-true", + // Empty segment - may not match any users + }, + }, + Clients: []*iamv1.Client{ + { + Name: "clients/test-client", + }, + }, + ClientCredentials: []*iamv1.ClientCredential{ + { + // ClientCredential name must start with the client name + Name: "clients/test-client/credentials/test-credential", + Credential: &iamv1.ClientCredential_ClientSecret_{ + ClientSecret: &iamv1.ClientCredential_ClientSecret{ + Secret: "test-secret", + }, + }, + }, + }, + } + data, err := proto.Marshal(state) + if err != nil { + panic("Failed to create state with sticky flag: " + err.Error()) + } + return data +} + +// Helper function to create a ResolveWithStickyRequest +func createResolveWithStickyRequest( + resolveRequest *resolver.ResolveFlagsRequest, + materializations map[string]*resolver.MaterializationMap, + failFast bool, + notProcessSticky bool, +) *resolver.ResolveWithStickyRequest { + if materializations == nil { + materializations = make(map[string]*resolver.MaterializationMap) + } + return &resolver.ResolveWithStickyRequest{ + ResolveRequest: resolveRequest, + MaterializationsPerUnit: materializations, + FailFastOnSticky: failFast, + NotProcessSticky: notProcessSticky, + } +} + +// Helper function to create a tutorial-feature resolve request with standard test data +func createTutorialFeatureRequest() *resolver.ResolveFlagsRequest { + return &resolver.ResolveFlagsRequest{ + Flags: []string{"flags/tutorial-feature"}, + Apply: true, + ClientSecret: "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", + EvaluationContext: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "visitor_id": structpb.NewStringValue("tutorial_visitor"), + }, + }, + } +} + func TestSwapWasmResolverApi_NewSwapWasmResolverApi(t *testing.T) { ctx := context.Background() runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig()) @@ -123,29 +257,27 @@ func TestSwapWasmResolverApi_WithRealState(t *testing.T) { } defer swap.Close(ctx) - // Resolve the tutorial-feature flag using the real client secret from the state - // The state includes client secret: mkjJruAATQWjeY7foFIWfVAcBWnci2YF - // Use "tutorial_visitor" as the visitor_id to match the segment targeting - request := &resolver.ResolveFlagsRequest{ - Flags: []string{"flags/tutorial-feature"}, - Apply: false, - ClientSecret: "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", - EvaluationContext: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "visitor_id": structpb.NewStringValue("tutorial_visitor"), - }, - }, - } + request := createResolveWithStickyRequest( + createTutorialFeatureRequest(), + nil, // empty materializations + true, // failFast + false, // notProcessSticky + ) - response, err := swap.Resolve(request) + stickyResponse, err := swap.ResolveWithSticky(request) if err != nil { t.Fatalf("Unexpected error resolving tutorial-feature flag: %v", err) } - if response == nil { + if stickyResponse == nil { t.Fatal("Expected non-nil response") } + response := stickyResponse.GetSuccess().GetResponse() + if response == nil { + t.Fatal("Expected successful resolve response") + } + if len(response.ResolvedFlags) != 1 { t.Fatalf("Expected 1 resolved flag, got %d", len(response.ResolvedFlags)) } @@ -225,22 +357,23 @@ func TestSwapWasmResolverApi_UpdateStateAndFlushLogs(t *testing.T) { } // Verify that we can successfully resolve after the state update - request := &resolver.ResolveFlagsRequest{ - Flags: []string{"flags/tutorial-feature"}, - Apply: false, - ClientSecret: "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", - EvaluationContext: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "visitor_id": structpb.NewStringValue("tutorial_visitor"), - }, - }, - } - - response, err := swap.Resolve(request) + request := createResolveWithStickyRequest( + createTutorialFeatureRequest(), + nil, // empty materializations + true, // failFast + false, // notProcessSticky + ) + + stickyResponse, err := swap.ResolveWithSticky(request) if err != nil { t.Fatalf("Resolve failed after update: %v", err) } + response := stickyResponse.GetSuccess().GetResponse() + if response == nil { + t.Fatal("Expected successful resolve response") + } + // Verify we got the expected resolution if len(response.ResolvedFlags) != 1 { t.Errorf("Expected 1 resolved flag, got %d", len(response.ResolvedFlags)) @@ -279,22 +412,23 @@ func TestSwapWasmResolverApi_MultipleUpdates(t *testing.T) { } // Verify that Resolve successfully works after each update - request := &resolver.ResolveFlagsRequest{ - Flags: []string{"flags/tutorial-feature"}, - Apply: false, - ClientSecret: "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", - EvaluationContext: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "visitor_id": structpb.NewStringValue("tutorial_visitor"), - }, - }, - } - - response, resolveErr := swap.Resolve(request) + request := createResolveWithStickyRequest( + createTutorialFeatureRequest(), + nil, // empty materializations + true, // failFast + false, // notProcessSticky + ) + + stickyResponse, resolveErr := swap.ResolveWithSticky(request) if resolveErr != nil { t.Fatalf("Update %d: Resolve failed: %v", i, resolveErr) } + response := stickyResponse.GetSuccess().GetResponse() + if response == nil { + t.Fatalf("Update %d: Expected successful resolve response", i) + } + // Verify we got the expected variant after each swap if len(response.ResolvedFlags) != 1 { t.Errorf("Update %d: Expected 1 resolved flag, got %d", i, len(response.ResolvedFlags)) @@ -338,3 +472,137 @@ func TestErrInstanceClosed(t *testing.T) { t.Error("Expected errors.Is to work with ErrInstanceClosed") } } + +// State from data sample, flag without sticky rules +func TestSwapWasmResolverApi_ResolveFlagWithNoStickyRules(t *testing.T) { + ctx := context.Background() + runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig()) + defer runtime.Close(ctx) + + flagLogger := NewNoOpWasmFlagLogger() + testState := loadTestResolverState(t) + testAcctID := loadTestAccountID(t) + + wasmResolver, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testState, testAcctID) + if err != nil { + t.Fatalf("Failed to create SwapWasmResolverApi with sample state: %v", err) + } + defer wasmResolver.Close(ctx) + + stickyRequest := createResolveWithStickyRequest( + createTutorialFeatureRequest(), + nil, // empty materializations + true, // failFast + false, // notProcessSticky + ) + + response, err := wasmResolver.ResolveWithSticky(stickyRequest) + if err != nil { + t.Fatalf("Unexpected error resolving tutorial-feature flag with sticky: %v", err) + } + + if response == nil { + t.Fatal("Expected non-nil response") + } + + successResult, ok := response.ResolveResult.(*resolver.ResolveWithStickyResponse_Success_) + if !ok { + t.Fatal("Expected success result from ResolveWithSticky") + } + + resolveResponse := successResult.Success.Response + if len(resolveResponse.ResolvedFlags) != 1 { + t.Fatalf("Expected 1 resolved flag, got %d", len(resolveResponse.ResolvedFlags)) + } + + resolvedFlag := resolveResponse.ResolvedFlags[0] + + if resolvedFlag.Flag != "flags/tutorial-feature" { + t.Errorf("Expected flag 'flags/tutorial-feature', got '%s'", resolvedFlag.Flag) + } + + expectedVariant := "flags/tutorial-feature/variants/exciting-welcome" + if resolvedFlag.Variant != expectedVariant { + t.Errorf("Expected variant '%s', got '%s'", expectedVariant, resolvedFlag.Variant) + } + + if resolvedFlag.Reason.String() != "RESOLVE_REASON_MATCH" { + t.Errorf("Expected reason RESOLVE_REASON_MATCH, got %v", resolvedFlag.Reason) + } + + if resolvedFlag.Value == nil { + t.Fatal("Expected non-nil value in resolved flag") + } + + fields := resolvedFlag.Value.GetFields() + if fields == nil { + t.Fatal("Expected fields in resolved value") + } + + expectedMessage := "We are very excited to welcome you to Confidence! This is a message from the tutorial flag." + messageValue, hasMessage := fields["message"] + if !hasMessage { + t.Error("Expected 'message' field in resolved value") + } else if messageValue.GetStringValue() != expectedMessage { + t.Errorf("Expected message '%s', got '%s'", expectedMessage, messageValue.GetStringValue()) + } + + expectedTitle := "Welcome to Confidence!" + titleValue, hasTitle := fields["title"] + if !hasTitle { + t.Error("Expected 'title' field in resolved value") + } else if titleValue.GetStringValue() != expectedTitle { + t.Errorf("Expected title '%s', got '%s'", expectedTitle, titleValue.GetStringValue()) + } +} + +func TestSwapWasmResolverApi_ResolveFlagWithStickyRules_MissingMaterializations(t *testing.T) { + ctx := context.Background() + runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig()) + defer runtime.Close(ctx) + + flagLogger := NewNoOpWasmFlagLogger() + stickyState := createStateWithStickyFlag() + accountId := "test-account" + + swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, stickyState, accountId) + if err != nil { + t.Fatalf("Failed to create SwapWasmResolverApi: %v", err) + } + defer swap.Close(ctx) + + stickyRequest := createResolveWithStickyRequest( + &resolver.ResolveFlagsRequest{ + Flags: []string{"flags/sticky-test-flag"}, + Apply: true, + ClientSecret: "test-secret", + EvaluationContext: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "user_id": structpb.NewStringValue("test-user-123"), + }, + }, + }, + nil, // empty materializations - missing the required "experiment_v1" materialization + true, // failFast + false, // notProcessSticky + ) + + response, err := swap.ResolveWithSticky(stickyRequest) + if err != nil { + t.Fatalf("Unexpected error from ResolveWithSticky: %v", err) + } + + if response == nil { + t.Fatal("Expected non-nil response") + } + + // The response should be a MissingMaterializations result, not Success + missingResult, ok := response.ResolveResult.(*resolver.ResolveWithStickyResponse_MissingMaterializations_) + if !ok { + t.Fatal("Expected MissingMaterializations result, got Success or other type") + } + + if missingResult.MissingMaterializations == nil { + t.Fatal("Expected non-nil MissingMaterializations") + } +} diff --git a/wasm/go-host/resolver_api.go b/wasm/go-host/resolver_api.go index 7ade18b3..b5b8554c 100644 --- a/wasm/go-host/resolver_api.go +++ b/wasm/go-host/resolver_api.go @@ -24,11 +24,12 @@ type ResolverApi struct { runtime wazero.Runtime // WASM exports - wasmMsgAlloc api.Function - wasmMsgFree api.Function - wasmMsgGuestSetResolverState api.Function - wasmMsgGuestResolve api.Function - wasmMsgGuestResolveSimple api.Function + wasmMsgAlloc api.Function + wasmMsgFree api.Function + wasmMsgGuestSetResolverState api.Function + wasmMsgGuestResolve api.Function + wasmMsgGuestResolveWithSticky api.Function + wasmMsgGuestResolveSimple api.Function } // NewResolverApi creates a new ResolverApi instance @@ -73,19 +74,21 @@ func NewResolverApi(ctx context.Context, runtime wazero.Runtime, wasmBytes []byt wasmMsgFree := instance.ExportedFunction("wasm_msg_free") wasmMsgGuestSetResolverState := instance.ExportedFunction("wasm_msg_guest_set_resolver_state") wasmMsgGuestResolve := instance.ExportedFunction("wasm_msg_guest_resolve") + wasmMsgGuestResolveWithSticky := instance.ExportedFunction("wasm_msg_guest_resolve_with_sticky") - if wasmMsgAlloc == nil || wasmMsgFree == nil || wasmMsgGuestSetResolverState == nil || wasmMsgGuestResolve == nil { + if wasmMsgAlloc == nil || wasmMsgFree == nil || wasmMsgGuestSetResolverState == nil || wasmMsgGuestResolve == nil || wasmMsgGuestResolveWithSticky == nil { panic("Required WASM exports not found") } return &ResolverApi{ - instance: instance, - module: module, - runtime: runtime, - wasmMsgAlloc: wasmMsgAlloc, - wasmMsgFree: wasmMsgFree, - wasmMsgGuestSetResolverState: wasmMsgGuestSetResolverState, - wasmMsgGuestResolve: wasmMsgGuestResolve, + instance: instance, + module: module, + runtime: runtime, + wasmMsgAlloc: wasmMsgAlloc, + wasmMsgFree: wasmMsgFree, + wasmMsgGuestSetResolverState: wasmMsgGuestSetResolverState, + wasmMsgGuestResolve: wasmMsgGuestResolve, + wasmMsgGuestResolveWithSticky: wasmMsgGuestResolveWithSticky, } }