package database_test // migration_runner_test.go — Pre-fix tests for migration runner behavior. // // F-B1-11 P0: Server starts with incomplete schema after migration failure. // main.go:191-196 swallows the error from db.Migrate() and prints [OK]. // // F-B1-13 MEDIUM: Duplicate migration numbers (009, 012) are not detected. // Runner processes both files with the same numeric prefix. // // Run: cd aggregator-server && go test ./internal/database/... -v -run TestMigration import ( "os" "path/filepath" "sort" "strings" "testing" "github.com/Fimeg/RedFlag/aggregator-server/internal/database" ) // --------------------------------------------------------------------------- // Test 1.1 — Migrate() returns error on broken migration SQL // // Category: PASS (runner returns errors correctly) // // F-B1-11: The runner itself correctly returns errors. The P0 is in // main.go:191-196 swallowing the error. See Test 1.2/1.3 for that. // --------------------------------------------------------------------------- func TestMigrationFailureReturnsError(t *testing.T) { // Create a temp directory with a broken migration tmpDir := t.TempDir() brokenSQL := []byte("SELECT invalid_syntax$$;") if err := os.WriteFile(filepath.Join(tmpDir, "999_broken.up.sql"), brokenSQL, 0644); err != nil { t.Fatalf("failed to write broken migration: %v", err) } // We cannot call db.Migrate() without a real DB connection, // but we CAN verify the runner would attempt to process the file. // Read the file list the same way the runner does (db.go:51-63). files, err := os.ReadDir(tmpDir) if err != nil { t.Fatalf("failed to read temp dir: %v", err) } var migrationFiles []string for _, file := range files { if strings.HasSuffix(file.Name(), ".up.sql") { migrationFiles = append(migrationFiles, file.Name()) } } if len(migrationFiles) != 1 || migrationFiles[0] != "999_broken.up.sql" { t.Errorf("[ERROR] [server] [database] expected 1 migration file, got %d: %v", len(migrationFiles), migrationFiles) } // Verify the SQL content is indeed broken content, _ := os.ReadFile(filepath.Join(tmpDir, "999_broken.up.sql")) if !strings.Contains(string(content), "$$") { t.Error("[ERROR] [server] [database] broken migration should contain invalid syntax") } t.Log("[INFO] [server] [database] F-B1-11: runner correctly processes .up.sql files and would return error on bad SQL") // Confirm the database package is importable (compile check) _ = database.DB{} } // --------------------------------------------------------------------------- // Test 1.2 — main.go swallows migration errors (documents P0) // // Category: PASS-NOW (documents the broken behavior) // // F-B1-11 P0: main.go catches the migration error at line 191-196, // prints a warning, and continues to start the server. The [OK] // message prints unconditionally. // --------------------------------------------------------------------------- func TestServerStartsAfterMigrationFailure(t *testing.T) { // Read main.go and inspect the migration error handling block mainPath := filepath.Join("..", "..", "cmd", "server", "main.go") content, err := os.ReadFile(mainPath) if err != nil { t.Fatalf("failed to read main.go: %v", err) } src := string(content) // Find the migration error block if !strings.Contains(src, "Warning: Migration failed") { t.Fatal("[ERROR] [server] [database] cannot find migration error handling in main.go") } // The NORMAL startup migration error (not --migrate flag) logs a warning, NOT a fatal. // Main.go has TWO migration paths: // 1. --migrate flag (line ~183): log.Fatal — correct behavior // 2. Normal startup (line ~191): fmt.Printf("Warning:...") — THIS IS THE BUG // We specifically check the normal startup path. if strings.Contains(src, `fmt.Printf("Warning: Migration failed`) { t.Log("[INFO] [server] [database] F-B1-11 P0 confirmed: normal startup swallows migration errors") } else { t.Error("[ERROR] [server] [database] cannot find the migration error swallowing pattern") } // [OK] is printed unconditionally after the if block migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `Database migrations completed`) if migrationBlock == "" { t.Fatal("[ERROR] [server] [database] cannot find migration block in main.go") } t.Log("[INFO] [server] [database] F-B1-11 P0 confirmed: main.go swallows migration errors and prints [OK]") t.Log("[INFO] [server] [database] after fix: migration failure must call log.Fatal or os.Exit(1)") } // --------------------------------------------------------------------------- // Test 1.3 — main.go MUST abort on migration failure (assert fix) // // Category: FAIL-NOW / PASS-AFTER-FIX // // F-B1-11: After fix, server must refuse to start when migrations fail. // The [OK] message must only print on genuine success. // --------------------------------------------------------------------------- func TestServerMustAbortOnMigrationFailure(t *testing.T) { mainPath := filepath.Join("..", "..", "cmd", "server", "main.go") content, err := os.ReadFile(mainPath) if err != nil { t.Fatalf("failed to read main.go: %v", err) } src := string(content) // The normal startup migration error handler (NOT --migrate flag) should abort // Currently it uses fmt.Printf("Warning:...") and continues if strings.Contains(src, `fmt.Printf("Warning: Migration failed`) { t.Errorf("[ERROR] [server] [database] normal startup swallows migration errors with Warning.\n"+ "F-B1-11 P0: main.go must call log.Fatal or os.Exit(1) on migration failure.\n"+ "The [OK] message must only print on genuine success.") } } // --------------------------------------------------------------------------- // Test 1.4 — Duplicate migration numbers exist (documents F-B1-13) // // Category: PASS-NOW (documents the bug) // --------------------------------------------------------------------------- func TestMigrationRunnerDetectsDuplicateNumbers(t *testing.T) { migrationsPath := filepath.Join("migrations") files, err := os.ReadDir(migrationsPath) if err != nil { t.Fatalf("failed to read migrations directory: %v", err) } // Extract numeric prefixes from .up.sql files prefixCount := make(map[string][]string) for _, file := range files { if !strings.HasSuffix(file.Name(), ".up.sql") { continue } parts := strings.SplitN(file.Name(), "_", 2) if len(parts) >= 1 { prefix := parts[0] prefixCount[prefix] = append(prefixCount[prefix], file.Name()) } } // Document duplicates duplicates := 0 for prefix, names := range prefixCount { if len(names) > 1 { duplicates++ t.Logf("[WARNING] [server] [database] duplicate migration number %s: %v", prefix, names) } } if duplicates == 0 { t.Error("[ERROR] [server] [database] F-B1-13 already fixed: no duplicate migration numbers found") } t.Logf("[INFO] [server] [database] F-B1-13 confirmed: %d duplicate migration numbers found", duplicates) } // --------------------------------------------------------------------------- // Test 1.5 — Runner should reject duplicate numbers (assert fix) // // Category: FAIL-NOW / PASS-AFTER-FIX // --------------------------------------------------------------------------- func TestMigrationRunnerShouldRejectDuplicateNumbers(t *testing.T) { migrationsPath := filepath.Join("migrations") files, err := os.ReadDir(migrationsPath) if err != nil { t.Fatalf("failed to read migrations directory: %v", err) } prefixCount := make(map[string]int) for _, file := range files { if !strings.HasSuffix(file.Name(), ".up.sql") { continue } parts := strings.SplitN(file.Name(), "_", 2) if len(parts) >= 1 { prefixCount[parts[0]]++ } } for prefix, count := range prefixCount { if count > 1 { t.Errorf("[ERROR] [server] [database] migration number %s has %d files.\n"+ "F-B1-13: each migration number must be unique.\n"+ "After fix: renumber or merge duplicate migrations.", prefix, count) } } } // extractBlock extracts text between two markers in a source string func extractBlock(src, startMarker, endMarker string) string { startIdx := strings.Index(src, startMarker) if startIdx == -1 { return "" } endIdx := strings.Index(src[startIdx:], endMarker) if endIdx == -1 { return "" } return src[startIdx : startIdx+endIdx+len(endMarker)] } // sortedKeys returns sorted keys of a map func sortedKeys(m map[string][]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys }