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:
Florian Sylvain committed 2026-11-03 16:01:52 +01:00
1 parent 9d284e65a9
commit 7c15206170
85 files changed
+1788 -593

No files matched your search

+1 -2
View File
@@ -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
}
+42 -16
View File
@@ -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 {
+3
View File
@@ -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) {
+128
View File
@@ -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)
}
}
})
}
+9 -6
View File
@@ -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
+18 -4
View File
@@ -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} {
+200
View File
@@ -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)
}
+148
View File
@@ -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
}
+2 -1
View File
@@ -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())
}
})
+9 -4
View File
@@ -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)
+105
View File
@@ -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")
}
}
+2 -2
View File
@@ -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)
+44
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+7 -1
View File
@@ -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()}
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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})
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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()
+98
View File
@@ -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)
})
}
+4 -6
View File
@@ -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))