From 2b3ef5aacc0994eddca701d791ee6c8313cd1c47 Mon Sep 17 00:00:00 2001 From: Juho Makinen Date: Thu, 29 Aug 2024 10:55:37 +1000 Subject: [PATCH] chore: support extending ftltest context with additional options --- go-runtime/ftl/ftltest/fake.go | 7 +- go-runtime/ftl/ftltest/ftltest.go | 40 ++++++++--- go-runtime/ftl/ftltest/ftltest_test.go | 67 +++++++++++++++++++ .../testdata/go/verbtypes/verbtypes_test.go | 23 +++++++ 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/go-runtime/ftl/ftltest/fake.go b/go-runtime/ftl/ftltest/fake.go index 8a121eb615..6d4bbf9227 100644 --- a/go-runtime/ftl/ftltest/fake.go +++ b/go-runtime/ftl/ftltest/fake.go @@ -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 @@ -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) diff --git a/go-runtime/ftl/ftltest/ftltest.go b/go-runtime/ftl/ftltest/ftltest.go index a36bc0cc3d..2052b59170 100644 --- a/go-runtime/ftl/ftltest/ftltest.go +++ b/go-runtime/ftl/ftltest/ftltest.go @@ -25,6 +25,9 @@ import ( mcu "github.com/TBD54566975/ftl/internal/testutils/modulecontext" ) +// Allows tests to mock module reflection +var moduleGetter = reflection.Module + type OptionsState struct { databases map[string]modulecontext.Database mockVerbs map[schema.RefKey]modulecontext.Verb @@ -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 @@ -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. @@ -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) } @@ -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) } @@ -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 { @@ -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 { @@ -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 } diff --git a/go-runtime/ftl/ftltest/ftltest_test.go b/go-runtime/ftl/ftltest/ftltest_test.go index cc9a5420a3..125ea51104 100644 --- a/go-runtime/ftl/ftltest/ftltest_test.go +++ b/go-runtime/ftl/ftltest/ftltest_test.go @@ -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" ) @@ -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 + }) +} diff --git a/go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes_test.go b/go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes_test.go index a301960819..2017ae53a9 100644 --- a/go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes_test.go +++ b/go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes_test.go @@ -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) {