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 }