Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/service_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,17 @@ A test hook must:
* `stage` (string, optional): If executing a stage, for example `beforeEvaluation`, this should be the stage.
- Return data from the stages as specified via the `data` configuration. For instance the return value from the `beforeEvaluation` hook should be `data['beforeEvaluation']` merged with the input data for the stage.

#### Capability `"flag-change-listeners"`

This means that the SDK has support for flag change listeners and can notify the test harness when flags change.

The SDK must support registering listeners that monitor for flag changes. When a flag changes, the SDK test service should POST notification data to the callback URI provided during listener registration.

The test harness supports testing two types of listeners:
- **General flag change listeners**: Notified when any flag's configuration changes
- **Flag value change listeners**: Notified when a specific flag's evaluated value changes for a given context

For details on the commands and callback payloads, see the `registerFlagChangeListener`, `registerFlagValueChangeListener`, and `unregisterListener` commands.
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated

#### Capability `"tls:verify-peer"`

Expand Down
69 changes: 69 additions & 0 deletions mockld/listener_callback_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package mockld

import (
"encoding/json"
"io"
"net/http"

"github.com/launchdarkly/sdk-test-harness/v2/framework"
"github.com/launchdarkly/sdk-test-harness/v2/framework/harness"
"github.com/launchdarkly/sdk-test-harness/v2/servicedef"
)

// ListenerCallbackService is a mock HTTP server that receives flag change listener notifications
// POSTed by an SDK test service. Each registered listener should have its own instance so that
// notifications can be attributed to the correct listener in test assertions.
type ListenerCallbackService struct {
payloadEndpoint *harness.MockEndpoint
CallChannel chan servicedef.ListenerNotification
}

// GetURL returns the callback URI to provide when registering a listener. The SDK test service
// will POST a ListenerNotification JSON body to this URL when the listener fires.
func (l *ListenerCallbackService) GetURL() string {
return l.payloadEndpoint.BaseURL()
}

// Close shuts down the mock HTTP endpoint and releases its resources.
func (l *ListenerCallbackService) Close() {
l.payloadEndpoint.Close()
}

// NewListenerCallbackService creates a ListenerCallbackService with a mock HTTP endpoint
// ready to receive notifications. Call Close() when done.
func NewListenerCallbackService(
testHarness *harness.TestHarness,
logger framework.Logger,
) *ListenerCallbackService {
l := &ListenerCallbackService{
CallChannel: make(chan servicedef.ListenerNotification),
}

endpointHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
bytes, err := io.ReadAll(req.Body)
logger.Printf("Received listener notification: %s", string(bytes))
if err != nil {
logger.Printf("Could not read body from listener callback.")
w.WriteHeader(http.StatusBadRequest)
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Body logged before ReadAll error is checked

Low Severity

The result of io.ReadAll is passed to logger.Printf on line 44 before the err return value is checked on line 45. If the read fails, bytes may be nil or contain only partial data, yet it gets logged as if it were a complete notification. While string(nil) won't panic in Go, this produces misleading debug output. The events_service.go in the same package correctly checks the error before using the data.

Fix in Cursor Fix in Web

var notification servicedef.ListenerNotification
err = json.Unmarshal(bytes, &notification)
if err != nil {
logger.Printf("Could not unmarshal listener notification.")
w.WriteHeader(http.StatusBadRequest)
return
}

go func() {
l.CallChannel <- notification
}()

w.WriteHeader(http.StatusOK)
})

l.payloadEndpoint = testHarness.NewMockEndpoint(
endpointHandler, logger, harness.MockEndpointDescription("listener notification"))

return l
}
287 changes: 287 additions & 0 deletions sdktests/common_tests_listeners.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
package sdktests

import (
"github.com/launchdarkly/go-test-helpers/v2/jsonhelpers"

"github.com/launchdarkly/go-sdk-common/v3/ldattr"
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel"

"github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest"
"github.com/launchdarkly/sdk-test-harness/v2/mockld"
"github.com/launchdarkly/sdk-test-harness/v2/servicedef"
)

func doCommonListenerTests(t *ldtest.T) {
t.RequireCapability(servicedef.CapabilityFlagChangeListeners)
t.Run("flag change listener", doFlagChangeListenerTests)
t.Run("flag value change listener", doFlagValueChangeListenerTests)
}

