package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/infrastructure/persistence/database" entity "RenewCMS/internal/infrastructure/persistence/models" "errors" "fmt" "testing" "time" "gorm.io/gorm" ) const ( backfillArticleCount = 5000 backfillIndexName = "idx_articles_site_slug" ) type backfillWrites struct { updates int rows int64 transactions map[any]bool failFrom int } func watchBackfillWrites(t *testing.T, db *gorm.DB) *backfillWrites { t.Helper() writes := &backfillWrites{transactions: map[any]bool{}} name := fmt.Sprintf("backfill-writes-%s", t.Name()) err := db.Callback().Update().Before("gorm:update").Register(name, func(tx *gorm.DB) { if tx.Statement.Table != "articles" { return } if writes.failFrom > 0 && writes.updates+1 >= writes.failFrom { _ = tx.AddError(errors.New("injected failure")) return } writes.updates++ writes.transactions[tx.Statement.ConnPool] = true }) if err != nil { t.Fatalf("register callback: %v", err) } err = db.Callback().Update().After("gorm:update").Register(name+"-rows", func(tx *gorm.DB) { if tx.Statement.Table == "articles" { writes.rows += tx.RowsAffected } }) if err != nil { t.Fatalf("register callback: %v", err) } t.Cleanup(func() { _ = db.Callback().Update().Remove(name) _ = db.Callback().Update().Remove(name + "-rows") }) return writes } func openLegacyArticleDatabase(t *testing.T) *gorm.DB { t.Helper() db := openUnmigratedSQLite(t) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("migrate: %v", err) } if err := db.Exec("DROP INDEX " + backfillIndexName).Error; err != nil { t.Fatalf("drop index: %v", err) } return db } func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) { t.Helper() if err := db.CreateInBatches(&rows, 100).Error; err != nil { t.Fatalf("seed: %v", err) } } func legacyArticles(count int) []entity.Article { rows := make([]entity.Article, 0, count) for i := 1; i <= count; i++ { rows = append(rows, entity.Article{Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)}) } return rows } type backfillRow struct { Slug string UpdatedAt time.Time } func storedBackfillRows(t *testing.T, db *gorm.DB) map[uint32]backfillRow { t.Helper() var rows []entity.Article if err := db.Unscoped().Find(&rows).Error; err != nil { t.Fatalf("read: %v", err) } found := make(map[uint32]backfillRow, len(rows)) for _, row := range rows { found[row.ID] = backfillRow{Slug: row.Slug, UpdatedAt: row.UpdatedAt} } return found } func countBackfillIndex(t *testing.T, db *gorm.DB) int64 { t.Helper() var count int64 if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?", backfillIndexName).Scan(&count).Error; err != nil { t.Fatalf("index lookup: %v", err) } return count } func TestBackfillArticleSlugs(t *testing.T) { t.Run("A large database is filled in batches", func(t *testing.T) { db := openLegacyArticleDatabase(t) seedLegacyArticles(t, db, legacyArticles(backfillArticleCount)) writes := watchBackfillWrites(t, db) if err := database.BackfillArticleSlugs(db); err != nil { t.Fatalf("backfill: %v", err) } seen := map[string]bool{} for id, row := range storedBackfillRows(t, db) { var siteID uint32 if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", id).Select("site_id").Scan(&siteID).Error; err != nil { t.Fatalf("site: %v", err) } key := fmt.Sprintf("%d/%s", siteID, row.Slug) if row.Slug == "" || seen[key] { t.Fatalf("article %d has an empty or duplicate slug %q", id, row.Slug) } seen[key] = true } if writes.updates != backfillArticleCount || len(writes.transactions) < 2 || len(writes.transactions) > backfillArticleCount/100 { t.Errorf("updates %d in %d transactions", writes.updates, len(writes.transactions)) } }) t.Run("The second boot changes nothing", func(t *testing.T) { db := openLegacyArticleDatabase(t) seedLegacyArticles(t, db, legacyArticles(300)) if err := database.BackfillArticleSlugs(db); err != nil { t.Fatalf("backfill: %v", err) } before := storedBackfillRows(t, db) writes := watchBackfillWrites(t, db) for i := 0; i < 2; i++ { if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("migrate: %v", err) } } after := storedBackfillRows(t, db) for id, row := range before { if after[id] != row { t.Fatalf("article %d changed from %+v to %+v", id, row, after[id]) } } if writes.rows != 0 { t.Errorf("second boot wrote %d rows", writes.rows) } if countBackfillIndex(t, db) != 1 { t.Errorf("%s must exist exactly once", backfillIndexName) } }) t.Run("A crashed backfill resumes", func(t *testing.T) { db := openLegacyArticleDatabase(t) seedLegacyArticles(t, db, legacyArticles(1200)) writes := watchBackfillWrites(t, db) writes.failFrom = 501 if err := database.BackfillArticleSlugs(db); err == nil { t.Fatalf("expected the injected failure") } interrupted := storedBackfillRows(t, db) filled := map[uint32]string{} for id, row := range interrupted { if row.Slug != "" { filled[id] = row.Slug } } if len(filled) == 0 || len(filled) >= 1200 { t.Fatalf("expected a partial run, %d filled", len(filled)) } writes.failFrom = 0 if err := database.BackfillArticleSlugs(db); err != nil { t.Fatalf("resume: %v", err) } for id, row := range storedBackfillRows(t, db) { if row.Slug == "" { t.Fatalf("article %d still empty", id) } if slug, wasFilled := filled[id]; wasFilled && slug != row.Slug { t.Fatalf("article %d changed from %q to %q", id, slug, row.Slug) } } }) t.Run("Duplicates get the first free suffix", func(t *testing.T) { db := openLegacyArticleDatabase(t) seedLegacyArticles(t, db, []entity.Article{ {Title: "Same Title", SiteID: 1}, {Title: "Same Title", SiteID: 1}, {Title: "Same Title", SiteID: 1}, {Title: "Same Title", SiteID: 2}, }) if err := database.BackfillArticleSlugs(db); err != nil { t.Fatalf("backfill: %v", err) } rows := storedBackfillRows(t, db) if rows[1].Slug != "same-title" || rows[2].Slug != "same-title-2" || rows[3].Slug != "same-title-3" || rows[4].Slug != "same-title" { t.Errorf("slugs = %+v", rows) } }) t.Run("Soft-deleted rows get their tombstone once and existing slugs are kept", func(t *testing.T) { db := openLegacyArticleDatabase(t) seedLegacyArticles(t, db, []entity.Article{{Title: "Gone one", SiteID: 1}, {Title: "Gone two", SiteID: 1}, {Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}}) if err := db.Where("id IN ?", []uint32{1, 2}).Delete(&entity.Article{}).Error; err != nil { t.Fatalf("soft delete: %v", err) } if err := database.BackfillArticleSlugs(db); err != nil { t.Fatalf("backfill: %v", err) } first := storedBackfillRows(t, db) if first[1].Slug != article.Tombstone(1) || first[2].Slug != article.Tombstone(2) || first[3].Slug != "chosen-by-owner" { t.Fatalf("slugs = %+v", first) } writes := watchBackfillWrites(t, db) if err := database.BackfillArticleSlugs(db); err != nil { t.Fatalf("second run: %v", err) } if writes.updates != 0 || writes.rows != 0 { t.Errorf("second run wrote %d rows", writes.rows) } }) }