Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: support extending ftltest context with additional options #2535

Merged
merged 1 commit into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion go-runtime/ftl/ftltest/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type subscriber func(context.Context, any) error
type fakeFTL struct {
fsm *fakeFSMManager

// We store the options used to construct this fake, so they can be
// replayed to extend the fake with new options
options []Option

mockMaps map[uintptr]mapImpl
allowMapCalls bool
configValues map[string][]byte
Expand All @@ -64,13 +68,14 @@ type fakeFTL struct {
// type but is not constrained by input/output type like ftl.Map.
type mapImpl func(context.Context) (any, error)

func contextWithFakeFTL(ctx context.Context) context.Context {
func contextWithFakeFTL(ctx context.Context, options ...Option) context.Context {
fake := &fakeFTL{
fsm: newFakeFSMManager(),
mockMaps: map[uintptr]mapImpl{},
allowMapCalls: false,
configValues: map[string][]byte{},
secretValues: map[string][]byte{},
options: options,
}
ctx = internal.WithContext(ctx, fake)
fake.pubSub = newFakePubSub(ctx)
Expand Down
40 changes: 29 additions & 11 deletions go-runtime/ftl/ftltest/ftltest.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import (
mcu "github.com/TBD54566975/ftl/internal/testutils/modulecontext"
)

// Allows tests to mock module reflection
var moduleGetter = reflection.Module
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We probably want to eventually figure out a more comprehensive way to stub out the reflection for tests.


type OptionsState struct {
databases map[string]modulecontext.Database
mockVerbs map[schema.RefKey]modulecontext.Verb
Expand All @@ -45,14 +48,18 @@ type Option struct {

// Context suitable for use in testing FTL verbs with provided options
func Context(options ...Option) context.Context {
ctx := log.ContextWithNewDefaultLogger(context.Background())
module := moduleGetter()
return newContext(ctx, module, options...)
}

func newContext(ctx context.Context, module string, options ...Option) context.Context {
state := &OptionsState{
databases: make(map[string]modulecontext.Database),
mockVerbs: make(map[schema.RefKey]modulecontext.Verb),
}

ctx := log.ContextWithNewDefaultLogger(context.Background())
ctx = contextWithFakeFTL(ctx)
name := reflection.Module()
ctx = contextWithFakeFTL(ctx, options...)

sort.Slice(options, func(i, j int) bool {
return options[i].rank < options[j].rank
Expand All @@ -65,11 +72,22 @@ func Context(options ...Option) context.Context {
}
}

builder := modulecontext.NewBuilder(name).AddDatabases(state.databases)
builder := modulecontext.NewBuilder(module).AddDatabases(state.databases)
builder = builder.UpdateForTesting(state.mockVerbs, state.allowDirectVerbBehavior, newFakeLeaseClient())

return mcu.MakeDynamic(ctx, builder.Build()).ApplyToContext(ctx)
}

// SubContext applies the given options to the given context, creating a new
// context extending the previous one.
//
// Does not modify the existing context
func SubContext(ctx context.Context, options ...Option) context.Context {
oldFtl := internal.FromContext(ctx).(*fakeFTL) //nolint:forcetypeassert
module := moduleGetter()
return newContext(ctx, module, append(oldFtl.options, options...)...)
}

// WithDefaultProjectFile loads config and secrets from the default project
// file, which is either the FTL_CONFIG environment variable or the
// ftl-project.toml file in the git root.
Expand Down Expand Up @@ -113,7 +131,7 @@ func WithProjectFile(path string) Option {
if err != nil {
return fmt.Errorf("could not set up configs: %w", err)
}
configs, err := cm.MapForModule(ctx, reflection.Module())
configs, err := cm.MapForModule(ctx, moduleGetter())
if err != nil {
return fmt.Errorf("could not read configs: %w", err)
}
Expand All @@ -129,7 +147,7 @@ func WithProjectFile(path string) Option {
if err != nil {
return fmt.Errorf("could not set up secrets: %w", err)
}
secrets, err := sm.MapForModule(ctx, reflection.Module())
secrets, err := sm.MapForModule(ctx, moduleGetter())
if err != nil {
return fmt.Errorf("could not read secrets: %w", err)
}
Expand All @@ -156,8 +174,8 @@ func WithConfig[T ftl.ConfigType](config ftl.ConfigValue[T], value T) Option {
return Option{
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
if config.Module != reflection.Module() {
return fmt.Errorf("config %v does not match current module %s", config.Module, reflection.Module())
if config.Module != moduleGetter() {
return fmt.Errorf("config %v does not match current module %s", config.Module, moduleGetter())
}
fftl := internal.FromContext(ctx).(*fakeFTL) //nolint:forcetypeassert
if err := fftl.setConfig(config.Name, value); err != nil {
Expand All @@ -180,8 +198,8 @@ func WithSecret[T ftl.SecretType](secret ftl.SecretValue[T], value T) Option {
return Option{
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
if secret.Module != reflection.Module() {
return fmt.Errorf("secret %v does not match current module %s", secret.Module, reflection.Module())
if secret.Module != moduleGetter() {
return fmt.Errorf("secret %v does not match current module %s", secret.Module, moduleGetter())
}
fftl := internal.FromContext(ctx).(*fakeFTL) //nolint:forcetypeassert
if err := fftl.setSecret(secret.Name, value); err != nil {
Expand All @@ -205,7 +223,7 @@ func WithDatabase(dbHandle ftl.Database) Option {
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
fftl := internal.FromContext(ctx)
originalDSN, err := getDSNFromSecret(fftl, reflection.Module(), dbHandle.Name)
originalDSN, err := getDSNFromSecret(fftl, moduleGetter(), dbHandle.Name)
if err != nil {
return err
}
Expand Down
67 changes: 67 additions & 0 deletions go-runtime/ftl/ftltest/ftltest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"context"
"fmt"
"testing"
_ "unsafe"

"github.com/TBD54566975/ftl/go-runtime/ftl"
"github.com/TBD54566975/ftl/go-runtime/ftl/reflection"
"github.com/TBD54566975/ftl/go-runtime/internal"
"github.com/TBD54566975/ftl/internal/log"
"github.com/alecthomas/assert/v2"
)
Expand Down Expand Up @@ -36,3 +39,67 @@ func TestFtlTestProjectNotLoadedInContext(t *testing.T) {
_ = ftl.Config[string]("moo").Get(ctx)
})
}

func TestFtlTextContextExtension(t *testing.T) {
withFakeModule(t, "ftl/test")

t.Run("extends with a new project file", func(t *testing.T) {
original := Context(WithProjectFile("testdata/go/wrapped/ftl-project.toml"))
extended := SubContext(original, WithProjectFile("testdata/go/wrapped/ftl-project-test-1.toml"))

var config string
assert.NoError(t, internal.FromContext(original).(*fakeFTL).GetConfig(original, "config", &config)) //nolint:forcetypeassert
assert.Equal(t, "bazbaz", config, "does not change the original context")
assert.NoError(t, internal.FromContext(extended).(*fakeFTL).GetConfig(extended, "config", &config)) //nolint:forcetypeassert
assert.Equal(t, "foobar", config, "overwrites configuration values from the new file")
})
t.Run("extends with a new config value", func(t *testing.T) {
configA := ftl.ConfigValue[string]{Ref: reflection.Ref{Module: "ftl/test", Name: "configA"}}
configB := ftl.ConfigValue[string]{Ref: reflection.Ref{Module: "ftl/test", Name: "configB"}}

original := Context(WithConfig(configA, "a"), WithConfig(configB, "b"))
extended := SubContext(original, WithConfig(configA, "a.2"))

var config string
assert.NoError(t, internal.FromContext(original).(*fakeFTL).GetConfig(original, "configA", &config)) //nolint:forcetypeassert
assert.Equal(t, "a", config, "does not change the original context")
assert.NoError(t, internal.FromContext(extended).(*fakeFTL).GetConfig(extended, "configA", &config)) //nolint:forcetypeassert
assert.Equal(t, "a.2", config, "overwrites configuration values from the new file")
assert.NoError(t, internal.FromContext(extended).(*fakeFTL).GetConfig(extended, "configB", &config)) //nolint:forcetypeassert
assert.Equal(t, "b", config, "retains other config from the original context")
})
t.Run("extends with a new secret value", func(t *testing.T) {
secretA := ftl.SecretValue[string]{Ref: reflection.Ref{Module: "ftl/test", Name: "secretA"}}
secretB := ftl.SecretValue[string]{Ref: reflection.Ref{Module: "ftl/test", Name: "secretB"}}

original := Context(WithSecret(secretA, "a"), WithSecret(secretB, "b"))
extended := SubContext(original, WithSecret(secretA, "a.2"))

var config string
assert.NoError(t, internal.FromContext(original).(*fakeFTL).GetSecret(original, "secretA", &config)) //nolint:forcetypeassert
assert.Equal(t, "a", config, "does not change the original context")
assert.NoError(t, internal.FromContext(extended).(*fakeFTL).GetSecret(extended, "secretA", &config)) //nolint:forcetypeassert
assert.Equal(t, "a.2", config, "overwrites secret values from the new file")
assert.NoError(t, internal.FromContext(extended).(*fakeFTL).GetSecret(extended, "secretB", &config)) //nolint:forcetypeassert
assert.Equal(t, "b", config, "retains other secret from the original context")
})
t.Run("retains the existing context.Context state", func(t *testing.T) {
type keyType string
original := context.WithValue(Context(), keyType("key"), "value")
extended := SubContext(original, WithProjectFile("testdata/go/wrapped/ftl-project.toml"))

assert.Equal(t, "value", extended.Value(keyType("key")), "keeps context.Context value from the original context")
})
}

// mock out module reflection to make it testable
func withFakeModule(t *testing.T, name string) {
t.Helper()
var previousModuleGetter = moduleGetter
moduleGetter = func() string {
return name
}
t.Cleanup(func() {
moduleGetter = previousModuleGetter
})
}
23 changes: 23 additions & 0 deletions go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ func TestVerbs(t *testing.T) {
assert.Equal(t, knockOnEffects["empty"], "test")
}

func TestContextExtension(t *testing.T) {
ctx1 := ftltest.Context(
ftltest.WhenSource(Source, func(ctx context.Context) (Response, error) {
return Response{Output: "fake"}, nil
}),
)

ctx2 := ftltest.SubContext(
ctx1,
ftltest.WhenSource(Source, func(ctx context.Context) (Response, error) {
return Response{Output: "another fake"}, nil
}),
)

sourceResp, err := ftl.CallSource(ctx1, Source)
assert.NoError(t, err)
assert.Equal(t, Response{Output: "fake"}, sourceResp)

sourceResp, err = ftl.CallSource(ctx2, Source)
assert.NoError(t, err)
assert.Equal(t, Response{Output: "another fake"}, sourceResp)
}

func TestVerbErrors(t *testing.T) {
ctx := ftltest.Context(
ftltest.WhenVerb(Verb, func(ctx context.Context, req Request) (Response, error) {
Expand Down
Loading