From 03ec498fde2cf23e82763806df11c48fa334ba3f Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 30 Apr 2024 13:19:31 +1000 Subject: [PATCH] feat: module tests can declare config/secrets/DSNs and add verb mocks (#1317) Part of: https://hackmd.io/zNiBPIhVQN2hkeNHg2r8YA This PR makes it easy to set up the following: - config - secrets - DSNs - mocks for verbs ```go "github.com/TBD54566975/ftl/go-runtime/ftltest" func TestEcho(t *testing.T) { ctx := ftltest.Context ( ftltest.WithConfig("default", "anonymous"), ftltest.WithSecret("example", "test123"), ftltest.WithDSN("db", ftltest.DBTypePostgres, "..."), ftltest.WhenVerb(time.Time, func(ctx context. Context, req time. TimeRequest) (time.TimeResponse, error) { return time.TimeResponse{Time: stdtime.Date(2021, 9, 1, 0, 0, 0, 0, stdtime.UTC)}, nil }, ) ... } ``` Another improvement is when doing unit tests, a simple error message will be returned when attempting to make a `ftl.Call` when there isn't any rpc client set up. This error will be returned: > time.time: no mock found --- common/configuration/manager.go | 12 ++++- go-runtime/ftl/call.go | 35 +++++++----- go-runtime/ftl/call_overrider.go | 21 ++++++++ go-runtime/ftl/ftltest/ftltest.go | 90 +++++++++++++++++++++++++++++-- go-runtime/ftl/ftltest/mock.go | 42 +++++++++++++++ go-runtime/ftl/types.go | 4 +- internal/rpc/context.go | 4 ++ 7 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 go-runtime/ftl/call_overrider.go create mode 100644 go-runtime/ftl/ftltest/mock.go diff --git a/common/configuration/manager.go b/common/configuration/manager.go index 8cdfe54f39..93759de13d 100644 --- a/common/configuration/manager.go +++ b/common/configuration/manager.go @@ -43,14 +43,22 @@ func configFromEnvironment() []string { // the default ftl-project.toml. func NewDefaultSecretsManagerFromEnvironment(ctx context.Context) (*Manager[Secrets], error) { var cr Resolver[Secrets] = ProjectConfigResolver[Secrets]{Config: configFromEnvironment()} - return (DefaultSecretsMixin{}).NewSecretsManager(ctx, cr) + return DefaultSecretsMixin{ + InlineProvider: InlineProvider[Secrets]{ + Inline: true, + }, + }.NewSecretsManager(ctx, cr) } // NewDefaultConfigurationManagerFromEnvironment creates a new configuration // manager from the default ftl-project.toml. func NewDefaultConfigurationManagerFromEnvironment(ctx context.Context) (*Manager[Configuration], error) { cr := ProjectConfigResolver[Configuration]{Config: configFromEnvironment()} - return (DefaultConfigMixin{}).NewConfigurationManager(ctx, cr) + return DefaultConfigMixin{ + InlineProvider: InlineProvider[Configuration]{ + Inline: true, + }, + }.NewConfigurationManager(ctx, cr) } // New configuration manager. diff --git a/go-runtime/ftl/call.go b/go-runtime/ftl/call.go index 45b3e35c40..0d7c59ebab 100644 --- a/go-runtime/ftl/call.go +++ b/go-runtime/ftl/call.go @@ -11,19 +11,32 @@ import ( ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" - schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" "github.com/TBD54566975/ftl/backend/schema/strcase" "github.com/TBD54566975/ftl/go-runtime/encoding" "github.com/TBD54566975/ftl/internal/rpc" ) -func call[Req, Resp any](ctx context.Context, callee *schemapb.Ref, req Req) (resp Resp, err error) { - client := rpc.ClientFromContext[ftlv1connect.VerbServiceClient](ctx) +func call[Req, Resp any](ctx context.Context, callee Ref, req Req) (resp Resp, err error) { reqData, err := encoding.Marshal(req) if err != nil { return resp, fmt.Errorf("%s: failed to marshal request: %w", callee, err) } - cresp, err := client.Call(ctx, connect.NewRequest(&ftlv1.CallRequest{Verb: callee, Body: reqData})) + + if overrider, ok := CallOverriderFromContext(ctx); ok { + override, uncheckedResp, err := overrider.OverrideCall(ctx, callee, req) + if err != nil { + return resp, fmt.Errorf("%s: %w", callee, err) + } + if override { + if resp, ok = uncheckedResp.(Resp); ok { + return resp, nil + } + return resp, fmt.Errorf("%s: overridden verb had invalid response type %T, expected %v", callee, uncheckedResp, reflect.TypeFor[Resp]()) + } + } + + client := rpc.ClientFromContext[ftlv1connect.VerbServiceClient](ctx) + cresp, err := client.Call(ctx, connect.NewRequest(&ftlv1.CallRequest{Verb: callee.ToProto(), Body: reqData})) if err != nil { return resp, fmt.Errorf("%s: failed to call Verb: %w", callee, err) } @@ -45,23 +58,23 @@ func call[Req, Resp any](ctx context.Context, callee *schemapb.Ref, req Req) (re // Call a Verb through the FTL Controller. func Call[Req, Resp any](ctx context.Context, verb Verb[Req, Resp], req Req) (Resp, error) { - return call[Req, Resp](ctx, CallToSchemaRef(verb), req) + return call[Req, Resp](ctx, CallToRef(verb), req) } // CallSink calls a Sink through the FTL controller. func CallSink[Req any](ctx context.Context, sink Sink[Req], req Req) error { - _, err := call[Req, Unit](ctx, CallToSchemaRef(sink), req) + _, err := call[Req, Unit](ctx, CallToRef(sink), req) return err } // CallSource calls a Source through the FTL controller. func CallSource[Resp any](ctx context.Context, source Source[Resp]) (Resp, error) { - return call[Unit, Resp](ctx, CallToSchemaRef(source), Unit{}) + return call[Unit, Resp](ctx, CallToRef(source), Unit{}) } // CallEmpty calls a Verb with no request or response through the FTL controller. func CallEmpty(ctx context.Context, empty Empty) error { - _, err := call[Unit, Unit](ctx, CallToSchemaRef(empty), Unit{}) + _, err := call[Unit, Unit](ctx, CallToRef(empty), Unit{}) return err } @@ -71,12 +84,6 @@ func CallToRef(call any) Ref { return goRefToFTLRef(ref) } -// CallToSchemaRef returns the Ref for a Verb, Sink, Source, or Empty as a Schema Ref. -func CallToSchemaRef(call any) *schemapb.Ref { - ref := CallToRef(call) - return ref.ToProto() -} - func goRefToFTLRef(ref string) Ref { parts := strings.Split(ref[strings.LastIndex(ref, "/")+1:], ".") return Ref{parts[len(parts)-2], strcase.ToLowerCamel(parts[len(parts)-1])} diff --git a/go-runtime/ftl/call_overrider.go b/go-runtime/ftl/call_overrider.go new file mode 100644 index 0000000000..48694dbed1 --- /dev/null +++ b/go-runtime/ftl/call_overrider.go @@ -0,0 +1,21 @@ +package ftl + +import ( + "context" +) + +type CallOverrider interface { + OverrideCall(ctx context.Context, callee Ref, req any) (override bool, resp any, err error) +} +type contextCallOverriderKey struct{} + +func ApplyCallOverriderToContext(ctx context.Context, overrider CallOverrider) context.Context { + return context.WithValue(ctx, contextCallOverriderKey{}, overrider) +} + +func CallOverriderFromContext(ctx context.Context) (CallOverrider, bool) { + if overrider, ok := ctx.Value(contextCallOverriderKey{}).(CallOverrider); ok { + return overrider, true + } + return nil, false +} diff --git a/go-runtime/ftl/ftltest/ftltest.go b/go-runtime/ftl/ftltest/ftltest.go index f67b57ad92..e72e5e9d1f 100644 --- a/go-runtime/ftl/ftltest/ftltest.go +++ b/go-runtime/ftl/ftltest/ftltest.go @@ -3,18 +3,102 @@ package ftltest import ( "context" + "fmt" + "reflect" + cf "github.com/TBD54566975/ftl/common/configuration" "github.com/TBD54566975/ftl/go-runtime/ftl" "github.com/TBD54566975/ftl/go-runtime/modulecontext" "github.com/TBD54566975/ftl/internal/log" + "github.com/alecthomas/types/optional" ) -// Context suitable for use in testing FTL verbs. -func Context() context.Context { +type DBType int32 + +const ( + DBTypePostgres = DBType(modulecontext.DBTypePostgres) +) + +// Context suitable for use in testing FTL verbs with provided options +func Context(options ...func(context.Context) error) context.Context { ctx := log.ContextWithNewDefaultLogger(context.Background()) context, err := modulecontext.FromEnvironment(ctx, ftl.Module()) if err != nil { panic(err) } - return context.ApplyToContext(ctx) + ctx = context.ApplyToContext(ctx) + + mockProvider := newMockVerbProvider() + ctx = ftl.ApplyCallOverriderToContext(ctx, mockProvider) + + for _, option := range options { + err = option(ctx) + if err != nil { + panic(fmt.Sprintf("error applying option: %v", err)) + } + } + return ctx +} + +// WithConfig sets a configuration for the current module +// +// To be used with Context(...) +func WithConfig(name string, value any) func(context.Context) error { + return func(ctx context.Context) error { + cm := cf.ConfigFromContext(ctx) + return cm.Set(ctx, cf.Ref{Module: optional.Some(ftl.Module()), Name: name}, value) + } +} + +// WithSecret sets a secret for the current module +// +// To be used with Context(...) +func WithSecret(name string, value any) func(context.Context) error { + return func(ctx context.Context) error { + cm := cf.SecretsFromContext(ctx) + return cm.Set(ctx, cf.Ref{Module: optional.Some(ftl.Module()), Name: name}, value) + } +} + +// WithDSN sets a DSN for the current module +// +// To be used with Context(...) +func WithDSN(name string, dbType DBType, dsn string) func(context.Context) error { + return func(ctx context.Context) error { + dbProvider := modulecontext.DBProviderFromContext(ctx) + return dbProvider.Add(name, modulecontext.DBType(dbType), dsn) + } +} + +// WhenVerb replaces an implementation for a verb +// +// To be used when setting up a context for a test: +// ctx := ftltest.Context( +// +// ftltest.WhenVerb(Example.Verb, func(ctx context.Context, req Example.Req) (Example.Resp, error) { +// ... +// }), +// ... other options +// +// ) +func WhenVerb[Req any, Resp any](verb ftl.Verb[Req, Resp], fake func(ctx context.Context, req Req) (resp Resp, err error)) func(context.Context) error { + return func(ctx context.Context) error { + ref := ftl.CallToRef(verb) + overrider, ok := ftl.CallOverriderFromContext(ctx) + if !ok { + return fmt.Errorf("could not override %v with a fake, context not set up with call overrider", ref) + } + mockProvider, ok := overrider.(*mockVerbProvider) + if !ok { + return fmt.Errorf("could not override %v with a fake, call overrider is not a MockProvider", ref) + } + mockProvider.mocks[ref] = func(ctx context.Context, req any) (resp any, err error) { + request, ok := req.(Req) + if !ok { + return nil, fmt.Errorf("invalid request type %T for %v, expected %v", req, ref, reflect.TypeFor[Req]()) + } + return fake(ctx, request) + } + return nil + } } diff --git a/go-runtime/ftl/ftltest/mock.go b/go-runtime/ftl/ftltest/mock.go new file mode 100644 index 0000000000..684250c786 --- /dev/null +++ b/go-runtime/ftl/ftltest/mock.go @@ -0,0 +1,42 @@ +package ftltest + +import ( + "context" + "fmt" + + "github.com/TBD54566975/ftl/go-runtime/ftl" + "github.com/TBD54566975/ftl/internal/rpc" + + "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" +) + +type mockFunc func(ctx context.Context, req any) (resp any, err error) + +// mockVerbProvider keeps a mapping of verb references to mock functions. +// +// It implements the CallOverrider interface to intercept calls with the mock functions. +type mockVerbProvider struct { + mocks map[ftl.Ref]mockFunc +} + +var _ = (ftl.CallOverrider)(&mockVerbProvider{}) + +func newMockVerbProvider() *mockVerbProvider { + provider := &mockVerbProvider{ + mocks: map[ftl.Ref]mockFunc{}, + } + return provider +} + +func (m *mockVerbProvider) OverrideCall(ctx context.Context, ref ftl.Ref, req any) (override bool, resp any, err error) { + mock, ok := m.mocks[ref] + if ok { + resp, err = mock(ctx, req) + return true, resp, err + } + if rpc.IsClientAvailableInContext[ftlv1connect.VerbServiceClient](ctx) { + return false, nil, nil + } + // Return a clean error for testing because we know the client is not available to make real calls + return false, nil, fmt.Errorf("no mock found") +} diff --git a/go-runtime/ftl/types.go b/go-runtime/ftl/types.go index 32c19ebb93..0a4b646716 100644 --- a/go-runtime/ftl/types.go +++ b/go-runtime/ftl/types.go @@ -43,8 +43,8 @@ func (v *Ref) UnmarshalText(text []byte) error { return nil } -func (v *Ref) String() string { return v.Module + "." + v.Name } -func (v *Ref) ToProto() *schemapb.Ref { +func (v Ref) String() string { return v.Module + "." + v.Name } +func (v Ref) ToProto() *schemapb.Ref { return &schemapb.Ref{Module: v.Module, Name: v.Name} } diff --git a/internal/rpc/context.go b/internal/rpc/context.go index aaa38ded9a..40f1c09d94 100644 --- a/internal/rpc/context.go +++ b/internal/rpc/context.go @@ -180,6 +180,10 @@ func ClientFromContext[Client Pingable](ctx context.Context) Client { return value.(Client) //nolint:forcetypeassert } +func IsClientAvailableInContext[Client Pingable](ctx context.Context) bool { + return ctx.Value(clientKey[Client]{}) != nil +} + func propagateHeaders(ctx context.Context, isClient bool, header http.Header) (context.Context, error) { if isClient { if IsDirectRouted(ctx) {