feat: storage, language rules and migration

This commit is contained in:
Florian Sylvain committed 2026-11-01 17:50:28 +01:00
1 parent 8c4a1825ab
commit 0806733d5c
70 files changed
+1771 -132

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ func seedDeleteAccountRows(t *testing.T, db *gorm.DB, creditedArticles bool) {
&entity.APIKey{ID: 3, UserID: 8, Name: "ines", Prefix: "c", HashedKey: "h3"},
&entity.Author{ID: 5, Name: "Marta Alves", Slug: "marta-alves", Bio: "bio", Website: &website, SocialLinks: `[{"type":"github","url":"https://github.com/marta"}]`, UserID: &martaID, AvatarID: &martaID},
&entity.Author{ID: 6, Name: "Ines", Slug: "ines", SocialLinks: "[]", UserID: &inesID},
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
&entity.AuditEvent{ID: 1, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, UserAgent: &agent, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
+7 -7
View File
@@ -73,13 +73,13 @@ func articleIDs(listed []article.Article) []uint32 {
func seedExportRows(t *testing.T, db *gorm.DB) {
t.Helper()
rows := []any{
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8},
&entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
&entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
&entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
&entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7},
&entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
&entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Article{ID: 2, StoryID: fixtureStory(), Title: "Ines", Slug: "ines", CreatedBy: 8},
&entity.Article{ID: 3, StoryID: fixtureStory(), Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
&entity.Article{ID: 7, StoryID: fixtureStory(), Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
&entity.Article{ID: 8, StoryID: fixtureStory(), Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
&entity.Article{ID: 4, StoryID: fixtureStory(), Title: "Gone", Slug: "gone", CreatedBy: 7},
&entity.Article{ID: 6, StoryID: fixtureStory(), Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
&entity.ArticleAuthor{ArticleID: 3, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 7, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 6, AuthorID: 5},
+1 -1
View File
@@ -187,7 +187,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) {
h := newActivityHarness(t)
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(newMockArticleRepo()), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "ab", Body: "Hello"})
+1 -1
View File
@@ -109,7 +109,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images),
+1 -1
View File
@@ -43,7 +43,7 @@ const (
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -23,7 +23,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
getUserUseCase := useCases.NewGetUserUseCase(routerUsers)
renderer := markdown.NewConverter()
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -22,7 +22,7 @@ func seedChunkedArticles(t *testing.T, db *gorm.DB, count int) {
t.Helper()
rows := make([]entity.Article, 0, count)
for i := 1; i <= count; i++ {
rows = append(rows, entity.Article{Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true})
rows = append(rows, entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true})
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed articles: %v", err)
+79
View File
@@ -0,0 +1,79 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"testing"
)
const (
createLanguageSiteID = 1
createLanguageTitle = "Bonjour le monde"
confirmedDefaultTag = "fr-CA"
)
func newCreateLanguageFixture(confirmed map[uint32]string) (*useCases.CreateArticleUseCase, *mockCreateTransactor, *mockArticleRepo) {
articles := newMockArticleRepo()
transactor := passthroughCreateTransactor(articles)
transactor.languages.confirmed = confirmed
return useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), transactor, articles
}
func createLanguageCommand() useCases.CreateArticleCommand {
return useCases.CreateArticleCommand{Title: createLanguageTitle, Caller: ownerCaller(1)}
}
func TestCreateArticleStartingLanguage(t *testing.T) {
t.Run("Creating an article before the default is confirmed", func(t *testing.T) {
create, transactor, articles := newCreateLanguageFixture(map[uint32]string{})
created, err := create.CreateArticle(context.Background(), createLanguageCommand())
if err != nil || articles.articles[created.ID].Lang != language.Unassigned {
t.Fatalf("expected an unassigned article, got %+v %v", articles.articles[created.ID], err)
}
if len(transactor.languages.locked) != 1 || transactor.languages.locked[0] != createLanguageSiteID {
t.Errorf("the site language must be read once under the site lock: %v", transactor.languages.locked)
}
})
t.Run("Creating an article after the default is confirmed", func(t *testing.T) {
create, _, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag})
created, err := create.CreateArticle(context.Background(), createLanguageCommand())
if err != nil || articles.articles[created.ID].Lang != confirmedDefaultTag {
t.Fatalf("expected lang %s, got %+v %v", confirmedDefaultTag, articles.articles[created.ID], err)
}
})
t.Run("A slug is unique per language when a new article takes the confirmed default", func(t *testing.T) {
create, _, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag})
articles.articles[50] = article.Article{ID: 50, SiteID: createLanguageSiteID, Lang: "en", Slug: "bonjour-le-monde"}
first, firstErr := create.CreateArticle(context.Background(), createLanguageCommand())
second, secondErr := create.CreateArticle(context.Background(), createLanguageCommand())
if firstErr != nil || secondErr != nil {
t.Fatalf("unexpected errors %v %v", firstErr, secondErr)
}
if first.Slug != "bonjour-le-monde" || second.Slug != "bonjour-le-monde-2" {
t.Errorf("slugs %q then %q", first.Slug, second.Slug)
}
})
t.Run("A failure reading the site language aborts the creation", func(t *testing.T) {
create, transactor, articles := newCreateLanguageFixture(map[uint32]string{})
failure := errors.New("language read failed")
transactor.languages.failWith = failure
_, err := create.CreateArticle(context.Background(), createLanguageCommand())
if !errors.Is(err, failure) || articles.createCalled {
t.Errorf("expected %v without a create, got %v", failure, err)
}
})
}
+56
View File
@@ -0,0 +1,56 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"context"
"sync/atomic"
)
type mockStartingLanguages struct {
confirmed map[uint32]string
locked []uint32
failWith error
}
var _ language.Repository = &mockStartingLanguages{}
func (m *mockStartingLanguages) ListBySite(siteID uint32) ([]language.SiteLanguage, error) {
return nil, nil
}
func (m *mockStartingLanguages) ConfirmDefault(siteID uint32) error {
return nil
}
func (m *mockStartingLanguages) LockStartingLanguage(siteID uint32) (string, error) {
m.locked = append(m.locked, siteID)
if m.failWith != nil {
return "", m.failWith
}
return m.confirmed[siteID], nil
}
type mockCreateTransactor struct {
articles article.Repository
languages *mockStartingLanguages
runs int
}
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})
}
func passthroughCreateTransactor(articles article.Repository) *mockCreateTransactor {
return &mockCreateTransactor{articles: articles, languages: &mockStartingLanguages{confirmed: map[uint32]string{}}}
}
var fixtureStoryCounter atomic.Uint32
func fixtureStory() uint32 {
return 1000 + fixtureStoryCounter.Add(1)
}
+1 -1
View File
@@ -35,7 +35,7 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(e.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
@@ -0,0 +1,122 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"encoding/json"
"net/http"
"testing"
)
const (
localizedArticleID = 12
localizedArticleStory = 5
)
var legacyListItemKeys = []string{"id", "title", "body", "body_format", "is_online", "site_id", "authors", "created_at", "updated_at", "excerpt", "slug", "seo_status", "seo_suggestions"}
var legacyArticleKeys = []string{"id", "title", "body", "is_online", "site_id", "authors", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "created_at", "updated_at"}
func localizedArticle(lang string, online bool) domainArticle.Article {
return domainArticle.Article{ID: localizedArticleID, Title: "Bonjour le monde", IsOnline: online, CreatedBy: createdByWriterID, Lang: lang, StoryID: localizedArticleStory}
}
func TestArticleLocalizationStorageHTTP(t *testing.T) {
assertFields := func(t *testing.T, fields map[string]string, lang string, legacyKeys []string) {
t.Helper()
if fields["lang"] != `"`+lang+`"` || fields["story_id"] != "5" {
t.Errorf(`expected lang %q and story_id 5, got lang=%s story_id=%s`, lang, fields["lang"], fields["story_id"])
}
for _, key := range legacyKeys {
if _, present := fields[key]; !present {
t.Errorf("legacy key %q is missing", key)
}
}
}
readers := []struct {
name string
lang string
path string
auth bool
list bool
on bool
}{
{"GET /v1/article/12 with a valid jwt", "fr-CA", "/v1/article/12", true, false, false},
{"GET /v1/article/12 with a valid jwt and no language", "", "/v1/article/12", true, false, false},
{"GET /v1/article/ list item", "fr-CA", "/v1/article/", true, true, false},
{"public article read of an online article", "fr-CA", "/v1/article/12", false, false, true},
}
for _, reader := range readers {
t.Run("Article reads expose lang and story_id "+reader.name, func(t *testing.T) {
e, router := newCreatedByEnv(t)
e.articles.articles[localizedArticleID] = localizedArticle(reader.lang, reader.on)
var options []activityRequest
if reader.auth {
options = append(options, asUser(e, activityOwnerID))
}
rec := sendCreatedBy(router, http.MethodGet, reader.path, "", options...)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var fields map[string]string
if reader.list {
for _, item := range decodeArticleItems(t, rec.Body.Bytes()) {
if string(item["id"]) == "12" {
fields = rawFields(item)
}
}
} else {
fields = rawFields(decodeArticleFields(t, rec.Body.Bytes()))
}
if fields == nil {
t.Fatalf("article 12 not found in %s", rec.Body)
}
legacyKeys := legacyArticleKeys
if reader.list {
legacyKeys = legacyListItemKeys
}
assertFields(t, fields, reader.lang, legacyKeys)
})
}
t.Run("Article write payloads cannot set lang or story_id", func(t *testing.T) {
e, router := newCreatedByEnv(t)
e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false)
created := sendCreatedBy(router, http.MethodPost, "/v1/article/", `{"title":"Fresh draft title","body":"","lang":"en","story_id":99}`, asUser(e, createdByWriterID))
patched := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"excerpt":"Short","lang":"en","story_id":99}`, asUser(e, activityOwnerID))
if created.Code != http.StatusCreated || patched.Code != http.StatusOK {
t.Fatalf("statuses %d and %d: %s %s", created.Code, patched.Code, created.Body, patched.Body)
}
createdFields := rawFields(decodeArticleFields(t, created.Body.Bytes()))
if createdFields["lang"] == `"en"` || createdFields["story_id"] == "99" {
t.Errorf("the create payload set lang or story_id: %v", createdFields)
}
assertFields(t, rawFields(decodeArticleFields(t, patched.Body.Bytes())), "fr-CA", legacyArticleKeys)
if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory {
t.Errorf("the stored language or story changed: %+v", stored)
}
})
t.Run("A patch carrying only lang and story_id is refused like a patch with no updatable field", func(t *testing.T) {
e, router := newCreatedByEnv(t)
e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false)
empty := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{}`, asUser(e, activityOwnerID))
localizationOnly := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"lang":"en","story_id":99}`, asUser(e, activityOwnerID))
if empty.Code != http.StatusBadRequest || localizationOnly.Code != empty.Code {
t.Fatalf("expected both to be refused with 400, got %d and %d", empty.Code, localizationOnly.Code)
}
if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory {
t.Errorf("the stored language or story changed: %+v", stored)
}
})
}
func rawFields(object map[string]json.RawMessage) map[string]string {
flat := make(map[string]string, len(object))
for key, value := range object {
flat[key] = string(value)
}
return flat
}
@@ -0,0 +1,626 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"fmt"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
"gorm.io/gorm"
)
const (
storiesBackfillMarker = "article_stories"
languageSuggestionsMarker = "site_language_suggestions"
storyLanguageIndexName = "idx_articles_story_lang"
siteSlugIndexName = "idx_articles_site_slug"
slugPerLanguageMarker = "article_slug_index_per_language"
injectedBackfillFailure = "injected backfill failure"
raceCreators = 8
raceRetries = 60
raceRetriesPerWriter = 4
raceRetryBudget = raceRetriesPerWriter * (raceCreators + 1)
storyRollbackCallback = "test:fail-credit-write"
storyRollbackFailure = "injected credit failure"
racePauseBetweenRetries = 10 * time.Millisecond
)
func localeOf(value string) *string {
return &value
}
func storageDefaultSite(t *testing.T, db *gorm.DB) entity.Site {
t.Helper()
var site entity.Site
if err := db.Where("is_default = ?", true).First(&site).Error; err != nil {
t.Fatalf("default site: %v", err)
}
return site
}
func storageSetLocale(t *testing.T, db *gorm.DB, siteID uint32, locale *string) {
t.Helper()
if err := db.Model(&entity.Site{}).Where("id = ?", siteID).UpdateColumn("locale", locale).Error; err != nil {
t.Fatalf("set locale: %v", err)
}
}
func regressToPreLocalizationSchema(t *testing.T, db *gorm.DB) {
t.Helper()
migrator := db.Migrator()
for _, name := range []string{storyLanguageIndexName, siteSlugIndexName} {
if migrator.HasIndex(&entity.Article{}, name) {
if err := migrator.DropIndex(&entity.Article{}, name); err != nil {
t.Fatalf("drop %s: %v", name, err)
}
}
}
if err := db.Exec("CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)").Error; err != nil {
t.Fatalf("recreate the pre-change slug index: %v", err)
}
statements := []string{
"UPDATE articles SET story_id = 0, lang = ''",
"DELETE FROM stories",
"DELETE FROM site_languages",
"DELETE FROM migration_markers WHERE name IN ('" + storiesBackfillMarker + "', '" + languageSuggestionsMarker + "', '" + slugPerLanguageMarker + "')",
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("%s: %v", statement, err)
}
}
}
func seedPreLocalizationArticles(t *testing.T, db *gorm.DB, siteID uint32, softDeleted ...uint32) {
t.Helper()
for id := uint32(1); id <= 3; id++ {
row := entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Article %d", id), Body: fmt.Sprintf("Body %d", id), SiteID: siteID, Slug: fmt.Sprintf("article-%d", id), IsOnline: id == 3}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed article %d: %v", id, err)
}
}
for _, id := range softDeleted {
if err := db.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", domainArticle.Tombstone(id)).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&entity.Article{}, id).Error; err != nil {
t.Fatal(err)
}
}
regressToPreLocalizationSchema(t, db)
}
func legacyDatabase(t *testing.T, open func(*testing.T) *gorm.DB, locale *string, softDeleted ...uint32) (*gorm.DB, entity.Site) {
t.Helper()
db := open(t)
site := storageDefaultSite(t, db)
storageSetLocale(t, db, site.ID, locale)
seedPreLocalizationArticles(t, db, site.ID, softDeleted...)
return db, site
}
func migrateStorage(t *testing.T, db *gorm.DB) {
t.Helper()
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
}
func storedArticles(t *testing.T, db *gorm.DB) []entity.Article {
t.Helper()
var rows []entity.Article
if err := db.Unscoped().Order("id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
func storedArticle(t *testing.T, db *gorm.DB, id uint32) entity.Article {
t.Helper()
var row entity.Article
if err := db.Unscoped().First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
func storageSnapshot(t *testing.T, db *gorm.DB) string {
t.Helper()
var stories []entity.Story
var languages []entity.SiteLanguage
var markers []entity.MigrationMarker
for _, target := range []any{&stories, &languages, &markers} {
if err := db.Order("1").Find(target).Error; err != nil {
t.Fatal(err)
}
}
indexes, err := db.Migrator().GetIndexes(&entity.Article{})
if err != nil {
t.Fatal(err)
}
names := []string{}
for _, index := range indexes {
unique, _ := index.Unique()
names = append(names, fmt.Sprintf("%s:%v:%v", index.Name(), unique, index.Columns()))
}
slices.Sort(names)
return fmt.Sprintf("%v|%v|%v|%v|%v", storedArticles(t, db), stories, languages, markers, names)
}
func markerRecorded(t *testing.T, db *gorm.DB, name string) bool {
t.Helper()
var count int64
if err := db.Model(&entity.MigrationMarker{}).Where("name = ?", name).Count(&count).Error; err != nil {
t.Fatal(err)
}
return count > 0
}
func createThroughUseCase(db *gorm.DB) *useCases.CreateArticleUseCase {
return useCases.NewCreateArticleUseCase(persistence.NewTransactor(db), &fakeBodyRenderer{}, discardRecorder, persistence.NewSiteRepository(db), persistence.NewAuthorRepository(db))
}
func createTitled(db *gorm.DB, title string) (domainArticle.Article, error) {
return createThroughUseCase(db).CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: title, Caller: ownerCaller(1)})
}
func newStorageArticle(siteID uint32, lang string, slug string, storyID uint32) domainArticle.Article {
return domainArticle.Article{Title: "Article " + slug, Body: "Body", SiteID: siteID, Lang: lang, Slug: slug, StoryID: storyID, SEODismissed: []string{}}
}
func runArticleLocalizationStorage(t *testing.T, open func(*testing.T) *gorm.DB) {
t.Run("Migration of an existing site and its articles", func(t *testing.T) {
db, site := legacyDatabase(t, open, localeOf("fr-CA"))
before := storedArticles(t, db)
migrateStorage(t, db)
after := storedArticles(t, db)
seen := map[uint32]bool{}
for index, row := range after {
if row.ID != before[index].ID || row.Title != before[index].Title || row.Body != before[index].Body || row.Slug != before[index].Slug || row.IsOnline != before[index].IsOnline || !row.CreatedAt.Equal(before[index].CreatedAt) {
t.Errorf("article %d changed: %+v", row.ID, row)
}
if row.Lang != "" || row.StoryID == 0 || seen[row.StoryID] || row.ContentRevision != 1 {
t.Errorf("article %d must have lang '', its own story and revision 1: %+v", row.ID, row)
}
seen[row.StoryID] = true
}
var languages []entity.SiteLanguage
if err := db.Where("site_id = ?", site.ID).Find(&languages).Error; err != nil {
t.Fatal(err)
}
if len(languages) != 1 || languages[0].Lang != "fr-CA" || !languages[0].IsDefault || !languages[0].Enabled || languages[0].Confirmed {
t.Errorf("expected one unconfirmed default fr-CA row, got %+v", languages)
}
if !markerRecorded(t, db, storiesBackfillMarker) {
t.Error("the story backfill marker must be recorded")
}
})
t.Run("Bare language fallback is stored as the suggestion", func(t *testing.T) {
db, site := legacyDatabase(t, open, localeOf("fr-BE"))
migrateStorage(t, db)
languages, err := persistence.NewSiteLanguageRepository(db).ListBySite(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)
}
})
t.Run("Site without a usable locale gets no suggestion row", func(t *testing.T) {
for _, locale := range []*string{nil, localeOf("xx-YY")} {
db, site := legacyDatabase(t, open, locale)
migrateStorage(t, db)
var count int64
if err := db.Model(&entity.SiteLanguage{}).Where("site_id = ?", site.ID).Count(&count).Error; err != nil || count != 0 {
t.Errorf("expected no row, got %d %v", count, err)
}
}
})
t.Run("Migration runs twice", func(t *testing.T) {
db, _ := legacyDatabase(t, open, localeOf("fr-CA"))
migrateStorage(t, db)
first := storageSnapshot(t, db)
migrateStorage(t, db)
if second := storageSnapshot(t, db); second != first {
t.Errorf("the second migration changed the database:\n%s\n%s", first, second)
}
})
t.Run("Migration is atomic", func(t *testing.T) {
db, _ := legacyDatabase(t, open, localeOf("fr-CA"))
updates := 0
const callbackName = "test:fail-second-story-assignment"
err := db.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "articles" {
return
}
updates++
if updates == 2 {
_ = tx.AddError(errors.New(injectedBackfillFailure))
}
})
if err != nil {
t.Fatal(err)
}
failure := database.Migrate(db, migrationPublicURL)
if failure == nil {
t.Fatal("the injected failure must abort the migration")
}
for _, row := range storedArticles(t, db) {
if row.StoryID != 0 {
t.Errorf("article %d kept a story_id after the rollback", row.ID)
}
}
var stories int64
if err := db.Model(&entity.Story{}).Count(&stories).Error; err != nil || stories != 0 {
t.Errorf("the rolled back stories must not exist: %d %v", stories, err)
}
if markerRecorded(t, db, storiesBackfillMarker) {
t.Error("the marker must not be recorded")
}
if err := db.Callback().Update().Remove(callbackName); err != nil {
t.Fatal(err)
}
migrateStorage(t, db)
for _, row := range storedArticles(t, db) {
if row.StoryID == 0 {
t.Errorf("article %d must have a story after the retry", row.ID)
}
}
if !markerRecorded(t, db, storiesBackfillMarker) {
t.Error("the marker must be recorded after the retry")
}
})
t.Run("Soft-deleted articles are migrated", func(t *testing.T) {
db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2)
migrateStorage(t, db)
for _, row := range storedArticles(t, db) {
if row.StoryID == 0 {
t.Errorf("article %d has no story", row.ID)
}
if row.ID == 2 && (!row.DeletedAt.Valid || row.Slug != domainArticle.Tombstone(2)) {
t.Errorf("the tombstone must be unchanged: %+v", row)
}
}
})
t.Run("Slug is unique per site and language", func(t *testing.T) {
db := open(t)
articles := persistence.NewArticleRepository(db)
site := storageDefaultSite(t, db)
other := entity.Site{Name: "Other", Key: "other", BaseURL: "https://other.example.com", DefaultRobots: "index"}
if err := db.Create(&other).Error; err != nil {
t.Fatal(err)
}
if _, err := articles.Create(newStorageArticle(site.ID, "fr", "bonjour", 0)); err != nil {
t.Fatalf("first create: %v", err)
}
_, duplicate := articles.Create(newStorageArticle(site.ID, "fr", "bonjour", 0))
_, otherLanguage := articles.Create(newStorageArticle(site.ID, "en", "bonjour", 0))
_, otherSite := articles.Create(newStorageArticle(other.ID, "fr", "bonjour", 0))
if !errors.Is(duplicate, domainArticle.ErrSlugTaken) {
t.Errorf("expected ErrSlugTaken, got %v", duplicate)
}
if otherLanguage != nil || otherSite != nil {
t.Errorf("language and site variants must be stored: %v %v", otherLanguage, otherSite)
}
})
t.Run("Existing unassigned articles keep the current per-site slug rule", func(t *testing.T) {
db := open(t)
articles := persistence.NewArticleRepository(db)
site := storageDefaultSite(t, db)
if _, err := articles.Create(newStorageArticle(site.ID, "", "same", 0)); err != nil {
t.Fatal(err)
}
second, err := articles.Create(newStorageArticle(site.ID, "", "other", 0))
if err != nil {
t.Fatal(err)
}
_, updateErr := articles.Update(second.ID, domainArticle.Update{Slug: localeOf("same")})
if !errors.Is(updateErr, domainArticle.ErrSlugTaken) {
t.Errorf("expected ErrSlugTaken, got %v", updateErr)
}
})
t.Run("One version per language in a story", func(t *testing.T) {
db := open(t)
articles := persistence.NewArticleRepository(db)
site := storageDefaultSite(t, db)
story := entity.Story{}
if err := db.Create(&story).Error; err != nil {
t.Fatal(err)
}
if _, err := articles.Create(newStorageArticle(site.ID, "fr", "un", story.ID)); err != nil {
t.Fatal(err)
}
_, duplicate := articles.Create(newStorageArticle(site.ID, "fr", "deux", story.ID))
_, otherLanguage := articles.Create(newStorageArticle(site.ID, "en", "three", story.ID))
if duplicate == nil {
t.Error("a second fr version in the same story must be rejected")
}
if otherLanguage != nil {
t.Errorf("an en version must be stored: %v", otherLanguage)
}
})
t.Run("Unassigned articles never collide on language", func(t *testing.T) {
db := open(t)
articles := persistence.NewArticleRepository(db)
site := storageDefaultSite(t, db)
stories := map[uint32]bool{}
for index := 0; index < 3; index++ {
created, err := articles.Create(newStorageArticle(site.ID, "", fmt.Sprintf("slug-%d", index), 0))
if err != nil || created.StoryID == 0 || stories[created.StoryID] || created.Lang != "" {
t.Fatalf("article %d: %+v %v", index, created, err)
}
stories[created.StoryID] = true
}
})
t.Run("Creating an article before the default is confirmed", func(t *testing.T) {
db, _ := legacyDatabase(t, open, localeOf("fr-CA"))
migrateStorage(t, db)
created, err := createTitled(db, "Bonjour le monde")
stored := storedArticle(t, db, created.ID)
if err != nil || stored.Lang != "" || stored.StoryID == 0 || stored.ContentRevision != 1 || created.ContentRevision != 1 {
t.Errorf("expected lang '' with a new story and revision 1, got %+v %v", stored, err)
}
})
t.Run("Creating an article after the default is confirmed", func(t *testing.T) {
db, site := legacyDatabase(t, open, localeOf("fr-CA"))
migrateStorage(t, db)
if err := persistence.NewSiteLanguageRepository(db).ConfirmDefault(site.ID); err != nil {
t.Fatal(err)
}
created, err := createTitled(db, "Bonjour le monde")
stored := storedArticle(t, db, created.ID)
if err != nil || stored.Lang != "fr-CA" {
t.Fatalf("expected lang fr-CA, got %+v %v", stored, err)
}
for _, row := range storedArticles(t, db) {
if row.ID != stored.ID && row.StoryID == stored.StoryID {
t.Errorf("the new story must be distinct from article %d", row.ID)
}
}
})
t.Run("Create racing a confirmation never strands an article", func(t *testing.T) {
db, site := legacyDatabase(t, open, localeOf("fr-CA"))
migrateStorage(t, db)
languages := persistence.NewSiteLanguageRepository(db)
var group sync.WaitGroup
var retries atomic.Int64
start := make(chan struct{})
retry := func(work func() error) error {
var err error
for attempt := 0; attempt < raceRetries; attempt++ {
if err = work(); err == nil {
return nil
}
retries.Add(1)
time.Sleep(racePauseBetweenRetries)
}
return err
}
failures := make(chan error, raceCreators+1)
for index := 0; index < raceCreators; index++ {
group.Add(1)
go func() {
defer group.Done()
<-start
failures <- retry(func() error {
_, err := createTitled(db, fmt.Sprintf("Race article %d", index))
return err
})
}()
}
group.Add(1)
go func() {
defer group.Done()
<-start
failures <- retry(func() error { return languages.ConfirmDefault(site.ID) })
}()
close(start)
group.Wait()
close(failures)
for err := range failures {
if err != nil {
t.Fatalf("a racing write never succeeded: %v", err)
}
}
t.Logf("busy retries: %d of a budget of %d", retries.Load(), raceRetryBudget)
if retries.Load() > raceRetryBudget {
t.Errorf("%d retries exceed the budget of %d: the lock is not serializing the writers", retries.Load(), raceRetryBudget)
}
rows := storedArticles(t, db)
if len(rows) != 3+raceCreators {
t.Fatalf("expected %d articles, got %d", 3+raceCreators, len(rows))
}
for _, row := range rows {
if row.Lang != "fr-CA" {
t.Errorf("article %d ended with lang %q", row.ID, row.Lang)
}
}
})
t.Run("A failed create leaves no orphan story", func(t *testing.T) {
db := open(t)
site := storageDefaultSite(t, db)
var before int64
if err := db.Model(&entity.Story{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
err := db.Callback().Create().Before("gorm:create").Register(storyRollbackCallback, func(tx *gorm.DB) {
if tx.Statement.Table == "article_authors" {
_ = tx.AddError(errors.New(storyRollbackFailure))
}
})
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Callback().Create().Remove(storyRollbackCallback); err != nil {
t.Fatal(err)
}
}()
draft := newStorageArticle(site.ID, "", "orphan", 0)
draft.Authors = []authordomain.Byline{{ID: 1}}
_, createErr := persistence.NewArticleRepository(db).Create(draft)
var after int64
if err := db.Model(&entity.Story{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
if createErr == nil || after != before {
t.Errorf("the story must roll back with the failed create: error %v, stories %d then %d", createErr, before, after)
}
})
t.Run("Migrating twice keeps the same slug in two languages of one site", func(t *testing.T) {
db := open(t)
site := storageDefaultSite(t, db)
articles := persistence.NewArticleRepository(db)
for _, lang := range []string{"fr", "en"} {
if _, err := articles.Create(newStorageArticle(site.ID, lang, "bonjour", 0)); err != nil {
t.Fatalf("create %s: %v", lang, err)
}
}
migrateStorage(t, db)
migrateStorage(t, db)
for _, row := range storedArticles(t, db) {
if row.Slug != "bonjour" {
t.Errorf("article %d (%s) was renamed to %q", row.ID, row.Lang, row.Slug)
}
}
})
t.Run("Assigning a language is idempotent", func(t *testing.T) {
db, site := legacyDatabase(t, open, localeOf("fr-CA"))
migrateStorage(t, db)
languages := persistence.NewSiteLanguageRepository(db)
if err := languages.ConfirmDefault(site.ID); err != nil {
t.Fatal(err)
}
before := storageSnapshot(t, db)
err := languages.ConfirmDefault(site.ID)
if err != nil || storageSnapshot(t, db) != before {
t.Errorf("the second assignment must change nothing: %v", err)
}
for _, row := range storedArticles(t, db) {
if row.Lang != "fr-CA" {
t.Errorf("article %d has lang %q", row.ID, row.Lang)
}
}
})
t.Run("Confirming a site without a default language is refused", func(t *testing.T) {
db := open(t)
site := storageDefaultSite(t, db)
err := persistence.NewSiteLanguageRepository(db).ConfirmDefault(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} {
if !db.Migrator().HasIndex(&entity.Article{}, name) {
t.Errorf("index %s is missing", name)
}
}
indexes, err := db.Migrator().GetIndexes(&entity.Article{})
if err != nil {
t.Fatal(err)
}
for _, index := range indexes {
if index.Name() == siteSlugIndexName && !slices.Equal(slices.Sorted(slices.Values(index.Columns())), []string{"lang", "site_id", "slug"}) {
t.Errorf("the slug index must cover (site_id, lang, slug), got %v", index.Columns())
}
}
})
t.Run("The per-language slug index is recorded and survives a second migration", func(t *testing.T) {
db := open(t)
if !markerRecorded(t, db, slugPerLanguageMarker) {
t.Error("the per-language slug index marker must be recorded")
}
migrateStorage(t, db)
if !db.Migrator().HasIndex(&entity.Article{}, siteSlugIndexName) || !markerRecorded(t, db, slugPerLanguageMarker) {
t.Error("a second migration must keep the index and its marker")
}
})
t.Run("A missing per-language slug index is recreated once the marker exists", func(t *testing.T) {
db := open(t)
if err := db.Migrator().DropIndex(&entity.Article{}, siteSlugIndexName); err != nil {
t.Fatal(err)
}
migrateStorage(t, db)
if !db.Migrator().HasIndex(&entity.Article{}, siteSlugIndexName) {
t.Error("the slug index must be recreated")
}
})
}
func TestArticleLocalizationStorageSQLite(t *testing.T) {
runArticleLocalizationStorage(t, openContractSQLite)
}
func TestArticleLocalizationStorageMySQL(t *testing.T) {
runArticleLocalizationStorage(t, openContractMySQL)
}
func TestArticleLocalizationStoragePostgres(t *testing.T) {
runArticleLocalizationStorage(t, openContractPostgres)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+100
View File
@@ -0,0 +1,100 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainSite "RenewCMS/internal/domain/site"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
)
func TestArticleRepositoryLocksOnlyWhenASiteIsInvolved(t *testing.T) {
t.Run("Creating an article without a site does not look up a site", func(t *testing.T) {
articles := persistence.NewArticleRepository(openContractSQLite(t))
created, err := articles.Create(newStorageArticle(0, "", "orphan", 0))
if err != nil {
t.Fatalf("expected the siteless article to be created, got %v", err)
}
if created.ID == 0 || created.SiteID != 0 {
t.Errorf("unexpected created article: %+v", created)
}
})
t.Run("Creating an article on a missing site fails", func(t *testing.T) {
articles := persistence.NewArticleRepository(openContractSQLite(t))
_, err := articles.Create(newStorageArticle(9999, "", "ghost", 0))
if !errors.Is(err, domainSite.ErrSiteNotFound) {
t.Errorf("expected ErrSiteNotFound, got %v", err)
}
})
t.Run("Updating the excerpt of a siteless article does not look up a site", func(t *testing.T) {
articles := persistence.NewArticleRepository(openContractSQLite(t))
created, err := articles.Create(newStorageArticle(0, "", "orphan", 0))
if err != nil {
t.Fatal(err)
}
updated, err := articles.Update(created.ID, domainArticle.Update{Excerpt: localeOf("Renamed")})
if err != nil || updated.Excerpt != "Renamed" {
t.Errorf("expected the excerpt update to succeed, got %+v %v", updated, err)
}
})
t.Run("Moving a siteless article to a site while setting a slug locks only the target", func(t *testing.T) {
db := openContractSQLite(t)
articles := persistence.NewArticleRepository(db)
site := storageDefaultSite(t, db)
created, err := articles.Create(newStorageArticle(0, "", "orphan", 0))
if err != nil {
t.Fatal(err)
}
updated, err := articles.Update(created.ID, domainArticle.Update{SiteID: &site.ID, Slug: localeOf("moved")})
if err != nil || updated.SiteID != site.ID || updated.Slug != "moved" {
t.Errorf("expected the move to succeed, got %+v %v", updated, err)
}
if stored := storedArticle(t, db, created.ID); stored.SiteID != site.ID || stored.Slug != "moved" {
t.Errorf("the move must be persisted, got site %d slug %q", stored.SiteID, stored.Slug)
}
})
t.Run("Setting a slug on a siteless article needs its site", func(t *testing.T) {
articles := persistence.NewArticleRepository(openContractSQLite(t))
created, err := articles.Create(newStorageArticle(0, "", "orphan", 0))
if err != nil {
t.Fatal(err)
}
_, err = articles.Update(created.ID, domainArticle.Update{Slug: localeOf("renamed")})
if !errors.Is(err, domainSite.ErrSiteNotFound) {
t.Errorf("expected ErrSiteNotFound, got %v", err)
}
})
}
func TestArticleRepositoryPersistsASlugChange(t *testing.T) {
db := openContractSQLite(t)
articles := persistence.NewArticleRepository(db)
site := storageDefaultSite(t, db)
created, err := articles.Create(newStorageArticle(site.ID, "", "before", 0))
if err != nil {
t.Fatal(err)
}
updated, err := articles.Update(created.ID, domainArticle.Update{Slug: localeOf("after")})
if err != nil || updated.Slug != "after" {
t.Fatalf("expected the slug change to succeed, got %+v %v", updated, err)
}
if stored := storedArticle(t, db, created.ID); stored.Slug != "after" {
t.Errorf("the slug change must be persisted, got %q", stored.Slug)
}
}
+7 -2
View File
@@ -71,6 +71,11 @@ func openLegacyArticleDatabase(t *testing.T) *gorm.DB {
func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) {
t.Helper()
for index := range rows {
if rows[index].StoryID == 0 {
rows[index].StoryID = fixtureStory()
}
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed: %v", err)
}
@@ -79,7 +84,7 @@ func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) {
func legacyArticles(count int) []entity.Article {
rows := make([]entity.Article, 0, count)
for i := 1; i <= count; i++ {
rows = append(rows, entity.Article{Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)})
rows = append(rows, entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)})
}
return rows
}
@@ -217,7 +222,7 @@ func TestBackfillArticleSlugs(t *testing.T) {
t.Run("Soft-deleted rows get their tombstone once and existing slugs are kept", func(t *testing.T) {
db := openLegacyArticleDatabase(t)
seedLegacyArticles(t, db, []entity.Article{{Title: "Gone one", SiteID: 1}, {Title: "Gone two", SiteID: 1}, {Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}})
seedLegacyArticles(t, db, []entity.Article{{StoryID: fixtureStory(), Title: "Gone one", SiteID: 1}, {StoryID: fixtureStory(), Title: "Gone two", SiteID: 1}, {StoryID: fixtureStory(), Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}})
if err := db.Where("id IN ?", []uint32{1, 2}).Delete(&entity.Article{}).Error; err != nil {
t.Fatalf("soft delete: %v", err)
}
+7 -7
View File
@@ -237,23 +237,23 @@ func (m *mockArticleRepo) GetAll() ([]article.Article, error) {
return result, nil
}
func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool {
func (m *mockArticleRepo) slugUsedOn(siteID uint32, lang string, slug string, exceptID uint32) bool {
if slug == "" {
return false
}
for id, other := range m.articles {
if id != exceptID && other.SiteID == siteID && other.Slug == slug {
if id != exceptID && other.SiteID == siteID && other.Lang == lang && other.Slug == slug {
return true
}
}
return false
}
func (m *mockArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
func (m *mockArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
if err := m.fail.trigger("SlugTaken"); err != nil {
return false, err
}
return m.slugUsedOn(siteID, slug, exceptID), nil
return m.slugUsedOn(siteID, lang, slug, exceptID), nil
}
func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) {
@@ -339,7 +339,7 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
}
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, 0) {
if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, 0) {
return article.Article{}, article.ErrSlugTaken
}
m.nextID++
@@ -395,7 +395,7 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, id) {
if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, id) {
return article.Article{}, article.ErrSlugTaken
}
applySeoUpdate(&a, update)
@@ -479,7 +479,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"}
+2 -2
View File
@@ -26,7 +26,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
+1 -1
View File
@@ -20,7 +20,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -359,7 +359,7 @@ func newAuthorEnv(t *testing.T) *authorEnv {
renderer := &fakeBodyRenderer{}
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
+1 -1
View File
@@ -24,7 +24,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+103
View File
@@ -0,0 +1,103 @@
package test
import (
"RenewCMS/internal/domain/language"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"sync"
"testing"
"gorm.io/gorm/schema"
)
func TestLanguageNormalization(t *testing.T) {
cases := []struct {
input string
want string
err error
}{
{"en", "en", nil},
{"EN", "en", nil},
{"pt-br", "pt-BR", nil},
{"PT_BR", "", language.ErrInvalidLanguage},
{"fr-ca", "fr-CA", nil},
{" fr", "fr", nil},
{"e", "", language.ErrInvalidLanguage},
{"english", "", language.ErrInvalidLanguage},
{"en-ZZZZ", "", language.ErrInvalidLanguage},
{"", "", language.ErrInvalidLanguage},
{"xx", "", language.ErrUnsupportedLanguage},
}
for _, c := range cases {
t.Run("Language tag normalization "+c.input, func(t *testing.T) {
got, err := language.Normalize(c.input)
if got != c.want || !errors.Is(err, c.err) {
t.Errorf("Normalize(%q) = %q, %v; want %q, %v", c.input, got, err, c.want, c.err)
}
})
}
}
func TestLanguageSuggestion(t *testing.T) {
text := func(value string) *string { return &value }
cases := []struct {
locale *string
suggested string
substituted bool
}{
{text("pt-BR"), "pt-BR", false},
{text("en-GB"), "en-GB", false},
{text("fr-BE"), "fr", true},
{text("fr"), "fr", false},
{text("xx-YY"), "", false},
{nil, "", false},
}
for _, c := range cases {
name := "none"
if c.locale != nil {
name = *c.locale
}
t.Run("Suggestion for a site's default language "+name, func(t *testing.T) {
got := language.Suggest(c.locale)
if got.Tag != c.suggested || got.Substituted != c.substituted || got.Found != (c.suggested != "") {
t.Errorf("Suggest = %+v; want %q substituted %v", got, c.suggested, c.substituted)
}
})
}
}
func TestLanguageSupportedReturnsIndependentCopy(t *testing.T) {
first := language.Supported()
if len(first) == 0 {
t.Fatal("expected supported languages")
}
first[0] = "xx"
second := language.Supported()
if second[0] == "xx" {
t.Fatal("Supported must return a copy")
}
if _, err := language.Normalize(second[0]); err != nil {
t.Fatalf("supported tag %q must normalize: %v", second[0], err)
}
}
func TestLanguageColumnsMatchTheDomainTagLength(t *testing.T) {
columns := []struct {
model any
field string
column string
}{
{&entity.Article{}, "Lang", "lang"},
{&entity.SiteLanguage{}, "Lang", "lang"},
{&entity.Site{}, "Locale", "locale"},
}
for _, c := range columns {
parsed, err := schema.Parse(c.model, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
if size := parsed.LookUpField(c.field).Size; size != language.MaxTagLength {
t.Errorf("%s.%s has size %d, expected %d", parsed.Table, c.column, size, language.MaxTagLength)
}
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ func TestMCPUploadsMultipartFromBase64(t *testing.T) {
t.Fatal(toolText(res))
}
call := f.last()
if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, `name="file"; filename="wall.png"`) || !strings.Contains(call.body, "PNGDATA") {
if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, "name=file") || !strings.Contains(call.body, "filename=wall.png") || !strings.Contains(call.body, "Content-Type: image/png") || !strings.Contains(call.body, "PNGDATA") {
t.Errorf("sent %+v", call)
}
if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError {
+1 -1
View File
@@ -67,7 +67,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
+1 -1
View File
@@ -18,7 +18,7 @@ import (
func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
uploadDir := t.TempDir()
+1 -1
View File
@@ -188,7 +188,7 @@ func TestDriverRegistries(t *testing.T) {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil {
if err := connection.DB.Create(&models.Article{StoryID: fixtureStory(), Title: "t"}).Error; err != nil {
t.Fatalf("expected the schema to exist: %v", err)
}
if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil {
+2 -2
View File
@@ -35,7 +35,7 @@ func openInMemoryDb(t *testing.T) *gorm.DB {
func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewArticleRepository(db)
@@ -126,7 +126,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -42,7 +42,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -24,7 +24,7 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+2 -2
View File
@@ -294,7 +294,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
t.Run("CreateArticle needs content.create for the article type and stores the caller", func(t *testing.T) {
repo, sites, authors, _ := newRepos()
create := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors)
create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, sites, authors)
created, err := create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(25, "content.create=all", "content.update:article=none")})
if err != nil || created.CreatedBy != 25 || created.IsOnline {
t.Fatalf("expected a draft created by 25, got %+v %v", created, err)
@@ -493,7 +493,7 @@ func TestContentScopeCreateArticleAuthorLookupFailure(t *testing.T) {
boom := errors.New("author store down")
authors.fail.failNextWith("GetByUserID", boom)
articles := newMockArticleRepo()
create := useCases.NewCreateArticleUseCase(articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), authors)
create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), authors)
caller := useCases.ContentCaller{UserID: 9, Grants: role.Role{Grants: grantRows("content.create=all")}.GrantSet()}
_, err := create.CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: "Hello", Body: "x", Caller: caller})
if !errors.Is(err, boom) {
+2 -2
View File
@@ -270,7 +270,7 @@ func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 {
func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
t.Helper()
if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
if err := db.Create(&models.Article{ID: id, StoryID: fixtureStory(), Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
t.Fatal(err)
}
}
@@ -339,7 +339,7 @@ func TestBackfillCreatedBy(t *testing.T) {
func TestNewArticlesRecordTheirCreator(t *testing.T) {
t.Run("New articles record their creator", func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -130,7 +130,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
+1 -1
View File
@@ -70,7 +70,7 @@ func TestSeoDataCleanerSlugRaces(t *testing.T) {
t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) {
e := newSeoEnv(t)
racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles}
create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(racing), &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
_, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Racing", Body: "x"})
+6 -6
View File
@@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
migrateSeo(t, db)
found, err := persistence.NewArticleRepository(db).Get(1)
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt}
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1}
if err != nil || !reflect.DeepEqual(found, want) {
t.Errorf("found %+v, %v", found, err)
}
@@ -251,7 +251,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
t.Fatal("the index is missing")
}
duplicate := entity.Article{Title: "Again", SiteID: 1, Slug: "news"}
duplicate := entity.Article{StoryID: fixtureStory(), Title: "Again", SiteID: 1, Slug: "news"}
if err := db.Create(&duplicate).Error; err == nil {
t.Error("a duplicate (site_id, slug) was accepted")
}
@@ -299,7 +299,7 @@ func TestSeoDataArticleRepositoryContract(t *testing.T) {
_, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"})
_, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, Slug: "spring"})
rawDuplicate := db.Create(&entity.Article{Title: "Four", SiteID: siteID, Slug: "spring"}).Error
rawDuplicate := db.Create(&entity.Article{StoryID: fixtureStory(), Title: "Four", SiteID: siteID, Slug: "spring"}).Error
if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil {
t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate)
@@ -346,10 +346,10 @@ func TestSeoDataArticleRepositoryContract(t *testing.T) {
{"slug taken ignores the article itself and a deleted article frees its slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
own, ownErr := articles.SlugTaken(siteID, "spring", created.ID)
other, otherErr := articles.SlugTaken(siteID, "spring", 0)
own, ownErr := articles.SlugTaken(siteID, "", "spring", created.ID)
other, otherErr := articles.SlugTaken(siteID, "", "spring", 0)
deleteErr := articles.Delete(created.ID)
afterDelete, afterErr := articles.SlugTaken(siteID, "spring", 0)
afterDelete, afterErr := articles.SlugTaken(siteID, "", "spring", 0)
_, recreated := articles.Create(article.Article{Title: "Again", SiteID: siteID, Slug: "spring"})
if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil {
+1 -1
View File
@@ -55,7 +55,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
renderer := &fakeBodyRenderer{}
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSeoFilterPerformance(t *testing.T) {
body := strings.Repeat("<p>"+strings.Repeat("word ", 120)+"</p>", 8)
rows := make([]entity.Article, 0, seoPerformanceArticles)
for i := 1; i <= seoPerformanceArticles; i++ {
row := entity.Article{Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"}
row := entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"}
if i%2 == 0 {
row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100)
}
+2 -2
View File
@@ -69,9 +69,9 @@ func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Coun
return m.mockArticleRepo.CountByVisibility(onlineOnly)
}
func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
m.reads["SlugTaken"]++
return m.mockArticleRepo.SlugTaken(siteID, slug, exceptID)
return m.mockArticleRepo.SlugTaken(siteID, lang, slug, exceptID)
}
func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) {
+2 -2
View File
@@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) {
func TestSiteUseCaseRules(t *testing.T) {
t.Run("Creating an article assigns it to the default site", func(t *testing.T) {
repo := newMockArticleRepo()
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
created, err := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
if err != nil || created.SiteID != 1 {
t.Errorf("created %+v, %v", created, err)
}
@@ -102,7 +102,7 @@ func TestSiteUseCaseRules(t *testing.T) {
t.Run("Creating an article fails when no default site can be read", func(t *testing.T) {
repo := newMockArticleRepo()
sites := newMockSiteRepo(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
+1 -1
View File
@@ -300,7 +300,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
renderer := &fakeBodyRenderer{}
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
+2 -2
View File
@@ -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"}
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"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper()
@@ -238,7 +238,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
}
func isNewTableObject(name string) bool {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role"} {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role", "stories", "site_languages", "idx_articles_story_lang"} {
if strings.HasPrefix(name, prefix) {
return true
}
+1 -1
View File
@@ -23,7 +23,7 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+2 -2
View File
@@ -30,7 +30,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
@@ -63,7 +63,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Backyard Renovation Update", Body: "Some body text"})
+1 -1
View File
@@ -21,7 +21,7 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+2 -2
View File
@@ -25,7 +25,7 @@ import (
func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewArticleRepository(db)
@@ -213,7 +213,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),