func doFlagChangeListenerTests(t *ldtest.T) {
t.Run("receives notification when flag changes", flagChangeListenerReceivesNotification)
t.Run("fires on config change even when value unchanged", flagChangeListenerFiresOnConfigChange)
t.Run("filters by flag key", flagChangeListenerFiltersByFlagKey)
t.Run("with empty flag key receives all flag changes", flagChangeListenerEmptyKeyReceivesAllFlags)
Comment thread
aaron-zeisler marked this conversation as resolved.
Outdated
}

func doFlagValueChangeListenerTests(t *ldtest.T) {
t.Run("receives notification when value changes", flagValueChangeListenerReceivesNotification)
t.Run("does not notify when value is unchanged", flagValueChangeListenerNoNotificationWhenUnchanged)
t.Run("multiple listeners both receive notification", multipleValueListenersBothNotified)
t.Run("is context specific", valueListenerIsContextSpecific)
}

// makeListenerFlag builds a server-side feature flag for listener tests. The flag evaluates to
// value as its off-variation, so any context will receive that value.
func makeListenerFlag(key string, version int, value ldvalue.Value) ldmodel.FeatureFlag {
return ldbuilders.NewFlagBuilder(key).Version(version).
On(false).OffVariation(0).Variations(value, ldvalue.String("other")).Build()
}

// createClientForListeners sets up a client with two flags (flag1 and flag2) pre-loaded via
// streaming, both initially evaluating to "value1". Use dataSystem.Synchronizers[0].streaming
// to push flag updates and trigger listener notifications.
func createClientForListeners(t *ldtest.T) (*SDKClient, *SDKDataSystem) {
flag1 := makeListenerFlag("flag1", 1, ldvalue.String("value1"))
flag2 := makeListenerFlag("flag2", 1, ldvalue.String("value1"))
data := mockld.NewServerSDKDataBuilder().Flag(flag1, flag2).Build()

dataSystem := NewSDKDataSystem(t, data)
client := NewSDKClient(t, dataSystem)

return client, dataSystem
}

// pushFlagUpdate pushes a flag update through the streaming service and signals that the payload
// is complete. version must increase with each call; it is used as both the flag version and the
// payload-transferred sequence number.
func pushFlagUpdate(dataSystem *SDKDataSystem, key string, version int, value ldvalue.Value) {
flag := makeListenerFlag(key, version, value)

streaming := dataSystem.Synchronizers[0].streaming
streaming.PushUpdate("flag", key, version, jsonhelpers.ToJSON(flag))
streaming.PushPayloadTransferred("updated", version)
}
Comment thread
aaron-zeisler marked this conversation as resolved.

// --- Flag change listener tests ---

func flagChangeListenerReceivesNotification(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback.Close()

client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
CallbackURI: callback.GetURL(),
})

pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("new-value"))

callback.ExpectFlagChangeNotification(t, "flag1")
}

func flagChangeListenerFiresOnConfigChange(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback.Close()

client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
CallbackURI: callback.GetURL(),
})

// Push an update that changes the flag's version but not its evaluated value.
// The general flag change listener must fire regardless of value changes, because
// it tracks configuration changes (e.g. targeting rule edits), not just value changes.
pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("value1"))

callback.ExpectFlagChangeNotification(t, "flag1")
}

func flagChangeListenerEmptyKeyReceivesAllFlags(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback.Close()

// An empty FlagKey means the listener should receive changes for any flag.
client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "",
CallbackURI: callback.GetURL(),
})

// Update flag1 — listener should fire.
pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("new-value"))
callback.ExpectFlagChangeNotification(t, "flag1")

// Update flag2 — listener should fire again.
// Use version 3 so the payload-transferred sequence number also increments.
pushFlagUpdate(dataSystem, "flag2", 3, ldvalue.String("new-value"))
callback.ExpectFlagChangeNotification(t, "flag2")
}

func flagChangeListenerFiltersByFlagKey(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback.Close()

// Register listener only for flag1.
client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
CallbackURI: callback.GetURL(),
})

// Update flag2 — should NOT trigger the listener.
pushFlagUpdate(dataSystem, "flag2", 2, ldvalue.String("new-value"))
callback.ExpectNoNotification(t, "flag1")

