Files
Redflag/aggregator-server/internal/database/migration_runner_test.go
jpetree331 ab676c3b83 test(database): B-1 pre-fix tests for migration and schema bugs
Pre-fix test suite documenting 9 database migration and schema
integrity bugs. Tests FAIL where they assert correct post-fix
behavior, PASS where they document current buggy state.

Tests added:
- F-B1-11 P0: main.go swallows migration errors (3 tests)
- F-B1-13: Duplicate migration numbers 009/012 (2 tests)
- F-B1-1: Migration 024 self-insert into schema_migrations (2 tests)
- F-B1-2: Migration 024 references non-existent column (2 tests)
- F-B1-3: Migration 018 wrong file suffix (2 tests)
- F-B1-4: Migration 018 GRANT to wrong role (1 test)
- F-B1-15: 7+ migrations not idempotent (2 tests)
- F-B1-5: Missing agent_commands sent_at index (2 tests)
- F-B1-6: N+1 query in GetDashboardStats (2 tests)
- F-B1-10: No background refresh token cleanup (2 tests)

Current state: 10 PASS, 10 FAIL, 0 SKIP.
All A-series tests continue to pass (no regressions).
See docs/B1_PreFix_Tests.md for full inventory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 06:42:19 -04:00

241 lines
8.4 KiB
Go

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
}