github.com/github/skeema@v1.2.6/skeema_test.go (about)

     1  package main
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	log "github.com/sirupsen/logrus"
     7  	"os"
     8  	"path/filepath"
     9  	"reflect"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/skeema/mybase"
    14  	"github.com/skeema/skeema/fs"
    15  	"github.com/skeema/skeema/util"
    16  	"github.com/skeema/tengo"
    17  )
    18  
    19  func TestMain(m *testing.M) {
    20  	// Suppress packet error output when attempting to connect to a Dockerized
    21  	// mysql-server which is still starting up
    22  	tengo.UseFilteredDriverLogger()
    23  
    24  	// Add global options to the global command suite, just like in main()
    25  	util.AddGlobalOptions(CommandSuite)
    26  
    27  	os.Exit(m.Run())
    28  }
    29  
    30  func TestIntegration(t *testing.T) {
    31  	images := tengo.SplitEnv("SKEEMA_TEST_IMAGES")
    32  	if len(images) == 0 {
    33  		fmt.Println("SKEEMA_TEST_IMAGES env var is not set, so integration tests will be skipped!")
    34  		fmt.Println("To run integration tests, you may set SKEEMA_TEST_IMAGES to a comma-separated")
    35  		fmt.Println("list of Docker images. Example:\n# SKEEMA_TEST_IMAGES=\"mysql:5.6,mysql:5.7\" go test")
    36  	}
    37  	manager, err := tengo.NewDockerClient(tengo.DockerClientOptions{})
    38  	if err != nil {
    39  		t.Errorf("Unable to create sandbox manager: %s", err)
    40  	}
    41  	suite := &SkeemaIntegrationSuite{manager: manager}
    42  	tengo.RunSuite(suite, t, images)
    43  }
    44  
    45  type SkeemaIntegrationSuite struct {
    46  	manager  *tengo.DockerClient
    47  	d        *tengo.DockerizedInstance
    48  	repoPath string
    49  }
    50  
    51  func (s *SkeemaIntegrationSuite) Setup(backend string) (err error) {
    52  	// Remember working directory, which should be the base dir for the repo
    53  	s.repoPath, err = os.Getwd()
    54  	if err != nil {
    55  		return err
    56  	}
    57  
    58  	// Spin up a Dockerized database server
    59  	s.d, err = s.manager.GetOrCreateInstance(tengo.DockerizedInstanceOptions{
    60  		Name:         fmt.Sprintf("skeema-test-%s", strings.Replace(backend, ":", "-", -1)),
    61  		Image:        backend,
    62  		RootPassword: "fakepw",
    63  	})
    64  	return err
    65  }
    66  
    67  func (s *SkeemaIntegrationSuite) Teardown(backend string) error {
    68  	if err := s.d.Stop(); err != nil {
    69  		return err
    70  	}
    71  	if err := os.Chdir(s.repoPath); err != nil {
    72  		return err
    73  	}
    74  	if err := os.RemoveAll(s.scratchPath()); err != nil {
    75  		return err
    76  	}
    77  	return nil
    78  }
    79  
    80  func (s *SkeemaIntegrationSuite) BeforeTest(method string, backend string) error {
    81  	// Clear data and re-source setup data
    82  	if err := s.d.NukeData(); err != nil {
    83  		return err
    84  	}
    85  	if _, err := s.d.SourceSQL(s.testdata("setup.sql")); err != nil {
    86  		return err
    87  	}
    88  
    89  	// Create or recreate scratch dir
    90  	if _, err := os.Stat(s.scratchPath()); err == nil { // dir exists
    91  		if err := os.RemoveAll(s.scratchPath()); err != nil {
    92  			return err
    93  		}
    94  	}
    95  	if err := os.MkdirAll(s.scratchPath(), 0777); err != nil {
    96  		return err
    97  	}
    98  	if err := os.Chdir(s.scratchPath()); err != nil {
    99  		return err
   100  	}
   101  
   102  	return nil
   103  }
   104  
   105  // testdata returns the absolute path of the testdata dir, or a file or dir
   106  // based from it
   107  func (s *SkeemaIntegrationSuite) testdata(joins ...string) string {
   108  	parts := append([]string{s.repoPath, "testdata"}, joins...)
   109  	return filepath.Join(parts...)
   110  }
   111  
   112  // scratchPath returns the scratch directory for tests to write temporary files
   113  // to.
   114  func (s *SkeemaIntegrationSuite) scratchPath() string {
   115  	return s.testdata(".scratch")
   116  }
   117  
   118  // handleCommand executes the supplied Skeema command-line, and confirms its exit
   119  // code matches the expected value.
   120  // pwd can specify a relative path (based off of testdata/.scratch) to
   121  // execute the command from the designated subdirectory. Afterwards, the pwd
   122  // will be restored to testdata/.scratch regardless.
   123  func (s *SkeemaIntegrationSuite) handleCommand(t *testing.T, expectedExitCode int, pwd, commandLine string, a ...interface{}) *mybase.Config {
   124  	t.Helper()
   125  
   126  	path := filepath.Join(s.scratchPath(), pwd)
   127  	if err := os.Chdir(path); err != nil {
   128  		t.Fatalf("Unable to cd to %s: %s", path, err)
   129  	}
   130  
   131  	fullCommandLine := fmt.Sprintf(commandLine, a...)
   132  	fmt.Fprintf(os.Stderr, "\x1b[37;1m%s$\x1b[0m %s\n", filepath.Join("testdata", ".scratch", pwd), fullCommandLine)
   133  	fakeFileSource := mybase.SimpleSource(map[string]string{"password": s.d.Instance.Password})
   134  	cfg := mybase.ParseFakeCLI(t, CommandSuite, fullCommandLine, fakeFileSource)
   135  	util.AddGlobalConfigFiles(cfg)
   136  	err := util.ProcessSpecialGlobalOptions(cfg)
   137  	if err != nil {
   138  		err = NewExitValue(CodeBadConfig, err.Error())
   139  	} else {
   140  		util.CloseCachedConnectionPools() // ensure no previous session state bleeds through
   141  		err = cfg.HandleCommand()
   142  	}
   143  
   144  	actualExitCode := ExitCode(err)
   145  	var msg string
   146  	if err != nil {
   147  		msg = err.Error()
   148  	}
   149  	if actualExitCode == 0 {
   150  		log.Info("Exit code 0 (SUCCESS)")
   151  	} else if actualExitCode >= CodeFatalError {
   152  		if msg == "" {
   153  			msg = "FATAL"
   154  		}
   155  		log.Errorf("Exit code %d (%s)", actualExitCode, msg)
   156  	} else {
   157  		if msg == "" {
   158  			msg = "WARNING"
   159  		}
   160  		log.Warnf("Exit code %d (%s)", actualExitCode, msg)
   161  	}
   162  	if actualExitCode != expectedExitCode {
   163  		t.Errorf("Unexpected exit code from `%s`: Expected code=%d, found code=%d, message=%s", fullCommandLine, expectedExitCode, actualExitCode, err)
   164  	}
   165  
   166  	if pwd != "" && pwd != "." {
   167  		if err := os.Chdir(s.scratchPath()); err != nil {
   168  			t.Fatalf("Unable to cd to %s: %s", s.scratchPath(), err)
   169  		}
   170  	}
   171  	fmt.Fprintf(os.Stderr, "\n")
   172  	return cfg
   173  }
   174  
   175  // verifyFiles compares the files in testdata/.scratch to the files in the
   176  // specified dir, and fails the test if any differences are found.
   177  func (s *SkeemaIntegrationSuite) verifyFiles(t *testing.T, cfg *mybase.Config, dirExpectedBase string) {
   178  	t.Helper()
   179  
   180  	// Hackily manipulate dirExpectedBase if testing against a database backend
   181  	// with different SHOW CREATE TABLE rules:
   182  	// In MariaDB 10.2+, default values are no longer quoted if non-strings; the
   183  	// blob and text types now permit default values; partitions are formatted
   184  	// differently; default values and on-update rules for CURRENT_TIMESTAMP always
   185  	// include parens and lowercase the function name.
   186  	// In MySQL 5.5, DATETIME columns cannot have default or on-update of
   187  	// CURRENT_TIMESTAMP; only one TIMESTAMP column can have on-update;
   188  	// CURRENT_TIMESTAMP does not take an arg for specifying sub-second precision
   189  	// In MySQL 8.0+, partitions are formatted differently; the default character
   190  	// set is now utf8mb4; the default collation for utf8mb4 has also changed.
   191  	if s.d.Flavor().VendorMinVersion(tengo.VendorMariaDB, 10, 2) {
   192  		dirExpectedBase = strings.Replace(dirExpectedBase, "golden", "golden-mariadb102", 1)
   193  	} else if major, minor, _ := s.d.Version(); major == 5 && minor == 5 {
   194  		dirExpectedBase = strings.Replace(dirExpectedBase, "golden", "golden-mysql55", 1)
   195  	} else if s.d.Flavor().HasDataDictionary() {
   196  		dirExpectedBase = strings.Replace(dirExpectedBase, "golden", "golden-mysql80", 1)
   197  	}
   198  
   199  	var compareDirs func(*fs.Dir, *fs.Dir)
   200  	compareDirs = func(a, b *fs.Dir) {
   201  		t.Helper()
   202  
   203  		// Compare .skeema option files
   204  		if (a.OptionFile == nil && b.OptionFile != nil) || (a.OptionFile != nil && b.OptionFile == nil) {
   205  			t.Errorf("Presence of option files does not match between %s and %s", a, b)
   206  		}
   207  		if a.OptionFile != nil {
   208  			// Force port number of a to equal port number in b, since b will use whatever
   209  			// dynamic port was allocated to the Dockerized database instance
   210  			aSectionsWithPort := a.OptionFile.SectionsWithOption("port")
   211  			bSectionsWithPort := b.OptionFile.SectionsWithOption("port")
   212  			if !reflect.DeepEqual(aSectionsWithPort, bSectionsWithPort) {
   213  				t.Errorf("Sections with port option do not match between %s and %s", a.OptionFile.Path(), b.OptionFile.Path())
   214  			} else {
   215  				for _, section := range bSectionsWithPort {
   216  					b.OptionFile.UseSection(section)
   217  					forcedValue, _ := b.OptionFile.OptionValue("port")
   218  					a.OptionFile.SetOptionValue(section, "port", forcedValue)
   219  				}
   220  			}
   221  			// Force flavor of a to match the DockerizedInstance's flavor
   222  			for _, section := range a.OptionFile.SectionsWithOption("flavor") {
   223  				a.OptionFile.SetOptionValue(section, "flavor", s.d.Flavor().String())
   224  			}
   225  
   226  			if !a.OptionFile.SameContents(b.OptionFile) {
   227  				t.Errorf("File contents do not match between %s and %s", a.OptionFile.Path(), b.OptionFile.Path())
   228  				fmt.Printf("Expected:\n%s\n", fs.ReadTestFile(t, a.OptionFile.Path()))
   229  				fmt.Printf("Actual:\n%s\n", fs.ReadTestFile(t, b.OptionFile.Path()))
   230  			}
   231  		}
   232  
   233  		// Compare *.sql files
   234  		if len(a.SQLFiles) != len(b.SQLFiles) {
   235  			t.Errorf("Differing count of *.sql files between %s and %s", a, b)
   236  		} else {
   237  			for n := range a.SQLFiles {
   238  				if a.SQLFiles[n].FileName != b.SQLFiles[n].FileName {
   239  					t.Errorf("Differing file name at position[%d]: %s vs %s", n, a.SQLFiles[n].FileName, b.SQLFiles[n].FileName)
   240  				}
   241  			}
   242  		}
   243  
   244  		// Compare parsed CREATEs
   245  		if len(a.LogicalSchemas) != len(b.LogicalSchemas) {
   246  			t.Errorf("Mismatch between count of parsed logical schemas: %s=%d vs %s=%d", a, len(a.LogicalSchemas), b, len(b.LogicalSchemas))
   247  		} else if len(a.LogicalSchemas) > 0 {
   248  			aCreates, bCreates := a.LogicalSchemas[0].Creates, b.LogicalSchemas[0].Creates
   249  			if len(aCreates) != len(bCreates) {
   250  				t.Errorf("Mismatch in CREATE count: %s=%d, %s=%d", a, len(aCreates), b, len(bCreates))
   251  			} else {
   252  				for key, aStmt := range aCreates {
   253  					bStmt := bCreates[key]
   254  					if aStmt.Text != bStmt.Text {
   255  						t.Errorf("Mismatch for %s:\n%s:\n%s\n\n%s:\n%s\n", key, aStmt.Location(), aStmt.Text, bStmt.Location(), bStmt.Text)
   256  					}
   257  				}
   258  			}
   259  		}
   260  
   261  		// Compare subdirs and walk them
   262  		aSubdirs, badSubdirCount, err := a.Subdirs()
   263  		if err != nil || badSubdirCount > 0 {
   264  			t.Fatalf("Unable to list subdirs of %s: %s (bad subdir count %d)", a, err, badSubdirCount)
   265  		}
   266  		bSubdirs, badSubdirCount, err := b.Subdirs()
   267  		if err != nil || badSubdirCount > 0 {
   268  			t.Fatalf("Unable to list subdirs of %s: %s (bad subdir count %d)", b, err, badSubdirCount)
   269  		}
   270  		if len(aSubdirs) != len(bSubdirs) {
   271  			t.Errorf("Differing count of subdirs between %s and %s", a, b)
   272  		} else {
   273  			for n := range aSubdirs {
   274  				if aSubdirs[n].BaseName() != bSubdirs[n].BaseName() {
   275  					t.Errorf("Subdir name mismatch: %s vs %s", aSubdirs[n], bSubdirs[n])
   276  				} else {
   277  					compareDirs(aSubdirs[n], bSubdirs[n])
   278  				}
   279  			}
   280  		}
   281  	}
   282  
   283  	expected, err := fs.ParseDir(dirExpectedBase, cfg)
   284  	if err != nil {
   285  		t.Fatalf("ParseDir(%s) returned %s", dirExpectedBase, err)
   286  	}
   287  	actual, err := fs.ParseDir(s.scratchPath(), cfg)
   288  	if err != nil {
   289  		t.Fatalf("ParseDir(%s) returned %s", s.scratchPath(), err)
   290  	}
   291  	compareDirs(expected, actual)
   292  }
   293  
   294  func (s *SkeemaIntegrationSuite) reinitAndVerifyFiles(t *testing.T, extraInitOpts, comparePath string) {
   295  	t.Helper()
   296  
   297  	if comparePath == "" {
   298  		comparePath = "../golden/init"
   299  	}
   300  	if err := os.RemoveAll("mydb"); err != nil {
   301  		t.Fatalf("Unable to clean directory: %s", err)
   302  	}
   303  	cfg := s.handleCommand(t, CodeSuccess, ".", "skeema init --dir mydb -h %s -P %d %s", s.d.Instance.Host, s.d.Instance.Port, extraInitOpts)
   304  	s.verifyFiles(t, cfg, comparePath)
   305  }
   306  
   307  func (s *SkeemaIntegrationSuite) assertTableExists(t *testing.T, schema, table, column string) {
   308  	t.Helper()
   309  	exists, phrase, err := s.objectExists(schema, tengo.ObjectTypeTable, table, column)
   310  	if err != nil {
   311  		t.Fatalf("Unexpected error checking existence of %s: %s", phrase, err)
   312  	}
   313  	if !exists {
   314  		t.Errorf("Expected %s to exist, but it does not", phrase)
   315  	}
   316  }
   317  
   318  func (s *SkeemaIntegrationSuite) assertTableMissing(t *testing.T, schema, table, column string) {
   319  	t.Helper()
   320  	exists, phrase, err := s.objectExists(schema, tengo.ObjectTypeTable, table, column)
   321  	if err != nil {
   322  		t.Fatalf("Unexpected error checking existence of %s: %s", phrase, err)
   323  	}
   324  	if exists {
   325  		t.Errorf("Expected %s to not exist, but it does", phrase)
   326  	}
   327  }
   328  
   329  func (s *SkeemaIntegrationSuite) objectExists(schemaName string, objectType tengo.ObjectType, objectName, columnName string) (exists bool, phrase string, err error) {
   330  	if schemaName == "" || (objectName == "" && columnName != "") || (objectType != tengo.ObjectTypeTable && columnName != "") {
   331  		panic(errors.New("Invalid parameter combination"))
   332  	}
   333  	if objectName == "" && columnName == "" {
   334  		phrase = fmt.Sprintf("schema %s", schemaName)
   335  		has, err := s.d.HasSchema(schemaName)
   336  		return has, phrase, err
   337  	} else if columnName == "" {
   338  		phrase = fmt.Sprintf("%s %s.%s", objectType, schemaName, objectName)
   339  	} else {
   340  		phrase = fmt.Sprintf("column %s.%s.%s", schemaName, objectName, columnName)
   341  	}
   342  
   343  	schema, err := s.d.Schema(schemaName)
   344  	if err != nil {
   345  		return false, phrase, fmt.Errorf("Unable to obtain %s: %s", phrase, err)
   346  	}
   347  	if columnName == "" {
   348  		dict := schema.ObjectDefinitions()
   349  		key := tengo.ObjectKey{Type: objectType, Name: objectName}
   350  		_, ok := dict[key]
   351  		return ok, phrase, nil
   352  	}
   353  	table := schema.Table(objectName)
   354  	columns := table.ColumnsByName()
   355  	_, exists = columns[columnName]
   356  	return exists, phrase, nil
   357  }
   358  
   359  // sourceSQL wraps tengo.DockerizedInstance.SourceSQL. If an error occurs, it is
   360  // fatal to the test. filePath should be a relative path based from testdata/.
   361  func (s *SkeemaIntegrationSuite) sourceSQL(t *testing.T, filePath string) {
   362  	t.Helper()
   363  	filePath = filepath.Join("..", filePath)
   364  	if _, err := s.d.SourceSQL(filePath); err != nil {
   365  		t.Fatalf("Unable to source %s: %s", filePath, err)
   366  	}
   367  }
   368  
   369  // cleanData wraps tengo.DockerizedInstance.NukeData. If an error occurs, it is
   370  // fatal to the test. To automatically source one or more *.sql files after
   371  // nuking the data, supply relative file paths as args.
   372  func (s *SkeemaIntegrationSuite) cleanData(t *testing.T, sourceAfter ...string) {
   373  	t.Helper()
   374  	if err := s.d.NukeData(); err != nil {
   375  		t.Fatalf("Unable to clear database state: %s", err)
   376  	}
   377  	for _, filePath := range sourceAfter {
   378  		s.sourceSQL(t, filePath)
   379  	}
   380  }
   381  
   382  // dbExec runs the specified SQL DML or DDL in the specified schema. If
   383  // something goes wrong, it is fatal to the current test.
   384  func (s *SkeemaIntegrationSuite) dbExec(t *testing.T, schemaName, query string, args ...interface{}) {
   385  	t.Helper()
   386  	s.dbExecWithParams(t, schemaName, "", query, args...)
   387  }
   388  
   389  // dbExecWithOptions run the specified SQL DML or DDL in the specified schema,
   390  // using the supplied URI-encoded session variables. If something goes wrong,
   391  // it is fatal to the current test.
   392  func (s *SkeemaIntegrationSuite) dbExecWithParams(t *testing.T, schemaName, params, query string, args ...interface{}) {
   393  	t.Helper()
   394  	db, err := s.d.Connect(schemaName, params)
   395  	if err != nil {
   396  		t.Fatalf("Unable to connect to DockerizedInstance: %s", err)
   397  	}
   398  	_, err = db.Exec(query, args...)
   399  	if err != nil {
   400  		t.Fatalf("Error running query on DockerizedInstance.\nSchema: %s\nQuery: %s\nError: %s", schemaName, query, err)
   401  	}
   402  }
   403  
   404  // getOptionFile returns a mybase.File representing the .skeema file in the
   405  // specified directory
   406  func getOptionFile(t *testing.T, basePath string, baseConfig *mybase.Config) *mybase.File {
   407  	t.Helper()
   408  	dir, err := fs.ParseDir(basePath, baseConfig)
   409  	if err != nil {
   410  		t.Fatalf("Unable to obtain directory %s: %s", basePath, err)
   411  	}
   412  	return dir.OptionFile
   413  }