diff --git a/go-runtime/ftl/ftltest/ftltest.go b/go-runtime/ftl/ftltest/ftltest.go index 68708f1300..e6f39f8f71 100644 --- a/go-runtime/ftl/ftltest/ftltest.go +++ b/go-runtime/ftl/ftltest/ftltest.go @@ -3,8 +3,10 @@ package ftltest import ( "context" + "database/sql" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "reflect" @@ -16,6 +18,7 @@ import ( "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/modulecontext" "github.com/TBD54566975/ftl/internal/slices" + _ "github.com/jackc/pgx/v5/stdlib" // SQL driver ) type OptionsState struct { @@ -168,6 +171,64 @@ func WithSecret[T ftl.SecretType](secret ftl.SecretValue[T], value T) Option { } } +// WithDatabase sets up a database for testing by appending "_test" to the DSN and emptying all tables +// +// To be used when setting up a context for a test: +// ctx := ftltest.Context( +// +// ftltest.WithDatabase(db), +// ... other options +// +// ) +func WithDatabase(dbHandle ftl.Database) Option { + return func(ctx context.Context, state *OptionsState) error { + envarName := fmt.Sprintf("FTL_POSTGRES_DSN_%s_%s", strings.ToUpper(ftl.Module()), strings.ToUpper(dbHandle.Name)) + originalDSN, ok := os.LookupEnv(envarName) + if !ok { + return fmt.Errorf("missing DSN for database %s: expected to find it at the environment variable %s", dbHandle.Name, envarName) + } + + // convert DSN by appending "_test" to table name + // postgres DSN format: postgresql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...] + // source: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING + dsnURL, err := url.Parse(originalDSN) + if err != nil { + return fmt.Errorf("could not parse DSN: %w", err) + } + if dsnURL.Path == "" { + return fmt.Errorf("DSN for %s must include table name: %s", dbHandle.Name, originalDSN) + } + dsnURL.Path += "_test" + dsn := dsnURL.String() + + // connect to db and clear out the contents of each table + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + return fmt.Errorf("could not create database %q with DSN %q: %w", dbHandle.Name, dsn, err) + } + _, err = sqlDB.ExecContext(ctx, `DO $$ + DECLARE + table_name text; + BEGIN + FOR table_name IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') + LOOP + EXECUTE 'DELETE FROM ' || table_name; + END LOOP; + END $$;`) + if err != nil { + return fmt.Errorf("could not clear tables in database %q: %w", dbHandle.Name, err) + } + + // replace original database with test database + replacementDB, err := modulecontext.NewDatabase(modulecontext.DBTypePostgres, dsn) + if err != nil { + return fmt.Errorf("could not create database %q with DSN %q: %w", dbHandle.Name, dsn, err) + } + state.databases[dbHandle.Name] = replacementDB + return nil + } +} + // WhenVerb replaces an implementation for a verb // // To be used when setting up a context for a test: diff --git a/integration/actions_test.go b/integration/actions_test.go index 18e1acd32e..a8a4147456 100644 --- a/integration/actions_test.go +++ b/integration/actions_test.go @@ -262,9 +262,22 @@ func queryRow(database string, query string, expected ...interface{}) action { } // Create a database for use by a module. -func createDB(t testing.TB, module, dbName string) { +func createDBAction(module, dbName string, isTest bool) action { + return func(t testing.TB, ic testContext) error { + createDB(t, module, dbName, isTest) + return nil + } +} + +func createDB(t testing.TB, module, dbName string, isTestDb bool) { + // envars do not include test suffix t.Setenv(fmt.Sprintf("FTL_POSTGRES_DSN_%s_%s", strings.ToUpper(module), strings.ToUpper(dbName)), fmt.Sprintf("postgres://postgres:secret@localhost:54320/%s?sslmode=disable", dbName)) + + // insert test suffix if needed when actually setting up db + if isTestDb { + dbName += "_test" + } infof("Creating database %s", dbName) db, err := sql.Open("pgx", "postgres://postgres:secret@localhost:54320/ftl?sslmode=disable") assert.NoError(t, err, "failed to open database connection") @@ -276,6 +289,9 @@ func createDB(t testing.TB, module, dbName string) { err = db.Ping() assert.NoError(t, err, "failed to ping database") + _, err = db.Exec("DROP DATABASE IF EXISTS " + dbName) + assert.NoError(t, err, "failed to delete existing database") + _, err = db.Exec("CREATE DATABASE " + dbName) assert.NoError(t, err, "failed to create database") diff --git a/integration/integration_test.go b/integration/integration_test.go index 6286c75af1..8cce3d2003 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -84,12 +84,18 @@ func TestNonExportedDecls(t *testing.T) { } func TestDatabase(t *testing.T) { - createDB(t, "database", "testdb") + createDB(t, "database", "testdb", false) run(t, + // deploy real module against "testdb" copyModule("database"), deploy("database"), call("database", "insert", obj{"data": "hello"}, func(response obj) error { return nil }), queryRow("testdb", "SELECT data FROM requests", "hello"), + + // run tests which should only affect "testdb_test" + createDBAction("database", "testdb", true), + testModule("database"), + queryRow("testdb", "SELECT data FROM requests", "hello"), ) } diff --git a/integration/testdata/go/database/database_test.go b/integration/testdata/go/database/database_test.go new file mode 100644 index 0000000000..57a386e2aa --- /dev/null +++ b/integration/testdata/go/database/database_test.go @@ -0,0 +1,50 @@ +package database + +import ( + "context" + "testing" + + "github.com/TBD54566975/ftl/go-runtime/ftl/ftltest" + "github.com/alecthomas/assert/v2" +) + +func TestDatabase(t *testing.T) { + ctx := ftltest.Context( + ftltest.WithDatabase(db), + ) + + Insert(ctx, InsertRequest{Data: "unit test 1"}) + list, err := getAll(ctx) + assert.NoError(t, err) + assert.Equal(t, 1, len(list)) + assert.Equal(t, "unit test 1", list[0]) + + ctx = ftltest.Context( + ftltest.WithDatabase(db), + ) + + Insert(ctx, InsertRequest{Data: "unit test 2"}) + list, err = getAll(ctx) + assert.NoError(t, err) + assert.Equal(t, 1, len(list)) + assert.Equal(t, "unit test 2", list[0]) +} + +func getAll(ctx context.Context) ([]string, error) { + rows, err := db.Get(ctx).Query("SELECT data FROM requests ORDER BY created_at;") + if err != nil { + return nil, err + } + defer rows.Close() + + list := []string{} + for rows.Next() { + var data string + err := rows.Scan(&data) + if err != nil { + return nil, err + } + list = append(list, data) + } + return list, nil +} diff --git a/integration/testdata/go/database/go.mod b/integration/testdata/go/database/go.mod index 244865b42a..e32fc04001 100644 --- a/integration/testdata/go/database/go.mod +++ b/integration/testdata/go/database/go.mod @@ -2,20 +2,28 @@ module ftl/database go 1.22.2 -require github.com/TBD54566975/ftl v0.189.0 +require ( + github.com/TBD54566975/ftl v0.189.0 + github.com/alecthomas/assert/v2 v2.9.0 +) require ( connectrpc.com/connect v1.16.1 // indirect connectrpc.com/grpcreflect v1.2.0 // indirect connectrpc.com/otelconnect v0.7.0 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/TBD54566975/scaffolder v0.8.0 // indirect github.com/alecthomas/concurrency v0.0.2 // indirect + github.com/alecthomas/kong v0.9.0 // indirect github.com/alecthomas/participle/v2 v2.1.1 // indirect + github.com/alecthomas/repr v0.4.0 // indirect github.com/alecthomas/types v0.14.0 // indirect github.com/alessio/shellescape v1.4.2 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect diff --git a/integration/testdata/go/database/go.sum b/integration/testdata/go/database/go.sum index 18fbc1619e..0131370846 100644 --- a/integration/testdata/go/database/go.sum +++ b/integration/testdata/go/database/go.sum @@ -4,10 +4,16 @@ connectrpc.com/grpcreflect v1.2.0 h1:Q6og1S7HinmtbEuBvARLNwYmTbhEGRpHDhqrPNlmK+U connectrpc.com/grpcreflect v1.2.0/go.mod h1:nwSOKmE8nU5u/CidgHtPYk1PFI3U9ignz7iDMxOYkSY= connectrpc.com/otelconnect v0.7.0 h1:ZH55ZZtcJOTKWWLy3qmL4Pam4RzRWBJFOqTPyAqCXkY= connectrpc.com/otelconnect v0.7.0/go.mod h1:Bt2ivBymHZHqxvo4HkJ0EwHuUzQN6k2l0oH+mp/8nwc= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/TBD54566975/scaffolder v0.8.0 h1:DWl1K3dWcLsOPAYGQGPQXtffrml6XCB0tF05JdpMqZU= +github.com/TBD54566975/scaffolder v0.8.0/go.mod h1:Ab/jbQ4q8EloYL0nbkdh2DVvkGc4nxr1OcIbdMpTxxg= github.com/alecthomas/assert/v2 v2.9.0 h1:ZcLG8ccMEtlMLkLW4gwGpBWBb0N8MUCmsy1lYBVd1xQ= github.com/alecthomas/assert/v2 v2.9.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/concurrency v0.0.2 h1:Q3kGPtLbleMbH9lHX5OBFvJygfyFw29bXZKBg+IEVuo= github.com/alecthomas/concurrency v0.0.2/go.mod h1:GmuQb/iHX7mbNtPlC/WDzEFxDMB0HYFer2Qda9QTs7w= +github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= +github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=