240 lines
7.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|