Skip to content

Commit

Permalink
feat: Add hooks contract tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
kinyoklion committed Mar 14, 2024
1 parent 3052945 commit 9211849
Show file tree
Hide file tree
Showing 8 changed files with 494 additions and 0 deletions.
27 changes: 27 additions & 0 deletions docs/service_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ and will send a `?filter=name` query parameter along with streaming/polling requ
For tests that involve filtering, the test harness will set the `filter` property of the `streaming` or `polling` configuration
object. The property will either be omitted if no filter is requested, or a non-empty string if requested.

### Capability `"evaluation-hooks"`

This means that the SDK has support for hooks and has the ability to register evaluation hooks.

For a test service to support hooks testing it must support a `test-hook`. The configuration will specify registering one or more `test-hooks`.

A test hook must:
- Implement the SDK hook interface.
- Whenever an evaluation stage is called post information about that call to the `callbackUrl` of the hook.
- The payload is an object with the following properties:
* `evaluationHookContext` (object, optional): If an evaluation stage was executed, then this should be the associated context.
* `flagKey` (string, required): The key of the flag being evaluated.
* `context` (object, required): The evaluation context associated with the evaluation.
* `defaultValue` (any): The default value for the evaluation.
* `method` (string, required): The name of the evaluation emthod that was called.
* `evaluationHookData` (object, optional): The EvaluationHookData passed to the stage during execution.
* `evaluationDetail` (object, optional): The details of the evaluation if executing an `afterEvaluation` stage.
* `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.

### Stop test service: `DELETE /`

The test harness sends this request at the end of a test run if you have specified `--stop-service-at-end` on the [command line](./running.md). The test service should simply quit. This is a convenience so CI scripts can simply start the test service in the background and assume it will be stopped for them.
Expand Down Expand Up @@ -164,6 +184,13 @@ A `POST` request indicates that the test harness wants to start an instance of t
* `initialContext` (object, optional): The context properties to initialize the SDK with (unless `initialUser` is specified instead). The test service for a client-side SDK can assume that the test harness will _always_ set this: if the test logic does not explicitly provide a value, the test harness will add a default one.
* `initialUser` (object, optional): Can be specified instead of `initialContext` to use an old-style user JSON representation.
* `evaluationReasons`, `useReport` (boolean, optional): These correspond to the SDK configuration properties of the same names.
* `hooks` (object, optional): If specified this has the configuration for hooks.
* `hooks` (array, required): Contains configuration of one or more hooks, each item is an object with the following parameters.
* `name` (string, required): A name to associate with the hook.
* `callbackUri` (string, required): A callback URL that the hook should post data to.
* `data` (object, optional): Contains data which should return from different execution stages.
* `beforeEvaluation` (object, optional): A map of `string` to `ldvalue` items. This should be returned from the `beforeEvaluation` stage of the test hook.
* `afterEvaluation` (object, optional): A map of `string` to `ldvalue` items. This should be returned from the `afterEvaluation` stage of the test hook.

The response to a valid request is any HTTP `2xx` status, with a `Location` header whose value is the URL of the test service resource representing this SDK client instance (that is, the one that would be used for "Close client" or "Send command" as described below).

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

import (
"encoding/json"
"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"
"io"
"net/http"
)

type HookCallbackService struct {
payloadEndpoint *harness.MockEndpoint
CallChannel chan servicedef.HookExecutionPayload
stopChannel chan struct{}
}

func (h *HookCallbackService) GetURL() string {
return h.payloadEndpoint.BaseURL()
}

func (h *HookCallbackService) Close() {
h.payloadEndpoint.Close()
}

func NewHookCallbackService(
testHarness *harness.TestHarness,
logger framework.Logger,
) *HookCallbackService {
h := &HookCallbackService{
CallChannel: make(chan servicedef.HookExecutionPayload),
stopChannel: make(chan struct{}),
}

endpointHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
bytes, err := io.ReadAll(req.Body)
logger.Printf("Received from hook: %s", string(bytes))
if err != nil {
return
}
var response servicedef.HookExecutionPayload
err = json.Unmarshal(bytes, &response)
if err == nil {
h.CallChannel <- response
}

w.WriteHeader(http.StatusOK)
})

h.payloadEndpoint = testHarness.NewMockEndpoint(endpointHandler, logger, harness.MockEndpointDescription("hook payload"))

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

import (
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/go-sdk-common/v3/ldmigration"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders"
"github.com/launchdarkly/sdk-test-harness/v2/data"
"github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest"
o "github.com/launchdarkly/sdk-test-harness/v2/framework/opt"
"github.com/launchdarkly/sdk-test-harness/v2/mockld"
"github.com/launchdarkly/sdk-test-harness/v2/servicedef"
"github.com/stretchr/testify/assert"
"time"
)

func doServerSideHooksTests(t *ldtest.T) {
t.RequireCapability(servicedef.CapabilityEvaluationHooks)
t.Run("executes beforeEvaluation stage", executesBeforeEvaluationStage)
t.Run("executes afterEvaluation stage", executesAfterEvaluationStage)
t.Run("data propagates from before to after", beforeEvaluationDataPropagatesToAfter)
t.Run("data propagates from before to after for migrations", beforeEvaluationDataPropagatesToAfterMigration)
}

func executesBeforeEvaluationStage(t *ldtest.T) {
t.Run("without detail", func(t *ldtest.T) { executesBeforeEvaluationStageDetail(t, false) })
t.Run("with detail", func(t *ldtest.T) { executesBeforeEvaluationStageDetail(t, true) })
t.Run("for migrations", executesBeforeEvaluationStageMigration)
}

func executesAfterEvaluationStage(t *ldtest.T) {
t.Run("without detail", func(t *ldtest.T) { executesAfterEvaluationStageDetail(t, false) })
t.Run("with detail", func(t *ldtest.T) { executesAfterEvaluationStageDetail(t, true) })
t.Run("for migrations", executesAfterEvaluationStageMigration)
}

func beforeEvaluationDataPropagatesToAfter(t *ldtest.T) {
t.Run("without detail", func(t *ldtest.T) { beforeEvaluationDataPropagatesToAfterDetail(t, false) })
t.Run("with detail", func(t *ldtest.T) { beforeEvaluationDataPropagatesToAfterDetail(t, true) })
}

type VariationParameters struct {
name string
flagKey string
defaultValue ldvalue.Value
valueType servicedef.ValueType
detail bool
}

func variationTestParams(detail bool) []VariationParameters {
return []VariationParameters{{
name: "for boolean variation",
flagKey: "bool-flag",
defaultValue: ldvalue.Bool(false),
valueType: servicedef.ValueTypeBool,
detail: detail,
},
{
name: "for string variation",
flagKey: "string-flag",
defaultValue: ldvalue.String("default"),
valueType: servicedef.ValueTypeString,
detail: detail,
},
{
name: "for double variation",
flagKey: "number-flag",
defaultValue: ldvalue.Float64(3.14),
valueType: servicedef.ValueTypeDouble,
detail: detail,
},
{
name: "for int variation",
flagKey: "number-flag",
defaultValue: ldvalue.Int(0xDEADBEEF),
valueType: servicedef.ValueTypeInt,
detail: detail,
},
{
name: "for json variation",
flagKey: "json-flag",
defaultValue: ldvalue.ObjectBuild().Build(),
valueType: servicedef.ValueTypeInt,
detail: detail,
},
}
}

func executesBeforeEvaluationStageDetail(t *ldtest.T, detail bool) {
testParams := variationTestParams(detail)

hookName := "executesBeforeEvaluationStage"
client, hooks := createClientForHooks(t, []string{hookName}, nil)
defer hooks.Close()

for _, testParam := range testParams {
t.Run(testParam.name, func(t *ldtest.T) {
client.EvaluateFlag(t, servicedef.EvaluateFlagParams{
FlagKey: testParam.flagKey,
Context: o.Some(ldcontext.New("user-key")),
ValueType: testParam.valueType,
DefaultValue: testParam.defaultValue,
})

hooks.ExpectCall(t, hookName, 1*time.Second, func(payload servicedef.HookExecutionPayload) bool {
if payload.Stage.Value() == servicedef.BeforeEvaluation {
hookContext := payload.EvaluationHookContext.Value()
assert.Equal(t, testParam.flagKey, hookContext.FlagKey)
assert.Equal(t, ldcontext.New("user-key"), hookContext.Context)
assert.Equal(t, testParam.defaultValue, hookContext.DefaultValue)
return true
}
return false
})
})
}
}

func executesBeforeEvaluationStageMigration(t *ldtest.T) {
hookName := "executesBeforeEvaluationStageMigration"
client, hooks := createClientForHooks(t, []string{hookName}, nil)
defer hooks.Close()

flagKey := "migration-flag"
params := servicedef.MigrationVariationParams{
Key: flagKey,
Context: ldcontext.New("user-key"),
DefaultStage: ldmigration.Off,
}
client.MigrationVariation(t, params)

hooks.ExpectCall(t, hookName, 1*time.Second, func(payload servicedef.HookExecutionPayload) bool {
if payload.Stage.Value() == servicedef.BeforeEvaluation {
hookContext := payload.EvaluationHookContext.Value()
assert.Equal(t, flagKey, hookContext.FlagKey)
assert.Equal(t, ldcontext.New("user-key"), hookContext.Context)
assert.Equal(t, ldvalue.String(string(ldmigration.Off)), hookContext.DefaultValue)
return true
}
return false
})
}

func executesAfterEvaluationStageDetail(t *ldtest.T, detail bool) {
testParams := variationTestParams(detail)

hookName := "executesAfterEvaluationStage"
client, hooks := createClientForHooks(t, []string{hookName}, nil)
defer hooks.Close()

for _, testParam := range testParams {
t.Run(testParam.name, func(t *ldtest.T) {
result := client.EvaluateFlag(t, servicedef.EvaluateFlagParams{
FlagKey: testParam.flagKey,
Context: o.Some(ldcontext.New("user-key")),
ValueType: testParam.valueType,
DefaultValue: testParam.defaultValue,
Detail: detail,
})

hooks.ExpectCall(t, hookName, 1*time.Second, func(payload servicedef.HookExecutionPayload) bool {
if payload.Stage.Value() == servicedef.AfterEvaluation {
hookContext := payload.EvaluationHookContext.Value()
assert.Equal(t, testParam.flagKey, hookContext.FlagKey)
assert.Equal(t, ldcontext.New("user-key"), hookContext.Context)
assert.Equal(t, testParam.defaultValue, hookContext.DefaultValue)
evaluationDetail := payload.EvaluationDetail.Value()
assert.Equal(t, result.Value, evaluationDetail.Value)
if detail {
assert.Equal(t, result.VariationIndex, evaluationDetail.VariationIndex)
assert.Equal(t, result.Reason, evaluationDetail.Reason)
}
return true
}
return false
})
})
}
}

func executesAfterEvaluationStageMigration(t *ldtest.T) {
hookName := "executesBeforeEvaluationStageMigration"
client, hooks := createClientForHooks(t, []string{hookName}, nil)
defer hooks.Close()

flagKey := "migration-flag"
params := servicedef.MigrationVariationParams{
Key: flagKey,
Context: ldcontext.New("user-key"),
DefaultStage: ldmigration.Off,
}
result := client.MigrationVariation(t, params)

hooks.ExpectCall(t, hookName, 1*time.Second, func(payload servicedef.HookExecutionPayload) bool {
if payload.Stage.Value() == servicedef.AfterEvaluation {
hookContext := payload.EvaluationHookContext.Value()
assert.Equal(t, flagKey, hookContext.FlagKey)
assert.Equal(t, ldcontext.New("user-key"), hookContext.Context)
assert.Equal(t, ldvalue.String(string(ldmigration.Off)), hookContext.DefaultValue)
evaluationDetail := payload.EvaluationDetail.Value()
assert.Equal(t, ldvalue.String(result.Result), evaluationDetail.Value)
return true
}
return false
})
}

func beforeEvaluationDataPropagatesToAfterDetail(t *ldtest.T, detail bool) {
testParams := variationTestParams(detail)

hookName := "beforeEvaluationDataPropagatesToAfterDetail"
hookData := make(map[servicedef.HookStage]map[string]ldvalue.Value)
hookData[servicedef.BeforeEvaluation] = make(map[string]ldvalue.Value)
hookData[servicedef.BeforeEvaluation]["someData"] = ldvalue.String("the hookData")

client, hooks := createClientForHooks(t, []string{hookName}, hookData)
defer hooks.Close()

for _, testParam := range testParams {
t.Run(testParam.name, func(t *ldtest.T) {
client.EvaluateFlag(t, servicedef.EvaluateFlagParams{
FlagKey: testParam.flagKey,
Context: o.Some(ldcontext.New("user-key")),
ValueType: testParam.valueType,
DefaultValue: testParam.defaultValue,
Detail: detail,
})

hooks.ExpectCall(t, hookName, 1*time.Second, func(payload servicedef.HookExecutionPayload) bool {
if payload.Stage.Value() == servicedef.AfterEvaluation {
hookData := payload.EvaluationHookData.Value()
assert.Equal(t, ldvalue.String("the hookData"), hookData["someData"])
assert.Len(t, hookData, 1)
return true
}
return false
})
})
}
}

func beforeEvaluationDataPropagatesToAfterMigration(t *ldtest.T) {
hookName := "beforeEvaluationDataPropagatesToAfterDetail"
hookData := make(map[servicedef.HookStage]map[string]ldvalue.Value)
hookData[servicedef.BeforeEvaluation] = make(map[string]ldvalue.Value)
hookData[servicedef.BeforeEvaluation]["someData"] = ldvalue.String("the hookData")

client, hooks := createClientForHooks(t, []string{hookName}, hookData)
defer hooks.Close()

flagKey := "migration-flag"
params := servicedef.MigrationVariationParams{
Key: flagKey,
Context: ldcontext.New("user-key"),
DefaultStage: ldmigration.Off,
}
client.MigrationVariation(t, params)

hooks.ExpectCall(t, hookName, 1*time.Second, func(payload servicedef.HookExecutionPayload) bool {
if payload.Stage.Value() == servicedef.AfterEvaluation {
hookData := payload.EvaluationHookData.Value()
assert.Equal(t, ldvalue.String("the hookData"), hookData["someData"])
assert.Len(t, hookData, 1)
return true
}
return false
})
}

func createClientForHooks(t *ldtest.T, instances []string, hookData map[servicedef.HookStage]map[string]ldvalue.Value) (*SDKClient, *Hooks) {
boolFlag := ldbuilders.NewFlagBuilder("bool-flag").
Variations(ldvalue.Bool(false), ldvalue.Bool(true)).
FallthroughVariation(1).On(true).Build()

numberFlag := ldbuilders.NewFlagBuilder("number-flag").
Variations(ldvalue.Int(0), ldvalue.Int(42)).
OffVariation(1).On(false).Build()

stringFlag := ldbuilders.NewFlagBuilder("string-flag").
Variations(ldvalue.String("string-off"), ldvalue.String("string-on")).
FallthroughVariation(1).On(true).Build()

jsonFlag := ldbuilders.NewFlagBuilder("json-flag").
Variations(ldvalue.ObjectBuild().Set("value", ldvalue.Bool(false)).Build(),
ldvalue.ObjectBuild().Set("value", ldvalue.Bool(true)).Build()).
FallthroughVariation(1).On(true).Build()
migrationFlag := ldbuilders.NewFlagBuilder("migration-flag").
On(true).
Variations(data.MakeStandardMigrationStages()...).
FallthroughVariation(1).
Build()

dataBuilder := mockld.NewServerSDKDataBuilder()
dataBuilder.Flag(boolFlag, numberFlag, stringFlag, jsonFlag, migrationFlag)

hooks := NewHooks(requireContext(t).harness, t.DebugLogger(), instances, hookData)

dataSource := NewSDKDataSource(t, dataBuilder.Build())
events := NewSDKEventSink(t)
client := NewSDKClient(t, dataSource, hooks, events)
return client, hooks
}
Loading

0 comments on commit 9211849

Please sign in to comment.