feat: storage, language rules and migration
This commit is contained in:
1 parent
8c4a1825ab
commit
0806733d5c
70 files changed
+1771
-132
No files matched your search
@@ -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"}]`},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user