fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination
This commit is contained in:
1 parent
9f4ce426a8
commit
daf3594316
86 files changed
+4223
-1485
No files matched your search
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user