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,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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user