refac: article reads, story delete, no leaks, stored SEO status, rate limit, cleanup (%site%, article repo split, old lang repos, one default per site rule to domain)
This commit is contained in:
1 parent
9d284e65a9
commit
7c15206170
85 files changed
+1788
-593
No files matched your search
@@ -24,10 +24,9 @@ func newCreateSiteWorld(t *testing.T) *storyWorld {
|
||||
w.sites.seed(createSiteNotes, "Notes", "notes", false)
|
||||
w.sites.seed(createSiteArchive, "Archive", "archive", false)
|
||||
w.sites.seed(createSiteFresh, "Fresh", "fresh", false)
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
|
||||
w.rows.rows[storyAtelier] = storyRows("fr", []string{"en", "fr", "de"}, nil)
|
||||
w.rows.rows[createSiteNotes] = storyRows("en", []string{"en"}, nil)
|
||||
w.rows.rows[createSiteArchive] = storyRows("en", []string{"en"}, nil)
|
||||
w.transactor.starting = map[uint32]string{storyJournal: "en", storyAtelier: "fr", createSiteNotes: "en", createSiteArchive: "en"}
|
||||
return w
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package test
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
@@ -10,35 +11,60 @@ import (
|
||||
)
|
||||
|
||||
type mockStartingLanguages struct {
|
||||
site.Repository
|
||||
confirmed map[uint32]string
|
||||
locked []uint32
|
||||
failWith error
|
||||
removed map[uint32]bool
|
||||
}
|
||||
|
||||
var _ language.Repository = &mockStartingLanguages{}
|
||||
var _ localization.Repository = &mockStartingLanguages{}
|
||||
|
||||
func (m *mockStartingLanguages) ListBySite(siteID uint32) ([]language.SiteLanguage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockStartingLanguages) MarkConfirmed(siteID uint32, tag string) error {
|
||||
func (m *mockStartingLanguages) LockSite(siteID uint32) error {
|
||||
m.locked = append(m.locked, siteID)
|
||||
if m.failWith != nil {
|
||||
return m.failWith
|
||||
}
|
||||
if m.removed[siteID] {
|
||||
return site.ErrSiteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStartingLanguages) LockSiteLanguages(siteID uint32) ([]language.SiteLanguage, error) {
|
||||
m.locked = append(m.locked, siteID)
|
||||
if m.failWith != nil {
|
||||
return nil, m.failWith
|
||||
}
|
||||
if m.removed[siteID] {
|
||||
return nil, site.ErrSiteNotFound
|
||||
}
|
||||
func (m *mockStartingLanguages) Rows(siteID uint32) ([]localization.Row, error) {
|
||||
tag, confirmed := m.confirmed[siteID]
|
||||
if !confirmed || tag == language.Unassigned {
|
||||
return nil, nil
|
||||
}
|
||||
return []language.SiteLanguage{{SiteID: siteID, Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}}, nil
|
||||
return []localization.Row{{Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}}, nil
|
||||
}
|
||||
|
||||
func (m *mockStartingLanguages) ConfirmedSites() (map[uint32]bool, error) {
|
||||
return map[uint32]bool{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStartingLanguages) VersionCounts(siteID uint32) (map[string]localization.VersionCount, error) {
|
||||
return map[string]localization.VersionCount{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStartingLanguages) Replace(siteID uint32, rows []localization.Row) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStartingLanguages) ConfirmSetup(siteID uint32, tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type removableSites struct {
|
||||
*mockSiteRepo
|
||||
removed map[uint32]bool
|
||||
}
|
||||
|
||||
func (r removableSites) LockSite(siteID uint32) error {
|
||||
if r.removed[siteID] {
|
||||
return site.ErrSiteNotFound
|
||||
}
|
||||
return r.mockSiteRepo.LockSite(siteID)
|
||||
}
|
||||
|
||||
type mockCreateTransactor struct {
|
||||
@@ -51,7 +77,7 @@ var _ transaction.Transactor = &mockCreateTransactor{}
|
||||
|
||||
func (m *mockCreateTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
|
||||
m.runs++
|
||||
return work(transaction.Repositories{Articles: m.articles, Languages: m.languages})
|
||||
return work(transaction.Repositories{Articles: m.articles, Sites: m.languages, Localization: m.languages})
|
||||
}
|
||||
|
||||
func passthroughCreateTransactor(articles article.Repository) *mockCreateTransactor {
|
||||
|
||||
@@ -224,6 +224,9 @@ func TestArticleCreatedByHttp(t *testing.T) {
|
||||
delete(signedIn, "created_by")
|
||||
delete(signedIn, "capabilities")
|
||||
delete(signedIn, "translations")
|
||||
for _, editorial := range []string{"source_unavailable", "missing_langs", "excluded_from_public", "partial_view"} {
|
||||
delete(signedIn, editorial)
|
||||
}
|
||||
delete(anonymous, "translations")
|
||||
delete(anonymous, "capabilities")
|
||||
if len(anonymous) != len(signedIn) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func editorialField[T any](t *testing.T, response storyResponse, key string) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
if err := json.Unmarshal(response.fields(t)[key], &value); err != nil {
|
||||
t.Fatalf("%s: %v in %s", key, err, response.text())
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func translationEntry(t *testing.T, response storyResponse, id string) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
for _, entry := range translationsOf(t, response) {
|
||||
if string(entry["id"]) == id {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
t.Fatalf("no translation %s in %s", id, response.text())
|
||||
return nil
|
||||
}
|
||||
|
||||
func putTrackingDraft(w *storyWorld) {
|
||||
source := uint32(12)
|
||||
w.put(article.Article{ID: 14, Title: "Costes", CreatedBy: storyRestrict, Slug: "costes", Lang: "es", StoryID: storyStory, SourceArticleID: &source, SourceRevision: 2})
|
||||
}
|
||||
|
||||
func TestArticleEditorialContextHTTP(t *testing.T) {
|
||||
t.Run("Missing languages are the enabled languages without a readable version", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
cases := []struct {
|
||||
userID uint32
|
||||
missing []string
|
||||
}{
|
||||
{storyOwner, []string{}},
|
||||
{storyRestrict, []string{"de", "fr"}},
|
||||
{storyReader, []string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := editorialField[[]string](t, w.send(c.userID, http.MethodGet, "/v1/article/10", ""), "missing_langs")
|
||||
if len(got) != len(c.missing) || len(got) > 0 && (got[0] != c.missing[0] || got[1] != c.missing[1]) {
|
||||
t.Errorf("user %d: missing_langs %v, want %v", c.userID, got, c.missing)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A version in a disabled language or without language is left out of public surfaces", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
putTrackingDraft(w)
|
||||
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: storyWriter1, Slug: "unassigned", StoryID: 30, IsOnline: true})
|
||||
|
||||
owner := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
|
||||
if !editorialField[bool](t, owner, "excluded_from_public") || string(translationEntry(t, owner, "14")["excluded_from_public"]) != "true" || string(translationEntry(t, owner, "10")["excluded_from_public"]) != "false" {
|
||||
t.Errorf("es must be left out, en kept: %s", owner.text())
|
||||
}
|
||||
if !editorialField[bool](t, w.send(storyOwner, http.MethodGet, "/v1/article/30", ""), "excluded_from_public") {
|
||||
t.Error("a version without language must be left out of the public language surfaces")
|
||||
}
|
||||
if editorialField[bool](t, w.send(storyOwner, http.MethodGet, "/v1/article/10", ""), "excluded_from_public") {
|
||||
t.Error("an enabled language must not be left out")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A deleted source and an unreadable source read the same", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
putTrackingDraft(w)
|
||||
|
||||
tracked := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
|
||||
unreadable := w.send(storyRestrict, http.MethodGet, "/v1/article/14", "")
|
||||
delete(w.articles.articles, 12)
|
||||
deleted := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
|
||||
|
||||
if string(tracked.fields(t)["source"]) != `{"article_id":12,"revision":2}` || editorialField[bool](t, tracked, "source_unavailable") {
|
||||
t.Fatalf("a readable source is shown: %s", tracked.text())
|
||||
}
|
||||
for name, response := range map[string]storyResponse{"unreadable": unreadable, "deleted": deleted} {
|
||||
if string(response.fields(t)["source"]) != "null" || !editorialField[bool](t, response, "source_unavailable") {
|
||||
t.Errorf("%s source: %s", name, response.text())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Translations name the source they track only when it is readable", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
putTrackingDraft(w)
|
||||
|
||||
owner := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
|
||||
restricted := w.send(storyRestrict, http.MethodGet, "/v1/article/14", "")
|
||||
|
||||
if string(translationEntry(t, owner, "14")["source_article_id"]) != "12" || string(translationEntry(t, owner, "10")["source_article_id"]) != "null" {
|
||||
t.Errorf("owner translations: %s", owner.text())
|
||||
}
|
||||
if string(translationEntry(t, restricted, "14")["source_article_id"]) != "null" {
|
||||
t.Errorf("an unreadable source is named: %s", restricted.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Partial view follows the read scope of the caller only", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
if editorialField[bool](t, w.send(storyOwner, http.MethodGet, "/v1/article/10", ""), "partial_view") {
|
||||
t.Error("a caller who reads every version has a complete view")
|
||||
}
|
||||
if !editorialField[bool](t, w.send(storyRestrict, http.MethodGet, "/v1/article/13", ""), "partial_view") {
|
||||
t.Error("a caller limited to own versions has a partial view")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Anonymous reads carry no editorial keys", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
fields := w.send(storyAnon, http.MethodGet, "/v1/article/10", "").fields(t)
|
||||
|
||||
for _, key := range []string{"missing_langs", "excluded_from_public", "source_unavailable", "partial_view"} {
|
||||
if _, present := fields[key]; present {
|
||||
t.Errorf("anonymous read carries %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -628,11 +628,14 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
if missing.Total != len(wanted) || missing.Counts["missing"] != len(wanted) || all.Counts["missing"] != len(wanted) {
|
||||
t.Fatalf("total=%d counts=%v, want %d", missing.Total, missing.Counts, len(wanted))
|
||||
}
|
||||
for _, mixed := range []int{31, 33, 35} {
|
||||
if missing.item(mixed) != nil {
|
||||
t.Fatalf("story %d holding an unassigned version listed as needing translation", mixed)
|
||||
for _, mixed := range []int{31, 33} {
|
||||
if missing.item(mixed) == nil {
|
||||
t.Fatalf("mixed story %d must be listed as needing translation", mixed)
|
||||
}
|
||||
}
|
||||
if missing.item(35) != nil {
|
||||
t.Fatal("a story with only an unassigned version must never be listed as needing translation")
|
||||
}
|
||||
for _, tag := range []string{"fr", "de", "ar", "en"} {
|
||||
_, notIn := env.get(localizationOwnerID, "?group=story&lang="+tag+"&tab=missing&size=100")
|
||||
expected := []int{}
|
||||
@@ -655,8 +658,8 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
env := base(t)
|
||||
env.siteRepo.lists = 0
|
||||
_, body := env.get(localizationOwnerID, "?group=story&size=100")
|
||||
if len(body.Items) == 0 || env.siteRepo.lists != 1 {
|
||||
t.Fatalf("items=%d site lists=%d, want rows and 1", len(body.Items), env.siteRepo.lists)
|
||||
if len(body.Items) == 0 || env.siteRepo.lists > 1 {
|
||||
t.Fatalf("items=%d site lists=%d, want rows and at most 1", len(body.Items), env.siteRepo.lists)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -697,7 +700,7 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
})
|
||||
|
||||
t.Run("Repository failure answers 500", func(t *testing.T) {
|
||||
for _, method := range []string{"FindStoryPage", "CountStories", "FindPage"} {
|
||||
for _, method := range []string{"FindStoryPage", "CountStories", "ListVersions"} {
|
||||
env := base(t)
|
||||
env.repo.failMethod = method
|
||||
rec, _ := env.get(localizationOwnerID, "?group=story")
|
||||
|
||||
@@ -86,6 +86,13 @@ func (r *storyListingRepository) CountStories(filters domainArticle.StoryFilters
|
||||
return r.ArticleRepository.CountStories(filters)
|
||||
}
|
||||
|
||||
func (r *storyListingRepository) ListVersions(ids []uint32) ([]domainArticle.Article, error) {
|
||||
if err := r.failure("ListVersions"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.ArticleRepository.ListVersions(ids)
|
||||
}
|
||||
|
||||
type listCountingSites struct {
|
||||
*persistence.SiteRepository
|
||||
lists int
|
||||
|
||||
@@ -25,6 +25,13 @@ type countingStoryRepository struct {
|
||||
*persistence.ArticleRepository
|
||||
titleSearches int
|
||||
pageLoads int
|
||||
staleReads int
|
||||
}
|
||||
|
||||
func (r *countingStoryRepository) StaleSEO(afterID uint32, limit int) ([]domainArticle.Article, error) {
|
||||
stale, err := r.ArticleRepository.StaleSEO(afterID, limit)
|
||||
r.staleReads += len(stale)
|
||||
return stale, err
|
||||
}
|
||||
|
||||
func (r *countingStoryRepository) FindTitleMatches(q string, readable domainArticle.StoryFilters) ([]uint32, error) {
|
||||
@@ -78,7 +85,14 @@ func TestArticleStoryListSEOFilterAtScaleSQLite(t *testing.T) {
|
||||
site := storageDefaultSite(t, db)
|
||||
seedSEOScale(t, db, site.ID)
|
||||
repository := &countingStoryRepository{ArticleRepository: persistence.NewArticleRepository(db)}
|
||||
list := useCases.NewListArticlesUseCase(repository, &fakeBodyRenderer{}, persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db))
|
||||
sites, rows := persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db)
|
||||
if err := useCases.NewRefreshArticleSEOUseCase(repository, sites, rows).RefreshStale(); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
if repository.staleReads != seoScaleVersions {
|
||||
t.Fatalf("the backfill assessed %d versions, want %d", repository.staleReads, seoScaleVersions)
|
||||
}
|
||||
list := useCases.NewListArticlesUseCase(repository, &fakeBodyRenderer{}, sites, rows)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -93,7 +107,7 @@ func TestArticleStoryListSEOFilterAtScaleSQLite(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
repository.titleSearches, repository.pageLoads = 0, 0
|
||||
repository.titleSearches, repository.pageLoads, repository.staleReads = 0, 0, 0
|
||||
want := expectedSEOScale(c.hidden, c.titled)
|
||||
|
||||
page, err := list.ListStoriesPage(useCases.ListStoriesCommand{Caller: ownerCaller(1), Page: 2, Size: seoScalePageSize, SEO: c.seo, Q: c.q})
|
||||
@@ -116,8 +130,8 @@ func TestArticleStoryListSEOFilterAtScaleSQLite(t *testing.T) {
|
||||
if c.q != "" {
|
||||
wantSearches = 1
|
||||
}
|
||||
if repository.titleSearches != wantSearches || repository.pageLoads != 1 {
|
||||
t.Errorf("title searches %d, page loads %d per request", repository.titleSearches, repository.pageLoads)
|
||||
if repository.titleSearches != wantSearches || repository.pageLoads != 0 || repository.staleReads != 0 {
|
||||
t.Errorf("title searches %d, page loads %d, stale versions read %d per request", repository.titleSearches, repository.pageLoads, repository.staleReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package test
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
authordomain "RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
@@ -177,7 +177,17 @@ func createTitled(db *gorm.DB, title string) (domainArticle.Article, error) {
|
||||
}
|
||||
|
||||
func confirmDefaultLanguage(db *gorm.DB, siteID uint32) error {
|
||||
return useCases.NewConfirmDefaultLanguageUseCase(persistence.NewTransactor(db)).Confirm(context.Background(), siteID)
|
||||
var found entity.Site
|
||||
if err := db.First(&found, siteID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var seeded entity.SiteLanguage
|
||||
if err := db.Where("site_id = ? AND is_default = ?", siteID, true).First(&seeded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
setup := useCases.NewApplyLanguageSetupUseCase(persistence.NewTransactor(db), discardRecorder)
|
||||
_, err := setup.Apply(context.Background(), []localization.SetupChoice{{Key: found.Key, Lang: seeded.Lang}})
|
||||
return err
|
||||
}
|
||||
|
||||
func newStorageArticle(siteID uint32, lang string, slug string, storyID uint32) domainArticle.Article {
|
||||
@@ -219,7 +229,7 @@ func runArticleLocalizationStorage(t *testing.T, open func(*testing.T) *gorm.DB)
|
||||
|
||||
migrateStorage(t, db)
|
||||
|
||||
languages, err := persistence.NewSiteLanguageRepository(db).ListBySite(site.ID)
|
||||
languages, err := persistence.NewLocalizationRepository(db).Rows(site.ID)
|
||||
if err != nil || len(languages) != 1 || languages[0].Tag != "fr" || languages[0].Confirmed {
|
||||
t.Errorf("expected one unconfirmed fr row, got %+v %v", languages, err)
|
||||
}
|
||||
@@ -560,17 +570,6 @@ func runArticleLocalizationStorage(t *testing.T, open func(*testing.T) *gorm.DB)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Confirming a site without a default language is refused", func(t *testing.T) {
|
||||
db := open(t)
|
||||
site := storageDefaultSite(t, db)
|
||||
|
||||
err := confirmDefaultLanguage(db, site.ID)
|
||||
|
||||
if !errors.Is(err, language.ErrNoDefaultLanguage) {
|
||||
t.Errorf("expected ErrNoDefaultLanguage, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unique indexes replace the per-site slug index", func(t *testing.T) {
|
||||
db := open(t)
|
||||
for _, name := range []string{storyLanguageIndexName, siteSlugIndexName} {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type seoIndexEnv struct {
|
||||
t *testing.T
|
||||
db *gorm.DB
|
||||
articles *persistence.ArticleRepository
|
||||
sites *persistence.SiteRepository
|
||||
rows *persistence.LocalizationRepository
|
||||
refresh *useCases.RefreshArticleSEOUseCase
|
||||
siteID uint32
|
||||
}
|
||||
|
||||
func newSEOIndexEnv(t *testing.T, open func(*testing.T) *gorm.DB) *seoIndexEnv {
|
||||
t.Helper()
|
||||
db := open(t)
|
||||
e := &seoIndexEnv{t: t, db: db, articles: persistence.NewArticleRepository(db), sites: persistence.NewSiteRepository(db), rows: persistence.NewLocalizationRepository(db)}
|
||||
e.refresh = useCases.NewRefreshArticleSEOUseCase(e.articles, e.sites, e.rows)
|
||||
e.siteID = storageDefaultSite(t, db).ID
|
||||
e.must(db.Where("site_id = ?", e.siteID).Delete(&entity.SiteLanguage{}).Error)
|
||||
e.must(e.rows.Replace(e.siteID, []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "fr", Enabled: true}}))
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *seoIndexEnv) must(err error) {
|
||||
e.t.Helper()
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *seoIndexEnv) create(lang string, storyID uint32) domainArticle.Article {
|
||||
e.t.Helper()
|
||||
created, err := e.articles.Create(domainArticle.Article{Title: "Harbour " + lang, SiteID: e.siteID, Lang: lang, Slug: uniqueTestSlug(), StoryID: storyID, SEODismissed: []string{}})
|
||||
e.must(err)
|
||||
return created
|
||||
}
|
||||
|
||||
func (e *seoIndexEnv) status(id uint32) string {
|
||||
e.t.Helper()
|
||||
var statuses []string
|
||||
e.must(e.db.Model(&entity.Article{}).Where("id = ?", id).Pluck("seo_status", &statuses).Error)
|
||||
return statuses[0]
|
||||
}
|
||||
|
||||
func (e *seoIndexEnv) expectRecomputed(t *testing.T, name string, id uint32) {
|
||||
t.Helper()
|
||||
if e.status(id) != "" {
|
||||
t.Fatalf("%s left the stored status in place", name)
|
||||
}
|
||||
e.must(e.refresh.RefreshStale())
|
||||
if e.status(id) == "" {
|
||||
t.Fatalf("%s: the refresh did not store a status", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *seoIndexEnv) suggestions(id uint32) int {
|
||||
e.t.Helper()
|
||||
page, err := useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, e.sites, e.rows).ListArticlesPage(useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 100})
|
||||
e.must(err)
|
||||
return page.SEO[id].Suggestions
|
||||
}
|
||||
|
||||
func runArticleSEOIndex(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
t.Run("Every write to an SEO input clears the stored status until it is recomputed", func(t *testing.T) {
|
||||
e := newSEOIndexEnv(t, open)
|
||||
created := e.create("en", 0)
|
||||
unassigned := e.create("", 0)
|
||||
image := entity.Image{Path: "/static/uploadedImages/seo-index.png", Filename: "seo-index.png"}
|
||||
e.must(e.db.Create(&image).Error)
|
||||
other, err := e.sites.Create(site.Site{Name: "Atelier", Key: "atelier", BaseURL: "https://atelier.example"})
|
||||
e.must(err)
|
||||
credited, err := persistence.NewAuthorRepository(e.db).Create(author.Author{Name: "Noor", Slug: "noor"})
|
||||
e.must(err)
|
||||
e.expectRecomputed(t, "create", created.ID)
|
||||
title, body := "Harbour light", "<p>Body.</p>"
|
||||
cover := domainArticle.Nullable[uint32]{Set: true, Value: &image.ID}
|
||||
|
||||
writes := []struct {
|
||||
name string
|
||||
id uint32
|
||||
write func() error
|
||||
}{
|
||||
{"a title edit", created.ID, func() error { _, err := e.articles.Update(created.ID, domainArticle.Update{Title: &title}); return err }},
|
||||
{"a body edit", created.ID, func() error { _, err := e.articles.UpdateBody(created.ID, body); return err }},
|
||||
{"a new cover", created.ID, func() error {
|
||||
_, err := e.articles.Update(created.ID, domainArticle.Update{CoverImageID: cover})
|
||||
return err
|
||||
}},
|
||||
{"a deleted cover", created.ID, func() error { _, err := e.articles.ClearImage(image.ID); return err }},
|
||||
{"a restored cover", created.ID, func() error {
|
||||
return e.articles.RestoreImage(domainArticle.ImageReferences{Cover: []uint32{created.ID}}, image.ID)
|
||||
}},
|
||||
{"a merged cover", created.ID, func() error { _, err := e.articles.RepointImage([]uint32{image.ID}, image.ID); return err }},
|
||||
{"a credited author", created.ID, func() error {
|
||||
ids := []uint32{credited.ID}
|
||||
_, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &ids})
|
||||
return err
|
||||
}},
|
||||
{"a deleted author", created.ID, func() error { return persistence.NewAuthorRepository(e.db).DeleteUncrediting(credited.ID) }},
|
||||
{"a site description or default language change", created.ID, func() error { return e.articles.MarkSiteSEOStale(e.siteID) }},
|
||||
{"a language assignment", unassigned.ID, func() error { _, err := e.articles.AssignUnassignedLanguage(e.siteID, "fr"); return err }},
|
||||
{"a move to another site", created.ID, func() error { return e.articles.MoveToSite([]uint32{created.ID}, other.ID) }},
|
||||
}
|
||||
for _, w := range writes {
|
||||
e.must(e.refresh.RefreshStale())
|
||||
e.must(w.write())
|
||||
e.expectRecomputed(t, w.name, w.id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A status computed before a concurrent write is not stored", func(t *testing.T) {
|
||||
e := newSEOIndexEnv(t, open)
|
||||
created := e.create("en", 0)
|
||||
stale, err := e.articles.StaleSEO(0, 10)
|
||||
e.must(err)
|
||||
_, err = e.articles.UpdateBody(created.ID, "<p>Changed.</p>")
|
||||
e.must(err)
|
||||
|
||||
e.must(e.articles.StoreSEO([]domainArticle.SEORecord{{ArticleID: created.ID, Version: stale[0].SEOVersion, Summary: domainArticle.SEOSummary{Status: domainArticle.SEOStatusReady}}}))
|
||||
|
||||
if e.status(created.ID) != "" {
|
||||
t.Fatal("a status read before the write was stored over it")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The site description counts for the default language only and follows a default change", func(t *testing.T) {
|
||||
e := newSEOIndexEnv(t, open)
|
||||
english := e.create("en", 0)
|
||||
french := e.create("fr", english.StoryID)
|
||||
description := strings.Repeat("Renovation notes. ", 6)
|
||||
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, e.articles)
|
||||
_, err := update.Update(context.Background(), e.siteID, site.Patch{DefaultDescription: &description})
|
||||
e.must(err)
|
||||
|
||||
if e.suggestions(french.ID) != e.suggestions(english.ID)+1 {
|
||||
t.Fatalf("site text: en %d, fr %d suggestions", e.suggestions(english.ID), e.suggestions(french.ID))
|
||||
}
|
||||
current, err := e.rows.Rows(e.siteID)
|
||||
e.must(err)
|
||||
languages := useCases.NewUpdateSiteLanguagesUseCase(persistence.NewTransactor(e.db), discardRecorder)
|
||||
_, err = languages.Update(context.Background(), useCases.UpdateSiteLanguagesCommand{Key: storageDefaultSite(t, e.db).Key, Revision: localization.Revision(current), Default: "fr", Languages: []useCases.SiteLanguageEntry{{Lang: "en", Enabled: true}, {Lang: "fr", Enabled: true}}})
|
||||
e.must(err)
|
||||
|
||||
if e.suggestions(english.ID) != e.suggestions(french.ID)+1 {
|
||||
t.Fatalf("after the default change: en %d, fr %d suggestions", e.suggestions(english.ID), e.suggestions(french.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting an article removes every version and the article row", func(t *testing.T) {
|
||||
e := newSEOIndexEnv(t, open)
|
||||
english := e.create("en", 0)
|
||||
french := e.create("fr", english.StoryID)
|
||||
|
||||
e.must(e.articles.DeleteStory(english.StoryID))
|
||||
|
||||
var live, stories int64
|
||||
e.must(e.db.Model(&entity.Article{}).Where("story_id = ?", english.StoryID).Count(&live).Error)
|
||||
e.must(e.db.Model(&entity.Story{}).Where("id = ?", english.StoryID).Count(&stories).Error)
|
||||
if live != 0 || stories != 0 {
|
||||
t.Fatalf("live versions %d, story rows %d", live, stories)
|
||||
}
|
||||
for _, version := range []domainArticle.Article{english, french} {
|
||||
taken, err := e.articles.SlugTaken(e.siteID, version.Lang, version.Slug, 0)
|
||||
e.must(err)
|
||||
if taken {
|
||||
t.Errorf("the slug of %s is still taken", version.Lang)
|
||||
}
|
||||
}
|
||||
if err := e.articles.DeleteStory(english.StoryID); err == nil {
|
||||
t.Error("deleting a deleted article must answer not found")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestArticleSEOIndexSQLite(t *testing.T) {
|
||||
runArticleSEOIndex(t, openContractSQLite)
|
||||
}
|
||||
|
||||
func TestArticleSEOIndexMySQL(t *testing.T) {
|
||||
runArticleSEOIndex(t, openContractMySQL)
|
||||
}
|
||||
|
||||
func TestArticleSEOIndexPostgres(t *testing.T) {
|
||||
runArticleSEOIndex(t, openContractPostgres)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"reflect"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var _ article.ListingRepository = &mockArticleRepo{}
|
||||
|
||||
type assessedVersion struct {
|
||||
inputs article.Article
|
||||
credits []uint32
|
||||
}
|
||||
|
||||
func seoInputsOf(version article.Article) article.Article {
|
||||
version.SEO = article.SEOSummary{}
|
||||
version.SEOVersion = 0
|
||||
version.UpdatedAt = version.UpdatedAt.UTC()
|
||||
return version
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) seoFresh(version article.Article) bool {
|
||||
assessed, found := m.assessed[version.ID]
|
||||
return found && !version.SEO.Stale() && reflect.DeepEqual(assessed.inputs, seoInputsOf(version)) && slices.Equal(assessed.credits, m.credits[version.ID])
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) StaleSEO(afterID uint32, limit int) ([]article.Article, error) {
|
||||
if err := m.fail.trigger("StaleSEO"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stale := []article.Article{}
|
||||
for _, id := range m.sortedIds() {
|
||||
version := m.articles[id]
|
||||
if id <= afterID || m.seoFresh(version) {
|
||||
continue
|
||||
}
|
||||
version.SEO = article.SEOSummary{}
|
||||
stale = append(stale, m.withAuthors(version))
|
||||
if len(stale) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) StoreSEO(records []article.SEORecord) error {
|
||||
if err := m.fail.trigger("StoreSEO"); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.assessed == nil {
|
||||
m.assessed = map[uint32]assessedVersion{}
|
||||
}
|
||||
for _, record := range records {
|
||||
version, found := m.articles[record.ArticleID]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
version.SEO = record.Summary
|
||||
m.articles[record.ArticleID] = version
|
||||
m.assessed[record.ArticleID] = assessedVersion{inputs: seoInputsOf(version), credits: slices.Clone(m.credits[record.ArticleID])}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) MarkSiteSEOStale(siteID uint32) error {
|
||||
for id, version := range m.articles {
|
||||
if version.SiteID == siteID {
|
||||
delete(m.assessed, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) CountSuggestions(filters article.Filters) (int, error) {
|
||||
if err := m.fail.trigger("CountSuggestions"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0
|
||||
for _, version := range m.articles {
|
||||
if mockVisible(version, filters.IsOnline != nil && *filters.IsOnline, filters.VisibleTo) && version.SEO.Status == article.SEOStatusSuggestions {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) CountSuggestedStories(readable article.StoryFilters) (int, error) {
|
||||
m.listCalls++
|
||||
if err := m.fail.trigger("CountSuggestedStories"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stories := map[uint32]bool{}
|
||||
standalone := 0
|
||||
for _, version := range m.articles {
|
||||
if !mockVisible(version, readable.OnlyOnline, readable.VisibleTo) || version.SEO.Status != article.SEOStatusSuggestions {
|
||||
continue
|
||||
}
|
||||
if version.StoryID == 0 {
|
||||
standalone++
|
||||
continue
|
||||
}
|
||||
stories[version.StoryID] = true
|
||||
}
|
||||
return len(stories) + standalone, nil
|
||||
}
|
||||
|
||||
func mockVisible(version article.Article, onlineOnly bool, visibleTo *uint32) bool {
|
||||
if version.IsOnline {
|
||||
return true
|
||||
}
|
||||
if onlineOnly {
|
||||
return false
|
||||
}
|
||||
return visibleTo == nil || version.CreatedBy == *visibleTo
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) ListVersions(ids []uint32) ([]article.Article, error) {
|
||||
m.listCalls++
|
||||
if err := m.fail.trigger("ListVersions"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := []article.Article{}
|
||||
for _, id := range ids {
|
||||
if version, ok := m.articles[id]; ok {
|
||||
found = append(found, m.withAuthors(version))
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) DeleteStory(storyID uint32) error {
|
||||
if err := m.fail.trigger("DeleteStory"); err != nil {
|
||||
return err
|
||||
}
|
||||
deleted := 0
|
||||
for id, version := range m.articles {
|
||||
if version.StoryID == storyID {
|
||||
delete(m.articles, id)
|
||||
delete(m.credits, id)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
if deleted == 0 {
|
||||
return article.ErrArticleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -107,7 +108,7 @@ func TestArticleStoryLifecycleHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(response.text(), "Kosten") || strings.Contains(response.text(), `"de"`) {
|
||||
if strings.Contains(response.text(), "Kosten") || slices.Contains(languagesIn(translations), "de") {
|
||||
t.Errorf("the hidden version leaks: %s", response.text())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ type mockArticleRepo struct {
|
||||
onGet func(id uint32) error
|
||||
reverseStory bool
|
||||
listCalls int
|
||||
assessed map[uint32]assessedVersion
|
||||
}
|
||||
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
@@ -126,7 +127,10 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
||||
if filters.Status == article.StatusDraft && a.IsOnline {
|
||||
continue
|
||||
}
|
||||
if filters.Lang != nil && a.Lang != *filters.Lang {
|
||||
if filters.Lang != "" && a.Lang != filters.Lang {
|
||||
continue
|
||||
}
|
||||
if filters.SEOStatus != "" && a.SEO.Status != filters.SEOStatus {
|
||||
continue
|
||||
}
|
||||
if filters.Langs != nil && !slices.Contains(*filters.Langs, a.Lang) {
|
||||
@@ -141,8 +145,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
||||
matching = append(matching, a)
|
||||
}
|
||||
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
|
||||
start := min((filters.Page-1)*filters.Size, len(matching))
|
||||
end := min(start+filters.Size, len(matching))
|
||||
offset, limit := filters.Window()
|
||||
start := min(offset, len(matching))
|
||||
end := start + min(limit, len(matching)-start)
|
||||
if filters.Unpaged {
|
||||
start, end = 0, len(matching)
|
||||
}
|
||||
@@ -176,7 +181,7 @@ func (m *mockArticleRepo) CountVersions(filters article.Filters) (article.Counts
|
||||
}
|
||||
counts := article.Counts{}
|
||||
for _, a := range m.articles {
|
||||
if filters.Lang != nil && a.Lang != *filters.Lang {
|
||||
if filters.Lang != "" && a.Lang != filters.Lang {
|
||||
continue
|
||||
}
|
||||
if a.IsOnline {
|
||||
|
||||
@@ -96,7 +96,7 @@ func holdSiteLock(t *testing.T, db *gorm.DB, siteID uint32, then func(transactio
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
finished <- persistence.NewTransactor(db).Run(context.Background(), func(tx transaction.Repositories) error {
|
||||
if _, err := tx.Languages.LockSiteLanguages(siteID); err != nil {
|
||||
if err := tx.Sites.LockSite(siteID); err != nil {
|
||||
return err
|
||||
}
|
||||
close(held)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalizationDomainRules(t *testing.T) {
|
||||
t.Run("A site has exactly one default language", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows []localization.Row
|
||||
err error
|
||||
}{
|
||||
{"none", []localization.Row{{Tag: "en"}}, localization.ErrSingleDefault},
|
||||
{"one", []localization.Row{{Tag: "en", IsDefault: true}, {Tag: "fr"}}, nil},
|
||||
{"two", []localization.Row{{Tag: "en", IsDefault: true}, {Tag: "fr", IsDefault: true}}, localization.ErrSingleDefault},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := localization.RequireSingleDefault(c.rows); !errors.Is(err, c.err) {
|
||||
t.Errorf("%s: %v, want %v", c.name, err, c.err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only an enabled assigned language takes part in the public surfaces", func(t *testing.T) {
|
||||
rows := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "es"}}
|
||||
if !localization.PublicTag(rows, "en") || localization.PublicTag(rows, "es") || localization.PublicTag(rows, "") || localization.PublicTag(rows, "de") {
|
||||
t.Error("public languages are the enabled rows only")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The site text belongs to the default language and to unassigned versions", func(t *testing.T) {
|
||||
if !localization.SiteTextAllowed("en", "en") || !localization.SiteTextAllowed("en", "") || localization.SiteTextAllowed("en", "fr") {
|
||||
t.Error("site text rule")
|
||||
}
|
||||
if localization.ConfirmedDefaultTag([]localization.Row{{Tag: "en", IsDefault: true}}) != "" {
|
||||
t.Error("an unconfirmed default is no default")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The title template placeholders have one source", func(t *testing.T) {
|
||||
if site.DefaultTitleTemplate != site.TitlePlaceholder+" | "+site.SitePlaceholder {
|
||||
t.Errorf("default template %q", site.DefaultTitleTemplate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A page window never overflows", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
page, size, offset, limit int
|
||||
}{
|
||||
{1, 25, 0, 25},
|
||||
{3, 5, 10, 5},
|
||||
{0, 5, 0, 5},
|
||||
{math.MinInt, 5, 0, 5},
|
||||
{math.MaxInt, 25, math.MaxInt, 25},
|
||||
{2, -1, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
offset, limit := article.Filters{Page: c.page, Size: c.size}.Window()
|
||||
if offset != c.offset || limit != c.limit {
|
||||
t.Errorf("page %d size %d: %d %d", c.page, c.size, offset, limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckArticleSEOIsLanguageAware(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
sites := newMockSiteRepo(repo)
|
||||
journal := sites.seed(1, "Journal", "journal", true)
|
||||
journal.DefaultDescription = strings.Repeat("Renovation notes. ", 6)
|
||||
sites.sites[1] = journal
|
||||
rows := newFakeLocalizationStore(repo)
|
||||
rows.rows[1] = []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "fr", Enabled: true}}
|
||||
repo.articles[1] = article.Article{ID: 1, Title: "Harbour", SiteID: 1, Lang: "en", SEODismissed: []string{}}
|
||||
repo.articles[2] = article.Article{ID: 2, Title: "Port", SiteID: 1, Lang: "fr", SEODismissed: []string{}}
|
||||
check := useCases.NewCheckArticleSEOUseCase(repo, sites, newMockImageRepo(), rows)
|
||||
|
||||
codes := func(id uint32) []string {
|
||||
result, err := check.Check(id, ownerCaller(1), useCases.CheckArticleSEOCommand{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := []string{}
|
||||
for _, suggestion := range result.Suggestions {
|
||||
found = append(found, suggestion.Code)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
if slices.Contains(codes(1), article.DismissDescShort) {
|
||||
t.Error("the default language uses the site description")
|
||||
}
|
||||
if !slices.Contains(codes(2), article.DismissDescShort) {
|
||||
t.Error("another language never uses the site description")
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ var _ transaction.Transactor = &rollbackLocalizationTransactor{}
|
||||
func (r *rollbackLocalizationTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
|
||||
r.runs++
|
||||
rows, articles := r.store.snapshot()
|
||||
err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store})
|
||||
err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store, ArticleSEO: r.store.articles})
|
||||
if err != nil {
|
||||
r.store.rows = rows
|
||||
r.store.articles.articles = articles
|
||||
@@ -156,7 +156,7 @@ func newLocalizationEnv(t *testing.T) *localizationEnv {
|
||||
transactor := &rollbackLocalizationTransactor{store: store, sites: sites}
|
||||
c := e.container
|
||||
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder, e.articles)
|
||||
c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store)
|
||||
c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder)
|
||||
c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const publicReadBudget = 120
|
||||
|
||||
func (e *locPublicEnv) getFrom(remoteAddr string, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.RemoteAddr = remoteAddr
|
||||
rec := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestPublicReadRateLimit(t *testing.T) {
|
||||
t.Run("The public article routes and the sitemap share one budget per client", func(t *testing.T) {
|
||||
e := newLocPublicEnv(t)
|
||||
client := "198.51.100.7:4000"
|
||||
for index := 0; index < publicReadBudget; index++ {
|
||||
path := locFrBase
|
||||
if index%2 == 0 {
|
||||
path = "/v1/site/journal/sitemap"
|
||||
}
|
||||
if rec := e.getFrom(client, path); rec.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: %d %s", index, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{locFrBase, "/v1/site/journal/sitemap", "/v1/public/site/journal/story/5"} {
|
||||
rec := e.getFrom(client, path)
|
||||
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
|
||||
t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := e.getFrom("198.51.100.8:4000", locFrBase); rec.Code != http.StatusOK {
|
||||
t.Errorf("another client is limited: %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
|
||||
t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) {
|
||||
repo, sites, _, images := newRepos()
|
||||
check := useCases.NewCheckArticleSEOUseCase(repo, sites, images)
|
||||
check := useCases.NewCheckArticleSEOUseCase(repo, sites, images, plainLocalization())
|
||||
resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images, plainLocalization())
|
||||
reader := callerWith(22, "content.read=own")
|
||||
if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil {
|
||||
|
||||
@@ -218,7 +218,7 @@ func (w *contentScopeWorld) wire() {
|
||||
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
|
||||
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
|
||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization())
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization())
|
||||
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
|
||||
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
||||
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
|
||||
|
||||
@@ -68,7 +68,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
|
||||
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, p.articles)
|
||||
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
|
||||
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
|
||||
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
|
||||
|
||||
@@ -38,7 +38,13 @@ func TestSeoFilterPerformance(t *testing.T) {
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db), plainLocalization())
|
||||
articles, sites := persistence.NewArticleRepository(db), persistence.NewSiteRepository(db)
|
||||
backfillStarted := time.Now()
|
||||
if err := useCases.NewRefreshArticleSEOUseCase(articles, sites, plainLocalization()).RefreshStale(); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
t.Logf("startup backfill of %d articles: %d ms", seoPerformanceArticles, time.Since(backfillStarted).Milliseconds())
|
||||
list := useCases.NewListArticlesUseCase(articles, &fakeBodyRenderer{}, sites, plainLocalization())
|
||||
|
||||
filtered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
|
||||
unfiltered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.DefaultSort()}
|
||||
|
||||
@@ -130,7 +130,7 @@ func newSeoOutEnv(t *testing.T) *seoOutEnv {
|
||||
e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
|
||||
c := e.container
|
||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, plainLocalization())
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images, plainLocalization())
|
||||
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites, plainLocalization())
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization())
|
||||
router := chi.NewRouter()
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestSiteUseCaseRules(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
sites := newMockSiteRepo(repo)
|
||||
sites.seed(2, "Shop", "shop", false)
|
||||
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder)
|
||||
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder, repo)
|
||||
flag := true
|
||||
|
||||
_, err := uc.Update(testCtx, 2, site.Patch{IsDefault: &flag})
|
||||
|
||||
@@ -338,7 +338,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
|
||||
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
|
||||
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, p.articles)
|
||||
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
|
||||
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
|
||||
router := chi.NewRouter()
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
|
||||
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
|
||||
}
|
||||
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id", "invites", "idx_invites_role_id"} {
|
||||
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "idx_articles_seo_status", "users", "idx_users_role_id", "invites", "idx_invites_role_id"} {
|
||||
delete(before.schema, name)
|
||||
delete(after.schema, name)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
|
||||
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
|
||||
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision", "edit_revision", "source_article_id", "source_revision", "retired_id"}
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision", "edit_revision", "source_article_id", "source_revision", "retired_id", "seo_status", "seo_suggestions", "seo_version"}
|
||||
|
||||
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const storyDeletePath = "/v1/story/5"
|
||||
|
||||
func (w *storyWorld) storyVersionIDs() []uint32 {
|
||||
ids := []uint32{}
|
||||
for id, version := range w.articles.articles {
|
||||
if version.StoryID == storyStory {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestStoryDeleteHTTP(t *testing.T) {
|
||||
t.Run("Deleting an article removes every version and the article", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
w.send(storyOwner, http.MethodDelete, storyDeletePath, "").expect(t, http.StatusNoContent, "")
|
||||
|
||||
if remaining := w.storyVersionIDs(); len(remaining) != 0 {
|
||||
t.Fatalf("versions left: %v", remaining)
|
||||
}
|
||||
w.send(storyOwner, http.MethodGet, "/v1/article/10", "").expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
|
||||
deletes := 0
|
||||
for _, event := range w.recorder.events {
|
||||
if event.Action == audit.ActionArticleDelete && event.Params[audit.ParamStoryID] == storyStory {
|
||||
deletes++
|
||||
}
|
||||
}
|
||||
if deletes != 4 {
|
||||
t.Errorf("expected one article.delete per version, got %d in %v", deletes, w.recorder.actions())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Versions the caller cannot read are deleted with the article", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
w.send(storyDeleter, http.MethodDelete, storyDeletePath, "").expect(t, http.StatusNoContent, "")
|
||||
|
||||
if remaining := w.storyVersionIDs(); len(remaining) != 0 {
|
||||
t.Fatalf("hidden versions left: %v", remaining)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting an article needs delete permission on every version", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
before := w.storyVersionIDs()
|
||||
|
||||
response := w.send(storyWriter1, http.MethodDelete, storyDeletePath, "")
|
||||
|
||||
response.expect(t, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
|
||||
if response.header(apperr.MissingPermissionHeader) != "content.delete" || !slices.Equal(w.storyVersionIDs(), before) {
|
||||
t.Fatalf("header %q, versions %v", response.header(apperr.MissingPermissionHeader), w.storyVersionIDs())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed delete removes nothing", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
before := w.storyVersionIDs()
|
||||
w.articles.fail.failNext("DeleteStory")
|
||||
|
||||
w.send(storyOwner, http.MethodDelete, storyDeletePath, "").expect(t, http.StatusInternalServerError, apperr.DeleteArticleFailedErrorMessage)
|
||||
|
||||
if !slices.Equal(w.storyVersionIDs(), before) {
|
||||
t.Fatalf("versions changed: %v", w.storyVersionIDs())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown or unreadable article answers the same not found", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(w.stored(12))
|
||||
hidden := w.stored(11)
|
||||
hidden.StoryID = 77
|
||||
w.put(hidden)
|
||||
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/story/9999", "").expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
|
||||
w.send(storyDeleter, http.MethodDelete, "/v1/story/77", "").expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
|
||||
if _, kept := w.articles.articles[11]; !kept {
|
||||
t.Fatal("an unreadable article must not be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An invalid id is refused", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/story/abc", "").expect(t, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||
})
|
||||
}
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
storyPublish uint32 = 55
|
||||
storyNobody uint32 = 56
|
||||
storyPartial uint32 = 57
|
||||
storyDeleter uint32 = 58
|
||||
storyAnon uint32 = 0
|
||||
storyStory uint32 = 5
|
||||
)
|
||||
@@ -62,7 +63,6 @@ type storyTransactor struct {
|
||||
world *storyWorld
|
||||
runs int
|
||||
external map[uint32]article.Article
|
||||
starting map[uint32]string
|
||||
removed map[uint32]bool
|
||||
}
|
||||
|
||||
@@ -74,11 +74,7 @@ func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Reposit
|
||||
articles := maps.Clone(w.articles.articles)
|
||||
credits := maps.Clone(w.articles.credits)
|
||||
rows, _ := w.rows.snapshot()
|
||||
starting := map[uint32]string{storyJournal: "en", storyAtelier: "en"}
|
||||
if s.starting != nil {
|
||||
starting = s.starting
|
||||
}
|
||||
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: starting, removed: s.removed}})
|
||||
err := work(transaction.Repositories{Articles: w.articles, Sites: removableSites{mockSiteRepo: w.sites, removed: s.removed}, Authors: w.authors, Localization: w.rows, ArticleSEO: w.articles})
|
||||
if err != nil {
|
||||
w.articles.articles = articles
|
||||
w.articles.credits = credits
|
||||
@@ -136,6 +132,7 @@ func (w *storyWorld) seedUsers() {
|
||||
storyPublish: {"content.publish=all", "content.read=all"},
|
||||
storyNobody: {"schema.view=all"},
|
||||
storyPartial: {"content.update=all", "content.read=own"},
|
||||
storyDeleter: {"content.delete=all", "content.read=own"},
|
||||
}
|
||||
for id, grants := range custom {
|
||||
created, _ := w.roles.Create(role.Role{Name: "story-role-" + strconv.FormatUint(uint64(id), 10), Version: 1, Grants: grantRows(grants...)})
|
||||
@@ -204,6 +201,7 @@ func (w *storyWorld) wire() {
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder),
|
||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(w.articles, w.rows),
|
||||
MoveStoryUseCase: moveStory,
|
||||
DeleteStoryUseCase: useCases.NewDeleteStoryUseCase(w.transactor, w.articles, w.recorder),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Use(authtoken.Verifier(w.token))
|
||||
|
||||
Reference in new issue
Block a user