Files
RenewCMS/test/article_slug_backfill_test.go
T

240 lines
7.2 KiB
Go

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)
}
})
}