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:
Florian Sylvain committed 2026-10-19 00:25:31 +02:00
1 parent 9f4ce426a8
commit daf3594316
86 files changed
+4223 -1485

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
package test
import (
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"fmt"
"slices"
"testing"
"gorm.io/gorm"
)
const (
chunkedArticleCount = 1200
chunkedCreditedPool = 700
chunkedTitlePrefix = "Chunk"
)
func seedChunkedArticles(t *testing.T, db *gorm.DB, count int) {
t.Helper()
rows := make([]entity.Article, 0, count)
for i := 1; i <= count; i++ {
rows = append(rows, entity.Article{Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true})
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed articles: %v", err)
}
}
func seedChunkedCredits(t *testing.T, db *gorm.DB, articles int) []uint32 {
t.Helper()
authors := make([]entity.Author, 0, 3)
for i := 1; i <= 3; i++ {
authors = append(authors, entity.Author{Name: fmt.Sprintf("Author %d", i), Slug: fmt.Sprintf("author-%d", i), SocialLinks: "[]"})
}
if err := db.Create(&authors).Error; err != nil {
t.Fatalf("seed authors: %v", err)
}
order := []uint32{authors[2].ID, authors[0].ID, authors[1].ID}
credits := make([]entity.ArticleAuthor, 0, articles*3)
for article := 1; article <= articles; article++ {
for _, position := range []int{2, 0, 1} {
credits = append(credits, entity.ArticleAuthor{ArticleID: uint32(article), AuthorID: order[position], Position: position})
}
}
if err := db.CreateInBatches(&credits, 100).Error; err != nil {
t.Fatalf("seed credits: %v", err)
}
return []uint32{order[0], order[1], order[2]}
}
func chunkedFilters(size int) domain.Filters {
return domain.Filters{Q: chunkedTitlePrefix, Page: 1, Size: size, Sort: domain.Sort{Field: domain.SortByID, Direction: domain.SortAsc}}
}
func runChunkedLookups(t *testing.T, db *gorm.DB) {
seedChunkedArticles(t, db, chunkedArticleCount)
repo := persistence.NewArticleRepository(db)
t.Run("A page-sized lookup keeps every row once and in order", func(t *testing.T) {
filters := chunkedFilters(0)
filters.Unpaged = true
page, err := repo.FindPage(filters)
if err != nil || page.Total != chunkedArticleCount || len(page.Items) != chunkedArticleCount {
t.Fatalf("total %d items %d err %v", page.Total, len(page.Items), err)
}
ids := make([]uint32, 0, len(page.Items))
for _, item := range page.Items {
ids = append(ids, item.ID)
}
if !slices.IsSorted(ids) || len(slices.Compact(slices.Clone(ids))) != chunkedArticleCount {
t.Fatalf("ids are not unique and ascending")
}
})
t.Run("Batch boundaries", func(t *testing.T) {
for _, n := range []int{0, 1, 499, 500, 501, 1000, 1001} {
page, err := repo.FindPage(chunkedFilters(n))
if err != nil || len(page.Items) != n || page.Total != chunkedArticleCount {
t.Errorf("n=%d: items %d total %d err %v", n, len(page.Items), page.Total, err)
}
}
})
t.Run("Credits keep their order across batches", func(t *testing.T) {
expected := seedChunkedCredits(t, db, chunkedCreditedPool)
page, err := repo.FindPage(chunkedFilters(chunkedCreditedPool))
if err != nil || len(page.Items) != chunkedCreditedPool {
t.Fatalf("items %d err %v", len(page.Items), err)
}
for _, item := range page.Items {
got := make([]uint32, 0, len(item.Authors))
for _, byline := range item.Authors {
got = append(got, byline.ID)
}
if !slices.Equal(got, expected) {
t.Fatalf("article %d credits %v, expected %v", item.ID, got, expected)
}
}
})
t.Run("Soft-deleted rows follow the existing rule", func(t *testing.T) {
if err := db.Where("id IN ?", []uint32{1, 600, 1200}).Delete(&entity.Article{}).Error; err != nil {
t.Fatalf("soft delete: %v", err)
}
filters := chunkedFilters(0)
filters.Unpaged = true
page, err := repo.FindPage(filters)
if err != nil || len(page.Items) != chunkedArticleCount-3 || page.Total != chunkedArticleCount-3 {
t.Fatalf("items %d total %d err %v", len(page.Items), page.Total, err)
}
for _, item := range page.Items {
if item.ID == 1 || item.ID == 600 || item.ID == 1200 {
t.Fatalf("deleted article %d returned", item.ID)
}
}
})
t.Run("Ids that no longer exist are absent", func(t *testing.T) {
if err := db.Where("id BETWEEN ? AND ?", 101, 200).Delete(&entity.Article{}).Error; err != nil {
t.Fatalf("soft delete: %v", err)
}
page, err := repo.FindPage(chunkedFilters(600))
if err != nil || len(page.Items) != 600 || page.Total != chunkedArticleCount-103 {
t.Fatalf("items %d total %d err %v", len(page.Items), page.Total, err)
}
})
}
func TestChunkedLookups(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
runChunkedLookups(t, db)
}
func TestMySQLChunkedLookups(t *testing.T) {
runChunkedLookups(t, openContractMySQL(t))
}
func TestPostgresChunkedLookups(t *testing.T) {
runChunkedLookups(t, openContractPostgres(t))
}
+239
View File
@@ -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)
}
})
}
+100
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/author"
"fmt"
"log"
"net/http"
"net/http/httptest"
"reflect"
@@ -507,3 +508,102 @@ func TestAuthorsBackendOwnAuthor(t *testing.T) {
}
})
}
func TestAuthorsTotalHeader(t *testing.T) {
seed := func(e *authorEnv, count int) {
for id := uint32(1); id <= uint32(count); id++ {
name := fmt.Sprintf("Writer %02d", id)
if id <= 7 {
name = fmt.Sprintf("Mara %02d", id)
}
e.authors.seed(id, name, fmt.Sprintf("writer-%02d", id))
}
}
t.Run("The total is also sent as a header", func(t *testing.T) {
cases := []struct {
query string
header string
total int
}{{"?size=5", "30", 30}, {"?q=mar", "7", 7}, {"?page=3&size=100", "30", 30}, {"?q=nobody", "0", 0}}
for _, c := range cases {
e := newAuthorEnv(t)
seed(e, 30)
rec := e.owner(t, http.MethodGet, "/v1/author/"+c.query, "")
list := decodeAuthorList(t, rec)
if rec.Header().Get("X-Total-Count") != c.header || list.Total != c.total {
t.Errorf("%s: header %q total %d", c.query, rec.Header().Get("X-Total-Count"), list.Total)
}
}
})
t.Run("Every reader gets the header", func(t *testing.T) {
readers := map[string]func(e *authorEnv) *httptest.ResponseRecorder{
"owner": func(e *authorEnv) *httptest.ResponseRecorder { return e.owner(t, http.MethodGet, "/v1/author/", "") },
"admin": func(e *authorEnv) *httptest.ResponseRecorder {
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "")
},
"editor": func(e *authorEnv) *httptest.ResponseRecorder {
return e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
},
"key": func(e *authorEnv) *httptest.ResponseRecorder { return e.viaKey(t, http.MethodGet, "/v1/author/", "") },
}
for name, send := range readers {
e := newAuthorEnv(t)
seed(e, 4)
rec := send(e)
if rec.Code != http.StatusOK || rec.Header().Get("X-Total-Count") != "4" {
t.Errorf("%s: status %d header %q", name, rec.Code, rec.Header().Get("X-Total-Count"))
}
}
})
t.Run("Refused requests send no header", func(t *testing.T) {
cases := []struct {
query string
send func(e *authorEnv, path string) *httptest.ResponseRecorder
status int
}{
{"?size=30", func(e *authorEnv, path string) *httptest.ResponseRecorder {
return e.owner(t, http.MethodGet, path, "")
}, 400},
{"?page=0", func(e *authorEnv, path string) *httptest.ResponseRecorder {
return e.owner(t, http.MethodGet, path, "")
}, 400},
{"?page=abc", func(e *authorEnv, path string) *httptest.ResponseRecorder {
return e.owner(t, http.MethodGet, path, "")
}, 400},
{"", func(e *authorEnv, path string) *httptest.ResponseRecorder {
return e.as(t, apiKeyQuinnId, http.MethodGet, path, "")
}, 403},
{"", func(e *authorEnv, path string) *httptest.ResponseRecorder {
return e.anonymous(t, http.MethodGet, path, "")
}, 401},
}
for _, c := range cases {
e := newAuthorEnv(t)
seed(e, 4)
rec := c.send(e, "/v1/author/"+c.query)
if _, present := rec.Header()["X-Total-Count"]; rec.Code != c.status || present {
t.Errorf("%q: status %d header present %v", c.query, rec.Code, present)
}
}
})
}
func TestAuthorsResponseWriteFailureIsLogged(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(1, "Writer 01", "writer-01")
var logOutput strings.Builder
defaultLogOutput := log.Writer()
log.SetOutput(&logOutput)
defer log.SetOutput(defaultLogOutput)
req := httptest.NewRequest(http.MethodGet, "/v1/author/", nil)
req.AddCookie(e.cookie(t, apiKeyOwnerId, 0))
e.router.ServeHTTP(&erroringResponseWriter{}, req)
if !strings.Contains(logOutput.String(), "Failed to write response") {
t.Errorf("expected the write failure to be logged, got %q", logOutput.String())
}
}
+123
View File
@@ -0,0 +1,123 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/user"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
const mediaGetEditorID = 9
func newMediaGetEnv(t *testing.T) *siteEnv {
t.Helper()
e := newSiteEnvWithMain(t)
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor")}
e.seedImageRecord(5)
return e
}
func (e *siteEnv) seedImageRecord(id uint32) {
e.seedImage(id)
img := e.images.images[id]
img.Size, img.Width, img.Height, img.SHA256 = 2048, 800, 600, hashA
e.images.images[id] = img
e.seedSpring(1, `<img src="`+img.Path+`">`, true)
}
func TestMediaGetOne(t *testing.T) {
t.Run("Who can read one image", func(t *testing.T) {
cases := []struct {
credentials string
send func(e *siteEnv) *httptest.ResponseRecorder
status int
}{
{"the owner's jwt cookie", func(e *siteEnv) *httptest.ResponseRecorder {
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/5", "")
}, http.StatusOK},
{"a verified admin's jwt cookie", func(e *siteEnv) *httptest.ResponseRecorder {
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5", "")
}, http.StatusOK},
{"a verified account with another role", func(e *siteEnv) *httptest.ResponseRecorder {
return e.as(t, mediaGetEditorID, http.MethodGet, "/v1/media/5", "")
}, http.StatusOK},
{"a valid API key", func(e *siteEnv) *httptest.ResponseRecorder {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodGet, "/v1/media/5", "")
}, http.StatusOK},
{"an unverified account's jwt cookie", func(e *siteEnv) *httptest.ResponseRecorder {
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/media/5", "")
}, http.StatusForbidden},
{"no credentials", func(e *siteEnv) *httptest.ResponseRecorder {
return e.anonymous(t, http.MethodGet, "/v1/media/5", "")
}, http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.credentials, func(t *testing.T) {
e := newMediaGetEnv(t)
if rec := c.send(e); rec.Code != c.status {
t.Errorf("status = %d, expected %d", rec.Code, c.status)
}
})
}
})
t.Run("The item has the shape of the list", func(t *testing.T) {
e := newMediaGetEnv(t)
single := e.owner(t, http.MethodGet, "/v1/media/5", "")
listed := e.owner(t, http.MethodGet, "/v1/media/", "")
var one map[string]any
var page struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(single.Body.Bytes(), &one); err != nil {
t.Fatalf("decode item: %v", err)
}
if err := json.Unmarshal(listed.Body.Bytes(), &page); err != nil {
t.Fatalf("decode list: %v", err)
}
var expected map[string]any
for _, item := range page.Items {
if item["id"] == 5.0 {
expected = item
}
}
if expected == nil || !reflect.DeepEqual(one, expected) {
t.Fatalf("item %v, list element %v", one, expected)
}
if one["used_in"] != 1.0 || one["size"] != 2048.0 {
t.Errorf("item = %v", one)
}
})
t.Run("A missing image is not found", func(t *testing.T) {
e := newMediaGetEnv(t)
rec := e.owner(t, http.MethodGet, "/v1/media/999", "")
expectSiteStatusAndBody(t, rec, http.StatusNotFound, apperr.ImageNotFoundErrorMessage)
})
t.Run("A malformed id is refused", func(t *testing.T) {
for _, id := range []string{"abc", "-1", "4294967296", "1.5"} {
e := newMediaGetEnv(t)
rec := e.owner(t, http.MethodGet, "/v1/media/"+id, "")
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
}
})
t.Run("Reading one image records nothing", func(t *testing.T) {
e := newMediaGetEnv(t)
e.owner(t, http.MethodGet, "/v1/media/5", "")
e.noEvent(t)
})
t.Run("The route does not shadow the usage route", func(t *testing.T) {
e := newMediaGetEnv(t)
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || rec.Body.String() == "" {
t.Fatalf("status %d", rec.Code)
}
})
}
+11
View File
@@ -347,3 +347,14 @@ func TestListMediaUseCasePaging(t *testing.T) {
}
})
}
func TestMediaPaginationHugePage(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 3)
got, _ := env.list(t, "?page=138000000000000001&size=100")
if got.Total != 3 || len(got.Items) != 0 || got.Page != 138000000000000001 || got.Size != 100 {
t.Errorf("unexpected envelope total=%d items=%d page=%d size=%d", got.Total, len(got.Items), got.Page, got.Size)
}
}
+71
View File
@@ -0,0 +1,71 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"fmt"
"strings"
"testing"
"time"
)
const (
seoPerformanceArticles = 5000
seoPerformanceBudget = 300 * time.Millisecond
seoPerformanceCeiling = 500 * time.Millisecond
)
func TestSeoFilterPerformance(t *testing.T) {
if testing.Short() {
t.Skip("measures 5,000 articles on SQLite")
}
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
body := strings.Repeat("<p>"+strings.Repeat("word ", 120)+"</p>", 8)
rows := make([]entity.Article, 0, seoPerformanceArticles)
for i := 1; i <= seoPerformanceArticles; i++ {
row := entity.Article{Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"}
if i%2 == 0 {
row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100)
}
rows = append(rows, row)
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed: %v", err)
}
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db))
filtered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
unfiltered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, Sort: article.DefaultSort()}
slowestFiltered, page := slowestOfThree(t, list, filtered)
slowestUnfiltered, _ := slowestOfThree(t, list, unfiltered)
t.Logf("GET /v1/article/?seo=suggestions over %d articles in one site: slowest of 3 runs %d ms (budget %d ms), total %d, counts %+v", seoPerformanceArticles, slowestFiltered.Milliseconds(), seoPerformanceBudget.Milliseconds(), page.Total, page.Counts)
t.Logf("GET /v1/article/ (default page) over %d articles in one site: slowest of 3 runs %d ms (budget %d ms)", seoPerformanceArticles, slowestUnfiltered.Milliseconds(), seoPerformanceBudget.Milliseconds())
if slowestFiltered > seoPerformanceCeiling {
t.Errorf("the seo filter took %v, above %v", slowestFiltered, seoPerformanceCeiling)
}
if slowestUnfiltered > seoPerformanceCeiling {
t.Errorf("the unfiltered list took %v, above %v", slowestUnfiltered, seoPerformanceCeiling)
}
}
func slowestOfThree(t *testing.T, list *useCases.ListArticlesUseCase, command useCases.ListArticlesCommand) (time.Duration, useCases.ArticlePage) {
t.Helper()
slowest := time.Duration(0)
var page useCases.ArticlePage
for run := 0; run < 3; run++ {
started := time.Now()
found, err := list.ListArticlesPage(command)
if err != nil {
t.Fatalf("list: %v", err)
}
page = found
slowest = max(slowest, time.Since(started))
}
return slowest, page
}
+62 -2
View File
@@ -164,7 +164,7 @@ func TestSeoOutputList(t *testing.T) {
})
}
list := e.list(t, "owner", "?seo=suggestions")
if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0}) || list["total"] != 3.0 {
if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0, "suggestions": 3.0}) || list["total"] != 3.0 {
t.Errorf("list = %v %v", list["counts"], list["total"])
}
})
@@ -227,8 +227,68 @@ func TestSeoOutputList(t *testing.T) {
t.Run("The existing list behaviour is unchanged without the filter", func(t *testing.T) {
e := newSeoOutEnv(t)
list := e.list(t, "owner", "")
if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0}) {
if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0, "suggestions": 2.0}) {
t.Errorf("list = %v", list)
}
})
t.Run("counts.suggestions", func(t *testing.T) {
t.Run("It counts visible suggestions and equals the filter total", func(t *testing.T) {
e := newSeoOutEnv(t)
e.seedFourListed()
list := e.list(t, "owner", "")
filtered := e.list(t, "owner", "?seo=suggestions")
counts := list["counts"].(map[string]any)
if counts["suggestions"] != 2.0 || filtered["total"] != 2.0 || filtered["counts"].(map[string]any)["suggestions"] != 2.0 {
t.Errorf("counts = %v filtered = %v", counts, filtered["counts"])
}
})
t.Run("It is zero when nothing needs attention", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
for id := uint32(1); id <= 5; id++ {
e.seedListed(id, "Clean"+strconv.Itoa(int(id)), true, nil)
}
if got := e.list(t, "owner", "")["counts"].(map[string]any)["suggestions"]; got != 0.0 {
t.Errorf("suggestions = %v", got)
}
})
t.Run("It is scoped to the caller's visibility", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
e.seedListed(1, "Offline", false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
e.seedListed(2, "Online", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
if got := e.list(t, "", "")["counts"].(map[string]any)["suggestions"]; got != 1.0 {
t.Errorf("anonymous suggestions = %v", got)
}
if got := e.list(t, "owner", "")["counts"].(map[string]any)["suggestions"]; got != 2.0 {
t.Errorf("owner suggestions = %v", got)
}
})
t.Run("It ignores the other query parameters", func(t *testing.T) {
e := newSeoOutEnv(t)
e.seedFourListed()
for _, query := range []string{"?q=nomatch", "?status=draft", "?seo=ready", "?size=5&page=9", "?sort=title:asc"} {
if got := e.list(t, "owner", query)["counts"].(map[string]any)["suggestions"]; got != 2.0 {
t.Errorf("%s: suggestions = %v", query, got)
}
}
})
t.Run("Hidden and dismissed articles are not counted", func(t *testing.T) {
e := newSeoOutEnv(t)
e.cleanArticles()
e.seedListed(1, "Dismissed", true, func(a *article.Article) {
a.CoverImageID, a.CoverImageAlt = nil, ""
a.SEODismissed = []string{"cover_none"}
})
e.seedListed(2, "Hidden", true, func(a *article.Article) { a.Noindex = true; a.CoverImageID, a.CoverImageAlt = nil, "" })
if got := e.list(t, "owner", "")["counts"].(map[string]any)["suggestions"]; got != 0.0 {
t.Errorf("suggestions = %v", got)
}
})
})
}
+53
View File
@@ -0,0 +1,53 @@
package test
import (
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"fmt"
"testing"
)
func TestSiteRepositoryFind(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, "https://renew.example.com"); err != nil {
t.Fatalf("migrate: %v", err)
}
repo := persistence.NewSiteRepository(db)
for i := 0; i < 12; i++ {
_, err := repo.Create(site.Site{Name: fmt.Sprintf("Shop %02d", i), Key: fmt.Sprintf("shop%02d", i), BaseURL: "https://shop.example.net", DefaultRobots: site.RobotsIndex})
if err != nil {
t.Fatalf("create: %v", err)
}
}
if _, err := repo.Create(site.Site{Name: "100% Café", Key: "cafe", BaseURL: "https://cafe.example.net", DefaultRobots: site.RobotsIndex}); err != nil {
t.Fatalf("create: %v", err)
}
t.Run("Unpaged returns everything with the default first", func(t *testing.T) {
page, err := repo.Find(site.ListQuery{})
if err != nil || page.Total != 14 || len(page.Items) != 14 || !page.Items[0].IsDefault {
t.Fatalf("page %+v err %v", page.Total, err)
}
})
t.Run("Paging slices after the search", func(t *testing.T) {
page, err := repo.Find(site.ListQuery{Q: "SHOP", Page: 2, Size: 5})
if err != nil || page.Total != 12 || len(page.Items) != 5 || page.Items[0].Name != "Shop 05" {
t.Fatalf("page %+v err %v", page, err)
}
past, err := repo.Find(site.ListQuery{Q: "shop", Page: 9, Size: 5})
if err != nil || past.Total != 12 || past.Items == nil || len(past.Items) != 0 {
t.Fatalf("past %+v err %v", past, err)
}
})
t.Run("Wildcards are literal and accents are ignored", func(t *testing.T) {
for _, q := range []string{"%", "cafe", "CAFÉ"} {
page, err := repo.Find(site.ListQuery{Q: q})
if err != nil || page.Total != 1 || page.Items[0].Key != "cafe" {
t.Fatalf("%q: %+v err %v", q, page, err)
}
}
})
}
+256
View File
@@ -0,0 +1,256 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/site"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strconv"
"strings"
"testing"
)
func seedPagedSites(e *siteEnv, count int, shops int) {
e.sites.seed(1, "Renew", "renew", true)
for id := uint32(2); id <= uint32(count); id++ {
name := fmt.Sprintf("Site %02d", id)
key := fmt.Sprintf("site%02d", id)
if int(id) <= shops+1 {
name = fmt.Sprintf("Shop %02d", id)
key = fmt.Sprintf("shop%02d", id)
}
e.sites.seed(id, name, key, false)
}
}
func siteNames(sites []site.Site) []string {
names := []string{}
for _, s := range sites {
names = append(names, s.Name)
}
return names
}
func siteIDs(sites []site.Site) []uint32 {
ids := []uint32{}
for _, s := range sites {
ids = append(ids, s.ID)
}
return ids
}
func TestSitesPagination(t *testing.T) {
list := func(t *testing.T, e *siteEnv, query string) (*httptest.ResponseRecorder, []site.Site) {
t.Helper()
rec := e.owner(t, http.MethodGet, "/v1/site/"+query, "")
if rec.Code != http.StatusOK {
return rec, nil
}
return rec, decodeSiteList(t, rec)
}
t.Run("With no paging parameter every site is returned", func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 61, 0)
rec, sites := list(t, e, "")
if len(sites) != 61 || sites[0].Name != "Renew" || rec.Header().Get("X-Total-Count") != "61" {
t.Fatalf("got %d sites, first %q, total %q", len(sites), sites[0].Name, rec.Header().Get("X-Total-Count"))
}
})
t.Run("q alone returns every match", func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 61, 40)
rec, sites := list(t, e, "?q=shop")
if len(sites) != 40 || rec.Header().Get("X-Total-Count") != "40" {
t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count"))
}
})
t.Run("Paging and sizes", func(t *testing.T) {
cases := []struct {
params string
count int
}{{"page=1", 25}, {"page=2", 25}, {"page=3", 11}, {"size=5", 5}, {"size=25", 25}, {"size=50&page=2", 11}, {"size=100", 61}}
for _, c := range cases {
t.Run(c.params, func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 61, 0)
rec, sites := list(t, e, "?"+c.params)
if len(sites) != c.count || rec.Header().Get("X-Total-Count") != "61" {
t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count"))
}
})
}
})
t.Run("Pages never overlap and keep the order", func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 61, 0)
_, unpaged := list(t, e, "")
joined := []site.Site{}
for page := 1; page <= 3; page++ {
_, part := list(t, e, "?page="+strconv.Itoa(page))
joined = append(joined, part...)
}
if !reflect.DeepEqual(siteIDs(joined), siteIDs(unpaged)) || joined[0].Name != "Renew" {
t.Fatalf("pages differ from the unpaged list")
}
})
t.Run("A page past the end is an empty array with the total", func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 61, 0)
rec, _ := list(t, e, "?page=99")
if strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "61" {
t.Fatalf("body %q total %q", rec.Body.String(), rec.Header().Get("X-Total-Count"))
}
})
t.Run("No match is an empty array", func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 3, 0)
rec, _ := list(t, e, "?q=nomatch")
if strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" {
t.Fatalf("body %q total %q", rec.Body.String(), rec.Header().Get("X-Total-Count"))
}
})
t.Run("Search matches name, key and base URL", func(t *testing.T) {
cases := []struct {
q string
names []string
}{
{"renew", []string{"Renew"}},
{"JOURNAL", []string{"Journal"}},
{"blog", []string{"Journal"}},
{"example.org", []string{"Journal"}},
{"example", []string{"Renew", "Archive", "Journal"}},
{"nobody", []string{}},
}
for _, c := range cases {
t.Run(c.q, func(t *testing.T) {
e := newSiteEnv(t)
e.sites.seed(1, "Renew", "renew", true)
journal := e.sites.seed(2, "Journal", "blog", false)
journal.BaseURL = "https://journal.example.org"
e.sites.sites[2] = journal
archive := e.sites.seed(3, "Archive", "archive", false)
archive.BaseURL = "https://old.example.net"
e.sites.sites[3] = archive
rec, sites := list(t, e, "?q="+url.QueryEscape(c.q))
if !reflect.DeepEqual(siteNames(sites), c.names) || rec.Header().Get("X-Total-Count") != strconv.Itoa(len(c.names)) {
t.Fatalf("names %v total %q", siteNames(sites), rec.Header().Get("X-Total-Count"))
}
})
}
})
t.Run("Search ignores accents", func(t *testing.T) {
e := newSiteEnv(t)
e.sites.seed(1, "Café", "cafe", true)
for _, q := range []string{"cafe", "CAFÉ"} {
rec, sites := list(t, e, "?q="+url.QueryEscape(q))
if !reflect.DeepEqual(siteNames(sites), []string{"Café"}) || rec.Header().Get("X-Total-Count") != "1" {
t.Fatalf("%s: names %v", q, siteNames(sites))
}
}
})
t.Run("Search and paging compose and the total follows the search", func(t *testing.T) {
e := newSiteEnv(t)
seedPagedSites(e, 61, 27)
rec, sites := list(t, e, "?q=shop&page=2")
if len(sites) != 2 || rec.Header().Get("X-Total-Count") != "27" {
t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count"))
}
})
t.Run("The default site is not forced into the results", func(t *testing.T) {
e := newSiteEnv(t)
e.sites.seed(1, "Renew", "renew", true)
e.sites.seed(2, "Journal", "blog", false)
_, sites := list(t, e, "?q=journal")
if !reflect.DeepEqual(siteNames(sites), []string{"Journal"}) {
t.Fatalf("names %v", siteNames(sites))
}
})
t.Run("A wildcard in q is literal", func(t *testing.T) {
e := newSiteEnv(t)
e.sites.seed(1, "Main", "main", true)
e.sites.seed(2, "100% Shop", "shop", false)
_, sites := list(t, e, "?q=%25")
if !reflect.DeepEqual(siteNames(sites), []string{"100% Shop"}) {
t.Fatalf("names %v", siteNames(sites))
}
})
t.Run("Invalid page, size or q", func(t *testing.T) {
for _, params := range []string{"page=0", "page=-1", "page=abc", "page=", "size=0", "size=30", "size=101", "size=abc", "size=", "q=" + strings.Repeat("a", 201)} {
t.Run(params[:min(len(params), 12)], func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodGet, "/v1/site/?"+params, "")
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if _, present := rec.Header()["X-Total-Count"]; present {
t.Errorf("X-Total-Count sent on a refusal")
}
})
}
})
t.Run("q at the limit is accepted", func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec, _ := list(t, e, "?q="+strings.Repeat("a", 200))
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
})
t.Run("The other fields are unchanged", func(t *testing.T) {
e := newSiteEnvWithMain(t)
e.seedArticle(1, siteMainID, true)
rec, sites := list(t, e, "?size=5")
if len(sites) != 1 || sites[0].ArticleCount != 1 || !strings.Contains(rec.Body.String(), `"article_count"`) {
t.Fatalf("body %s", rec.Body.String())
}
})
t.Run("Readers and refusals", func(t *testing.T) {
cases := []struct {
credentials string
send func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder
status int
}{
{"the owner's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/site/?size=5", "")
}, http.StatusOK},
{"a verified admin's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/?size=5", "")
}, http.StatusOK},
{"a valid API key", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodGet, "/v1/site/?size=5", "")
}, http.StatusOK},
{"an unverified account's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/site/?size=5", "")
}, http.StatusForbidden},
{"no credentials", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.anonymous(t, http.MethodGet, "/v1/site/?size=5", "")
}, http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.credentials, func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := c.send(e, t)
_, present := rec.Header()["X-Total-Count"]
if rec.Code != c.status || present != (c.status == http.StatusOK) {
t.Errorf("status %d, header present %v", rec.Code, present)
}
})
}
})
}
+23
View File
@@ -92,6 +92,29 @@ func (m *mockSiteRepo) List() ([]site.Site, error) {
return result, nil
}
func (m *mockSiteRepo) Find(query site.ListQuery) (site.Page, error) {
if err := m.fail.trigger("Find"); err != nil {
return site.Page{}, err
}
all, err := m.List()
if err != nil {
return site.Page{}, err
}
needle := foldArticleSearch(strings.TrimSpace(query.Q))
matching := []site.Site{}
for _, s := range all {
if needle == "" || strings.Contains(foldArticleSearch(s.Name+"\n"+s.Key+"\n"+s.BaseURL), needle) {
matching = append(matching, s)
}
}
if query.Size <= 0 {
return site.Page{Items: matching, Total: len(matching)}, nil
}
start := min((max(query.Page, 1)-1)*query.Size, len(matching))
end := min(start+query.Size, len(matching))
return site.Page{Items: matching[start:end], Total: len(matching)}, nil
}
func (m *mockSiteRepo) Get(id uint32) (site.Site, error) {
if err := m.fail.trigger("Get"); err != nil {
return site.Site{}, err