(Feat): Initial Commit
This commit is contained in:
382
internal/test/test_database.go
Normal file
382
internal/test/test_database.go
Normal file
@@ -0,0 +1,382 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user