package test import ( "RenewCMS/internal/infrastructure/persistence/database" entity "RenewCMS/internal/infrastructure/persistence/models" "path/filepath" "testing" "gorm.io/gorm" "gorm.io/gorm/logger" ) const ( nullableSourceRevisionColumn = "ALTER TABLE articles ADD COLUMN source_revision BIGINT" legacyRevisionArticles = 3 ) func reopenableSQLite(t *testing.T) (*gorm.DB, func() *gorm.DB) { t.Helper() path := filepath.ToSlash(filepath.Join(t.TempDir(), "revisions.db")) connect := func() *gorm.DB { connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: path}) if err != nil { t.Fatalf("open sqlite: %v", err) } connection.DB.Logger = logger.Discard t.Cleanup(func() { _ = connection.Close() }) return connection.DB } return connect(), connect } func reopenableMySQL(t *testing.T) (*gorm.DB, func() *gorm.DB) { t.Helper() server := connectMySQLTestServer(t) config := server.createDatabase(t) connect := func() *gorm.DB { return server.connect(t, config).DB } return connect(), connect } func reopenablePostgres(t *testing.T) (*gorm.DB, func() *gorm.DB) { t.Helper() server := connectPostgresTestServer(t) config := server.createDatabase(t) connect := func() *gorm.DB { return server.connect(t, config).DB } return connect(), connect } func regressToNullableSourceRevision(t *testing.T, db *gorm.DB) { t.Helper() if err := db.Migrator().DropColumn(&entity.Article{}, "source_revision"); err != nil { t.Fatalf("drop source_revision: %v", err) } if err := db.Exec(nullableSourceRevisionColumn).Error; err != nil { t.Fatalf("add nullable source_revision: %v", err) } } func nullSourceRevisions(t *testing.T, db *gorm.DB) int64 { t.Helper() var total int64 if err := db.Unscoped().Model(&entity.Article{}).Where("source_revision IS NULL").Count(&total).Error; err != nil { t.Fatal(err) } return total } func runSourceRevisionBackfill(t *testing.T, open func(t *testing.T) (*gorm.DB, func() *gorm.DB)) { t.Run("Articles stored before source revisions existed migrate twice", func(t *testing.T) { db, reconnect := open(t) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("first migrate: %v", err) } for index := 0; index < legacyRevisionArticles; index++ { row := entity.Article{Title: "Legacy", Body: "Body", StoryID: fixtureStory(), Slug: uniqueTestSlug(), SourceRevision: 7} if err := db.Create(&row).Error; err != nil { t.Fatal(err) } } regressToNullableSourceRevision(t, db) if nulls := nullSourceRevisions(t, db); nulls != legacyRevisionArticles { t.Fatalf("expected %d NULL source revisions, got %d", legacyRevisionArticles, nulls) } db = reconnect() if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("migrate over NULL source revisions: %v", err) } if nulls := nullSourceRevisions(t, db); nulls != 0 { t.Errorf("%d NULL source revisions survived", nulls) } var revisions []uint32 if err := db.Unscoped().Model(&entity.Article{}).Order("id ASC").Pluck("source_revision", &revisions).Error; err != nil { t.Fatal(err) } if len(revisions) != legacyRevisionArticles { t.Fatalf("revisions = %v", revisions) } for _, revision := range revisions { if revision != 0 { t.Errorf("backfilled source revision = %d, want 0", revision) } } if columns, err := db.Migrator().ColumnTypes(&entity.Article{}); err == nil { for _, column := range columns { if nullable, known := column.Nullable(); column.Name() == "source_revision" && known && nullable { t.Errorf("source_revision is still nullable") } } } db = reconnect() if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("second migrate: %v", err) } if nulls := nullSourceRevisions(t, db); nulls != 0 { t.Errorf("second migrate left %d NULL source revisions", nulls) } }) } func TestSourceRevisionBackfillSQLite(t *testing.T) { runSourceRevisionBackfill(t, reopenableSQLite) } func TestSourceRevisionBackfillMySQL(t *testing.T) { runSourceRevisionBackfill(t, reopenableMySQL) } func TestSourceRevisionBackfillPostgres(t *testing.T) { runSourceRevisionBackfill(t, reopenablePostgres) }