// Update flag1 — SHOULD trigger the listener.
pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("another-value"))
callback.ExpectFlagChangeNotification(t, "flag1")
}

// --- Flag value change listener tests ---

func flagValueChangeListenerReceivesNotification(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

context := ldcontext.New("user-key")
oldValue := ldvalue.String("value1")
newValue := ldvalue.String("new-value")
defaultValue := ldvalue.String("default")

callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback.Close()

client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
Context: context,
DefaultValue: defaultValue,
CallbackURI: callback.GetURL(),
})

pushFlagUpdate(dataSystem, "flag1", 2, newValue)

callback.ExpectValueChangeNotification(t, "flag1", oldValue, newValue)
}

func flagValueChangeListenerNoNotificationWhenUnchanged(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

context := ldcontext.New("user-key")

callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback.Close()

client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
Context: context,
DefaultValue: ldvalue.String("default"),
CallbackURI: callback.GetURL(),
})

// Update flag1 with a new version but the same evaluated value — should NOT trigger notification.
pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("value1"))
callback.ExpectNoNotification(t, "flag1")
}

func multipleValueListenersBothNotified(t *ldtest.T) {
client, dataSystem := createClientForListeners(t)

context := ldcontext.New("user-key")
oldValue := ldvalue.String("value1")
newValue := ldvalue.String("new-value")
defaultValue := ldvalue.String("default")

callback1 := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback1.Close()
callback2 := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback2.Close()

// Register two independent listeners for the same flag and context.
client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
Context: context,
DefaultValue: defaultValue,
CallbackURI: callback1.GetURL(),
})
client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{
ListenerID: "listener-2",
FlagKey: "flag1",
Context: context,
DefaultValue: defaultValue,
CallbackURI: callback2.GetURL(),
})

pushFlagUpdate(dataSystem, "flag1", 2, newValue)

// Both listeners must receive the notification independently.
callback1.ExpectValueChangeNotification(t, "flag1", oldValue, newValue)
callback2.ExpectValueChangeNotification(t, "flag1", oldValue, newValue)
}

func valueListenerIsContextSpecific(t *ldtest.T) {
context1 := ldcontext.New("user-1")
context2 := ldcontext.New("user-2")
defaultValue := ldvalue.String("default")

// Initially both contexts see "value1" (flag is off, returns the same off-variation for all).
flag1 := makeListenerFlag("flag1", 1, ldvalue.String("value1"))
data := mockld.NewServerSDKDataBuilder().Flag(flag1).Build()
dataSystem := NewSDKDataSystem(t, data)
client := NewSDKClient(t, dataSystem)

callback1 := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback1.Close()
callback2 := NewListenerCallback(requireContext(t).harness, t.DebugLogger())
defer callback2.Close()

client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{
ListenerID: "listener-1",
FlagKey: "flag1",
Context: context1,
DefaultValue: defaultValue,
CallbackURI: callback1.GetURL(),
})
client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{
ListenerID: "listener-2",
FlagKey: "flag1",
Context: context2,
DefaultValue: defaultValue,
CallbackURI: callback2.GetURL(),
})

// Push an updated flag that returns "updated-value" for user-1 via a targeting rule,
// and "value1" (unchanged) for everyone else via the fallthrough.
updatedFlag := ldbuilders.NewFlagBuilder("flag1").Version(2).
On(true).
FallthroughVariation(0).
Variations(ldvalue.String("value1"), ldvalue.String("updated-value")).
AddRule(ldbuilders.NewRuleBuilder().ID("target-rule").Variation(1).Clauses(
ldbuilders.Clause(ldattr.KeyAttr, ldmodel.OperatorIn, ldvalue.String("user-1")),
)).
Build()

streaming := dataSystem.Synchronizers[0].streaming
streaming.PushUpdate("flag", "flag1", 2, jsonhelpers.ToJSON(updatedFlag))
streaming.PushPayloadTransferred("updated", 2)

// context1 (user-1): value changed from "value1" to "updated-value" → notification expected.
callback1.ExpectValueChangeNotification(t, "flag1", ldvalue.String("value1"), ldvalue.String("updated-value"))

// context2 (user-2): value unchanged ("value1" → "value1") → no notification expected.
callback2.ExpectNoNotification(t, "flag1")
}
Loading
Loading