feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles
This commit is contained in:
1 parent
339e646eb8
commit
45fbbc33b1
93 files changed
+7630
-294
No files matched your search
@@ -3,6 +3,7 @@ package test
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -13,6 +14,9 @@ const (
|
||||
createLanguageSiteID = 1
|
||||
createLanguageTitle = "Bonjour le monde"
|
||||
confirmedDefaultTag = "fr-CA"
|
||||
|
||||
createLanguageOtherSiteID = 2
|
||||
createLanguageOtherSiteKey = "atelier"
|
||||
)
|
||||
|
||||
func newCreateLanguageFixture(confirmed map[uint32]string) (*useCases.CreateArticleUseCase, *mockCreateTransactor, *mockArticleRepo) {
|
||||
@@ -76,4 +80,52 @@ func TestCreateArticleStartingLanguage(t *testing.T) {
|
||||
t.Errorf("expected %v without a create, got %v", failure, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The command site decides the site and the language lock", func(t *testing.T) {
|
||||
create, transactor, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag, createLanguageOtherSiteID: "de"})
|
||||
create = useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, createLanguageSites(), plainAuthors())
|
||||
command := createLanguageCommand()
|
||||
command.Site = stringPointer(createLanguageOtherSiteKey)
|
||||
|
||||
created, err := create.CreateArticle(context.Background(), command)
|
||||
|
||||
stored := articles.articles[created.ID]
|
||||
if err != nil || stored.SiteID != createLanguageOtherSiteID || stored.Lang != "de" {
|
||||
t.Fatalf("expected a de article on site %d, got %+v %v", createLanguageOtherSiteID, stored, err)
|
||||
}
|
||||
if len(transactor.languages.locked) != 1 || transactor.languages.locked[0] != createLanguageOtherSiteID {
|
||||
t.Errorf("the chosen site must be locked: %v", transactor.languages.locked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown command site stores nothing", func(t *testing.T) {
|
||||
create, transactor, articles := newCreateLanguageFixture(map[uint32]string{})
|
||||
create = useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, createLanguageSites(), plainAuthors())
|
||||
command := createLanguageCommand()
|
||||
command.Site = stringPointer("missing")
|
||||
|
||||
_, err := create.CreateArticle(context.Background(), command)
|
||||
|
||||
if !errors.Is(err, site.ErrSiteNotFound) || articles.createCalled || transactor.runs != 0 {
|
||||
t.Errorf("expected site not found without a transaction, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An omitted command site reads the default site", func(t *testing.T) {
|
||||
sites := createLanguageSites()
|
||||
create, transactor, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag})
|
||||
create = useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
|
||||
|
||||
created, err := create.CreateArticle(context.Background(), createLanguageCommand())
|
||||
|
||||
if err != nil || articles.articles[created.ID].SiteID != createLanguageSiteID {
|
||||
t.Errorf("expected the default site, got %+v %v", articles.articles[created.ID], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createLanguageSites() *mockSiteRepo {
|
||||
sites := plainSites()
|
||||
sites.seed(createLanguageOtherSiteID, "Atelier", createLanguageOtherSiteKey, false)
|
||||
return sites
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
createSiteNotes uint32 = 3
|
||||
createSiteArchive uint32 = 4
|
||||
createSiteFresh uint32 = 5
|
||||
createSitePath = "/v1/article/"
|
||||
)
|
||||
|
||||
func newCreateSiteWorld(t *testing.T) *storyWorld {
|
||||
t.Helper()
|
||||
w := newTranslationWorld(t)
|
||||
w.sites.seed(createSiteNotes, "Notes", "notes", false)
|
||||
w.sites.seed(createSiteArchive, "Archive", "archive", false)
|
||||
w.sites.seed(createSiteFresh, "Fresh", "fresh", false)
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
|
||||
w.rows.rows[createSiteNotes] = storyRows("en", []string{"en"}, nil)
|
||||
w.rows.rows[createSiteArchive] = storyRows("en", []string{"en"}, nil)
|
||||
w.transactor.starting = map[uint32]string{storyJournal: "en", storyAtelier: "fr", createSiteNotes: "en", createSiteArchive: "en"}
|
||||
return w
|
||||
}
|
||||
|
||||
func fieldNames(response storyResponse, t *testing.T) []string {
|
||||
t.Helper()
|
||||
names := []string{}
|
||||
for name := range response.fields(t) {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func TestArticleCreateSiteHTTP(t *testing.T) {
|
||||
t.Run("The site decides where the new article is created", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
siteID uint32
|
||||
lang string
|
||||
}{
|
||||
{`{"title":"Plain","body":"x"}`, storyJournal, "en"},
|
||||
{`{"title":"Plain","site":"journal"}`, storyJournal, "en"},
|
||||
{`{"title":"Neuf","lang":"fr","site":"atelier"}`, storyAtelier, "fr"},
|
||||
{`{"title":"Plain","site":"atelier"}`, storyAtelier, "fr"},
|
||||
{`{"title":"Plain","site":null}`, storyJournal, "en"},
|
||||
{`{"title":"Plain","site":"notes","lang":"en"}`, createSiteNotes, "en"},
|
||||
}
|
||||
baseline := fieldNames(newCreateSiteWorld(t).send(storyWriter, http.MethodPost, createSitePath, `{"title":"Plain"}`), t)
|
||||
for _, c := range cases {
|
||||
t.Run(c.body, func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, createSitePath, c.body)
|
||||
|
||||
created := createdTranslation(t, w, response)
|
||||
if created.SiteID != c.siteID || created.Lang != c.lang || created.IsOnline || created.CreatedBy != storyWriter || created.StoryID == storyStory {
|
||||
t.Errorf("unexpected article: %+v", created)
|
||||
}
|
||||
if got := fieldNames(response, t); !reflect.DeepEqual(got, baseline) {
|
||||
t.Errorf("the response keys changed: %v vs %v", got, baseline)
|
||||
}
|
||||
w.expectUnchanged(before, created.ID)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refused target sites", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
caller uint32
|
||||
body string
|
||||
status int
|
||||
message string
|
||||
event bool
|
||||
}{
|
||||
{storyWriter, `{"title":"Plain","site":"missing"}`, 404, apperr.SiteNotFoundErrorMessage, true},
|
||||
{storyWriter, `{"title":"Plain","site":"Bad Key!"}`, 400, apperr.InvalidSiteKeyErrorMessage, false},
|
||||
{storyWriter, `{"title":"Plain","site":""}`, 400, apperr.InvalidSiteKeyErrorMessage, false},
|
||||
{storyWriter, `{"title":"Plain","site":"atelier","lang":"es"}`, 422, apperr.LanguageNotEnabledErrorMessage, true},
|
||||
{storyWriter, `{"title":"Plain","site":"notes","lang":"fr"}`, 422, apperr.LanguageNotEnabledErrorMessage, true},
|
||||
{storyWriter, `{"title":"Plain","site":"journal","lang":"es"}`, 409, apperr.LanguageUnavailableErrorMessage, true},
|
||||
{storyWriter, `{"title":"Plain","site":"atelier","lang":"pt"}`, 422, apperr.LanguageNotEnabledErrorMessage, true},
|
||||
{storyWriter, `{"title":"Plain","site":"atelier","lang":"xx"}`, 422, apperr.UnsupportedLanguageErrorMessage, true},
|
||||
{storyWriter, `{"title":"Plain","site":"atelier","lang":"english"}`, 422, apperr.InvalidLanguageErrorMessage, true},
|
||||
{storyWriter, `{"title":"ab","site":"atelier"}`, 400, "", false},
|
||||
{storyReader, `{"title":"Plain","site":"atelier"}`, 403, apperr.MissingPermissionErrorMessage, false},
|
||||
{storyAnon, `{"title":"Plain","site":"atelier"}`, 401, "You must be logged in to access this resource.", false},
|
||||
{storyWriter, `{"title":"Plain","site":"fresh","lang":"fr"}`, 409, apperr.SetupRequiredErrorMessage, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.body, func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(c.caller, http.MethodPost, createSitePath, c.body)
|
||||
|
||||
if response.Code != c.status || (c.message != "" && strings.TrimSpace(response.text()) != c.message) {
|
||||
t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(response.text()), "{") {
|
||||
t.Errorf("a plain text body expected: %q", response.text())
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
events := w.eventsOf(audit.ActionArticleCreate)
|
||||
if c.event && (len(events) != 1 || events[0].Outcome != audit.OutcomeFailed || events[0].FailureReason == nil || *events[0].FailureReason == "") {
|
||||
t.Errorf("one failed article.create event with a plain reason expected: %+v", events)
|
||||
}
|
||||
if !c.event && c.status != http.StatusForbidden && len(events) != 0 {
|
||||
t.Errorf("no event expected: %+v", events)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The language rules are those of the chosen site", func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
|
||||
atelier := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Neuf","lang":"fr","site":"atelier"}`)
|
||||
journal := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Neuf","lang":"es","site":"journal"}`)
|
||||
|
||||
atelier.expect(t, http.StatusCreated, "")
|
||||
journal.expect(t, http.StatusConflict, apperr.LanguageUnavailableErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("The slug is free within the chosen site and language", func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
w.put(article.Article{ID: 90, SiteID: storyJournal, Lang: "fr", Slug: "couts", Title: "Couts"})
|
||||
w.put(article.Article{ID: 91, SiteID: storyAtelier, Lang: "fr", Slug: "couts", Title: "Couts"})
|
||||
w.put(article.Article{ID: 92, SiteID: storyAtelier, Lang: "en", Slug: "frais", Title: "Frais"})
|
||||
|
||||
clash := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Couts","lang":"fr","site":"atelier"}`))
|
||||
otherSite := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Couts","lang":"en","site":"atelier"}`)
|
||||
plainJournal := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Frais","lang":"fr","site":"atelier"}`)
|
||||
notClashing := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Couts","lang":"en","site":"journal"}`)
|
||||
|
||||
if clash.Slug != "couts-2" {
|
||||
t.Errorf("expected couts-2, got %q", clash.Slug)
|
||||
}
|
||||
if got := createdTranslation(t, w, otherSite).Slug; got != "couts" {
|
||||
t.Errorf("another language must keep the plain slug, got %q", got)
|
||||
}
|
||||
if got := createdTranslation(t, w, plainJournal).Slug; got != "frais" {
|
||||
t.Errorf("another language must keep the plain slug, got %q", got)
|
||||
}
|
||||
if got := createdTranslation(t, w, notClashing).Slug; got != "couts" {
|
||||
t.Errorf("another site must keep the plain slug, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site together with a translation is refused", func(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"title":"Couts","lang":"fr","story_id":5,"site":"atelier"}`,
|
||||
`{"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true,"site":"journal"}`,
|
||||
`{"title":"Couts","site":"atelier","source_article_id":10}`,
|
||||
`{"title":"Couts","site":"atelier","copy":true}`,
|
||||
}
|
||||
for _, body := range bodies {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, createSitePath, body)
|
||||
|
||||
response.expect(t, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
w.expectUnchanged(before)
|
||||
if len(w.recorder.events) != 0 {
|
||||
t.Errorf("the translation use case must not be reached")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A translation keeps the site of its source", func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"site":"atelier"}`)
|
||||
|
||||
if created := createdTranslation(t, w, response); created.SiteID != storyJournal {
|
||||
t.Errorf("the translation must stay in site 1: %+v", created)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Visibility and activity", func(t *testing.T) {
|
||||
w := newCreateSiteWorld(t)
|
||||
|
||||
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Neuf","lang":"fr","site":"atelier"}`))
|
||||
|
||||
anonymous := w.send(storyAnon, http.MethodGet, createSitePath+idText(created.ID), "")
|
||||
anonymous.expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
|
||||
for _, key := range []string{"journal", "atelier"} {
|
||||
public := w.send(storyAnon, http.MethodGet, createSitePath+"?site="+key, "")
|
||||
if strings.Contains(public.text(), `"Neuf"`) {
|
||||
t.Errorf("the offline article must not be public on %s", key)
|
||||
}
|
||||
}
|
||||
atelier := w.send(storyWriter, http.MethodGet, createSitePath+"?site=atelier", "")
|
||||
journal := w.send(storyWriter, http.MethodGet, createSitePath+"?site=journal", "")
|
||||
if !strings.Contains(atelier.text(), `"Neuf"`) || strings.Contains(journal.text(), `"Neuf"`) {
|
||||
t.Errorf("only atelier lists the new article: %d %s / %d %s", atelier.Code, atelier.text(), journal.Code, journal.text())
|
||||
}
|
||||
events := w.eventsOf(audit.ActionArticleCreate)
|
||||
if len(events) != 1 || events[0].Outcome != audit.OutcomeSuccess || events[0].Params[audit.ParamSite] != "atelier" || events[0].Target.ID != idText(created.ID) {
|
||||
t.Errorf("one success event with the site key expected: %+v", events)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -312,6 +312,9 @@ func TestArticleTranslationCreateHTTP(t *testing.T) {
|
||||
{`{"title":"Neuf","story_id":5}`, 400, apperr.BodyErrorMessage, ""},
|
||||
{`{"title":"Neuf","lang":"fr","story_id":999}`, 404, apperr.ArticleNotFoundErrorMessage, ""},
|
||||
{`{"title":"Neuf","copy":true}`, 400, apperr.BodyErrorMessage, ""},
|
||||
{`{"title":"Neuf","lang":"fr","site":"atelier"}`, 201, "", "fr"},
|
||||
{`{"title":"Neuf","site":"missing"}`, 404, apperr.SiteNotFoundErrorMessage, ""},
|
||||
{`{"title":"Neuf","lang":"fr","story_id":5,"site":"atelier"}`, 400, apperr.BodyErrorMessage, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.body, func(t *testing.T) {
|
||||
|
||||
@@ -62,6 +62,7 @@ type storyTransactor struct {
|
||||
world *storyWorld
|
||||
runs int
|
||||
external map[uint32]article.Article
|
||||
starting map[uint32]string
|
||||
}
|
||||
|
||||
var _ transaction.Transactor = &storyTransactor{}
|
||||
@@ -72,7 +73,11 @@ func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Reposit
|
||||
articles := maps.Clone(w.articles.articles)
|
||||
credits := maps.Clone(w.articles.credits)
|
||||
rows, _ := w.rows.snapshot()
|
||||
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: map[uint32]string{storyJournal: "en", storyAtelier: "en"}}})
|
||||
starting := map[uint32]string{storyJournal: "en", storyAtelier: "en"}
|
||||
if s.starting != nil {
|
||||
starting = s.starting
|
||||
}
|
||||
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: starting}})
|
||||
if err != nil {
|
||||
w.articles.articles = articles
|
||||
w.articles.credits = credits
|
||||
|
||||
Reference in new issue
Block a user