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