package updates import ( "testing" "time" "git.golem15.com/golem15/summercms/modules/lagoon" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) const emailVerifiedAtID = "202610210001_add_users_email_verified_at" // emailVerifiedDB opens a dedicated database and a migrator over every // registered migration. func emailVerifiedDB(t *testing.T, name string) (*gorm.DB, *gormigrate.Gormigrate) { t.Helper() gdb, err := lagoon.Use(t.Context(), dedicatedDB(t, name)) if err != nil { t.Fatal(err) } return gdb, gormigrate.New(gdb, &gormigrate.Options{ TableName: "summer_migrations_golem15_user", UseTransaction: true, }, All()) } // TestEmailVerifiedAtMigration pins D-27: users.email_verified_at is a // nullable TIMESTAMPTZ added only when missing, the migration writes no // rows (no backfill, D-26), a column that already exists keeps its values // and the rollback leaves it in place. func TestEmailVerifiedAtMigration(t *testing.T) { t.Run("column_shape", func(t *testing.T) { gdb, m := emailVerifiedDB(t, "user_email_verified_shape") if err := m.Migrate(); err != nil { t.Fatal(err) } var row struct { DataType string IsNullable string } if err := gdb.Raw(`SELECT data_type, is_nullable FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_verified_at'`).Scan(&row).Error; err != nil { t.Fatal(err) } if row.DataType != "timestamp with time zone" || row.IsNullable != "YES" { t.Fatalf("email_verified_at = %+v", row) } }) t.Run("no_backfill", func(t *testing.T) { gdb, m := emailVerifiedDB(t, "user_email_verified_backfill") if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil { t.Fatal(err) } if gdb.Migrator().HasColumn("users", "email_verified_at") { t.Fatal("setup: the column exists before the migration") } for _, email := range []string{"legacy-a@example.test", "legacy-b@example.test"} { if err := gdb.Exec(`INSERT INTO users (email, password, is_activated, has_self_set_password, activated_at) VALUES (?, 'hash', TRUE, TRUE, NOW())`, email).Error; err != nil { t.Fatal(err) } } if err := m.Migrate(); err != nil { t.Fatal(err) } var total, proven int64 if err := gdb.Raw(`SELECT COUNT(*), COUNT(email_verified_at) FROM users`).Row().Scan(&total, &proven); err != nil { t.Fatal(err) } if total != 2 || proven != 0 { t.Fatalf("users %d, with proof %d: the migration must write no rows", total, proven) } }) t.Run("existing_column_kept", func(t *testing.T) { gdb, m := emailVerifiedDB(t, "user_email_verified_existing") if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil { t.Fatal(err) } // A Laravel-derived schema already carries the column and its values. if err := gdb.Exec(`ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ NULL`).Error; err != nil { t.Fatal(err) } want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) if err := gdb.Exec(`INSERT INTO users (email, email_verified_at) VALUES ('imported@example.test', ?)`, want).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`INSERT INTO users (email) VALUES ('imported-unproven@example.test')`).Error; err != nil { t.Fatal(err) } if err := m.Migrate(); err != nil { t.Fatal(err) } var ran int64 if err := gdb.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, emailVerifiedAtID).Scan(&ran).Error; err != nil { t.Fatal(err) } if ran != 1 { t.Fatal("the migration did not run over the existing column") } var got *time.Time if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE email = 'imported@example.test'`).Row().Scan(&got); err != nil { t.Fatal(err) } if got == nil || !got.Equal(want) { t.Fatalf("existing value = %v, want %v", got, want) } var proven int64 if err := gdb.Raw(`SELECT COUNT(email_verified_at) FROM users`).Row().Scan(&proven); err != nil { t.Fatal(err) } if proven != 1 { t.Fatalf("rows with proof = %d, want only the imported one", proven) } }) t.Run("rollback_keeps_column", func(t *testing.T) { gdb, m := emailVerifiedDB(t, "user_email_verified_rollback") if err := m.Migrate(); err != nil { t.Fatal(err) } if err := m.RollbackMigration(usersEmailVerifiedAtMigrations[0]); err != nil { t.Fatal(err) } if !gdb.Migrator().HasColumn("users", "email_verified_at") { t.Fatal("the rollback dropped the shared users column") } }) t.Run("history_once", func(t *testing.T) { n := 0 for _, mig := range All() { if mig.ID == emailVerifiedAtID { n++ } } if n != 1 || len(usersEmailVerifiedAtMigrations) != 1 || usersEmailVerifiedAtMigrations[0].ID != emailVerifiedAtID { t.Fatalf("%s registered %d times", emailVerifiedAtID, n) } }) }