383 lines
13 KiB
Go
383 lines
13 KiB
Go
package test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5" //nolint:gosec
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"allaboutapps.dev/aw/go-starter/internal/config"
|
|
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
|
pUtil "allaboutapps.dev/aw/go-starter/internal/util"
|
|
dbutil "allaboutapps.dev/aw/go-starter/internal/util/db"
|
|
"github.com/aarondl/sqlboiler/v4/boil"
|
|
"github.com/allaboutapps/integresql-client-go"
|
|
"github.com/allaboutapps/integresql-client-go/pkg/util"
|
|
"github.com/pkg/errors"
|
|
migrate "github.com/rubenv/sql-migrate"
|
|
)
|
|
|
|
var (
|
|
client *integresql.Client
|
|
|
|
// tracks IntegreSQL template initialization and hash relookup (to reidentify the pool from a precomputed poolID)
|
|
poolInitMap = &sync.Map{} // "poolID" -> *sync.Once
|
|
poolHashMap = &sync.Map{} // "poolID" -> "poolHash"
|
|
|
|
// we will compute a db template hash over the following dirs/files
|
|
migDir = config.DatabaseMigrationFolder
|
|
fixFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/fixtures/fixtures.go")
|
|
selfFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/test_database.go")
|
|
defaultPoolPaths = []string{migDir, fixFile, selfFile}
|
|
)
|
|
|
|
//nolint:gochecknoinits
|
|
func init() {
|
|
// autoinitialize IntegreSQL client
|
|
c, err := integresql.DefaultClientFromEnv()
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "Failed to create new integresql-client"))
|
|
}
|
|
client = c
|
|
|
|
// pin migrate to use the globally defined `migrations` table identifier
|
|
migrate.SetTable(config.DatabaseMigrationTable)
|
|
}
|
|
|
|
// WithTestDatabase returns an isolated test database based on the current migrations and fixtures.
|
|
func WithTestDatabase(t *testing.T, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
WithTestDatabaseContext(ctx, t, closure)
|
|
}
|
|
|
|
// WithTestDatabaseContext returns an isolated test database based on the current migrations and fixtures.
|
|
// The provided context will be used during setup (instead of the default background context).
|
|
func WithTestDatabaseContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
|
|
poolID := strings.Join(defaultPoolPaths, ",")
|
|
|
|
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
|
|
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
|
|
doOnce, ok := initOnce.(*sync.Once)
|
|
if !ok {
|
|
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
|
|
}
|
|
|
|
doOnce.Do(func() {
|
|
t.Helper()
|
|
|
|
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
|
|
poolHash := storePoolHash(t, poolID, defaultPoolPaths)
|
|
|
|
// properly build up the template database once
|
|
execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error {
|
|
t.Helper()
|
|
|
|
countMigrations, err := ApplyMigrations(t, db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err)
|
|
return err
|
|
}
|
|
t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash)
|
|
|
|
countFixtures, err := ApplyTestFixtures(ctx, t, db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err)
|
|
return err
|
|
}
|
|
t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash)
|
|
|
|
return nil
|
|
})
|
|
})
|
|
|
|
// execute closure in a new IntegreSQL database build from above template
|
|
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabase", closure)
|
|
}
|
|
|
|
type DatabaseDumpConfig struct {
|
|
DumpFile string // required, absolute path to dump file
|
|
ApplyMigrations bool // optional, default false
|
|
ApplyTestFixtures bool // optional, default false
|
|
}
|
|
|
|
// WithTestDatabaseFromDump returns an isolated test database based on a dump file.
|
|
func WithTestDatabaseFromDump(t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
WithTestDatabaseFromDumpContext(ctx, t, config, closure)
|
|
}
|
|
|
|
// WithTestDatabaseFromDumpContext returns an isolated test database based on a dump file.
|
|
// The provided context will be used during setup (instead of the default background context).
|
|
func WithTestDatabaseFromDumpContext(ctx context.Context, t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
|
|
// DumpFile is mandadory.
|
|
if config.DumpFile == "" {
|
|
t.Fatal("DatabaseDumpConfig.DumpFile is mandadory and cannot be ''")
|
|
}
|
|
|
|
// poolID must incorporate additional config args in the final hash
|
|
fragments := fmt.Sprintf("?migrations=%v&fixtures=%v", config.ApplyMigrations, config.ApplyTestFixtures)
|
|
poolID := strings.Join([]string{config.DumpFile, selfFile}, ",") + fragments
|
|
|
|
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
|
|
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
|
|
doOnce, ok := initOnce.(*sync.Once)
|
|
if !ok {
|
|
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
|
|
}
|
|
|
|
doOnce.Do(func() {
|
|
t.Helper()
|
|
|
|
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
|
|
poolHash := storePoolHash(t, poolID, []string{config.DumpFile, selfFile}, fragments)
|
|
|
|
// properly build up the template database once
|
|
execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error {
|
|
t.Helper()
|
|
|
|
if err := ApplyDump(ctx, t, db, config.DumpFile); err != nil {
|
|
t.Fatalf("Failed to apply dumps for %q: %v\n", poolHash, err)
|
|
return err
|
|
}
|
|
t.Logf("Applied dump for hash %q", poolHash)
|
|
|
|
if config.ApplyMigrations {
|
|
countMigrations, err := ApplyMigrations(t, db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err)
|
|
return err
|
|
}
|
|
t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash)
|
|
}
|
|
|
|
if config.ApplyTestFixtures {
|
|
countFixtures, err := ApplyTestFixtures(ctx, t, db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err)
|
|
return err
|
|
}
|
|
t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
})
|
|
|
|
// execute closure in a new IntegreSQL database build from above template
|
|
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseFromDump", closure)
|
|
}
|
|
|
|
// WithTestDatabaseEmpty returns an isolated test database with no migrations applied or fixtures inserted.
|
|
func WithTestDatabaseEmpty(t *testing.T, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
WithTestDatabaseEmptyContext(ctx, t, closure)
|
|
}
|
|
|
|
// WithTestDatabaseEmptyContext returns an isolated test database with no migrations applied or fixtures inserted.
|
|
// The provided context will be used during setup (instead of the default background context).
|
|
func WithTestDatabaseEmptyContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
|
|
poolID := selfFile
|
|
|
|
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
|
|
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
|
|
doOnce, ok := initOnce.(*sync.Once)
|
|
if !ok {
|
|
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
|
|
}
|
|
|
|
doOnce.Do(func() {
|
|
t.Helper()
|
|
|
|
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
|
|
poolHash := storePoolHash(t, poolID, []string{selfFile})
|
|
|
|
// properly build up the template database once (noop)
|
|
execClosureNewIntegresTemplate(ctx, t, poolHash, func(_ *sql.DB) error {
|
|
t.Helper()
|
|
return nil
|
|
})
|
|
})
|
|
|
|
// execute closure in a new IntegreSQL database build from above template
|
|
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseEmpty", closure)
|
|
}
|
|
|
|
// Adds poolID to poolHashMap pointing to the final integresql hash
|
|
// Expects hashPaths to be absolute paths to actual files or directories (its contents will be md5 hashed)
|
|
// Optional fragments can be used to further enhance the computed md5
|
|
func storePoolHash(t *testing.T, poolID string, hashPaths []string, fragments ...string) string {
|
|
t.Helper()
|
|
|
|
// compute a new integreSQL pool hash
|
|
poolHash, err := util.GetTemplateHash(hashPaths...)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create template hash for %v: %#v", poolID, err)
|
|
}
|
|
|
|
// update the hash with optional provided fragments
|
|
if len(fragments) > 0 {
|
|
poolHash = fmt.Sprintf("%x", md5.Sum([]byte(poolHash+strings.Join(fragments, ",")))) //nolint:gosec
|
|
}
|
|
|
|
// and point poolID to it (sideffect synchronized store!)
|
|
poolHashMap.Store(poolID, poolHash) // save it for all runners
|
|
|
|
return poolHash
|
|
}
|
|
|
|
// Gets precomputed integresql hash via poolID identifier from our synchronized map (see storePoolHash)
|
|
func getPoolHash(t *testing.T, poolID string) string {
|
|
t.Helper()
|
|
|
|
poolHash, ok := poolHashMap.Load(poolID)
|
|
if !ok {
|
|
t.Fatalf("Failed to get poolHash from poolID '%v'. Is poolHashMap initialized yet?", poolID)
|
|
return ""
|
|
}
|
|
|
|
hash, ok := poolHash.(string)
|
|
if !ok {
|
|
t.Fatalf("Expected poolHash to be of type string, but got %T", poolHash)
|
|
return ""
|
|
}
|
|
|
|
return hash
|
|
}
|
|
|
|
// Executes closure on an integresql **template** database
|
|
func execClosureNewIntegresTemplate(ctx context.Context, t *testing.T, poolHash string, closure func(db *sql.DB) error) {
|
|
t.Helper()
|
|
|
|
if err := client.SetupTemplateWithDBClient(ctx, poolHash, closure); err != nil {
|
|
// This error is exceptionally fatal as it hinders ANY future other
|
|
// test execution with this hash as the template was *never* properly
|
|
// setuped successfully. All GetTestDatabase will wait unti timeout
|
|
// unless we interrupt them by discarding the base template...
|
|
discardError := client.DiscardTemplate(ctx, poolHash)
|
|
|
|
if discardError != nil {
|
|
t.Fatalf("Failed to setup template database, also discarding failed for poolHash %q: %v, %v", poolHash, err, discardError)
|
|
}
|
|
|
|
t.Fatalf("Failed to setup template database (discarded) for poolHash %q: %v", poolHash, err)
|
|
}
|
|
}
|
|
|
|
// Executes closure on an integresql **test** database (scaffolded from a template)
|
|
func execClosureNewIntegresDatabase(ctx context.Context, t *testing.T, poolHash string, callee string, closure func(db *sql.DB)) {
|
|
t.Helper()
|
|
|
|
testDatabase, err := client.GetTestDatabase(ctx, poolHash)
|
|
|
|
if err != nil {
|
|
t.Fatalf("Failed to obtain test database: %v", err)
|
|
}
|
|
|
|
connectionString := testDatabase.Config.ConnectionString()
|
|
t.Logf("%v: %q", callee, testDatabase.Config.Database)
|
|
|
|
db, err := sql.Open("postgres", connectionString)
|
|
|
|
if err != nil {
|
|
t.Fatalf("Failed to setup test database for connectionString %q: %v", connectionString, err)
|
|
}
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
t.Fatalf("Failed to ping test database for connectionString %q: %v", connectionString, err)
|
|
}
|
|
|
|
closure(db)
|
|
|
|
// this database object is managed and should close automatically after running the test
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("Failed to close db %q: %v", connectionString, err)
|
|
}
|
|
|
|
// disallow any further refs to managed object after running the test
|
|
//nolint: wastedassign
|
|
db = nil
|
|
}
|
|
|
|
// ApplyMigrations applies all current database migrations to db
|
|
func ApplyMigrations(t *testing.T, db *sql.DB) (int, error) {
|
|
t.Helper()
|
|
|
|
// In case an old default sql-migrate migration table (named "gorp_migrations") still exists we rename it to the new name equivalent
|
|
// in sync with the settings in dbconfig.yml and config.DatabaseMigrationTable.
|
|
if _, err := db.ExecContext(t.Context(), fmt.Sprintf("ALTER TABLE IF EXISTS gorp_migrations RENAME TO %s;", config.DatabaseMigrationTable)); err != nil {
|
|
return 0, fmt.Errorf("failed to rename migrations table: %w", err)
|
|
}
|
|
|
|
migrations := &migrate.FileMigrationSource{Dir: migDir}
|
|
countMigrations, err := migrate.Exec(db, "postgres", migrations, migrate.Up)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to apply migrations: %w", err)
|
|
}
|
|
|
|
return countMigrations, nil
|
|
}
|
|
|
|
// ApplyTestFixtures applies all current test fixtures (insert) to db
|
|
func ApplyTestFixtures(ctx context.Context, t *testing.T, db *sql.DB) (int, error) {
|
|
t.Helper()
|
|
|
|
inserts := fixtures.Inserts()
|
|
|
|
// insert test fixtures in an auto-managed db transaction
|
|
err := dbutil.WithTransaction(ctx, db, func(tx boil.ContextExecutor) error {
|
|
t.Helper()
|
|
for _, fixture := range inserts {
|
|
if err := fixture.Insert(ctx, tx, boil.Infer()); err != nil {
|
|
return fmt.Errorf("failed to insert fixture: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to apply test fixtures: %w", err)
|
|
}
|
|
|
|
return len(inserts), nil
|
|
}
|
|
|
|
// ApplyDump applies dumpFile (absolute path to .sql file) to db
|
|
func ApplyDump(ctx context.Context, t *testing.T, db *sql.DB, dumpFile string) error {
|
|
t.Helper()
|
|
|
|
// ensure file exists
|
|
if _, err := os.Stat(dumpFile); err != nil {
|
|
return fmt.Errorf("failed to stat dump file: %w", err)
|
|
}
|
|
|
|
// we need to get the db name before being able to do anything.
|
|
var targetDB string
|
|
if err := db.QueryRowContext(ctx, "SELECT current_database();").Scan(&targetDB); err != nil {
|
|
return fmt.Errorf("failed to get current database: %w", err)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf("cat %q | psql %q", dumpFile, targetDB)) //nolint:gosec
|
|
combinedOutput, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, string(combinedOutput))
|
|
}
|
|
|
|
return nil
|
|
}
|