feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles

This commit is contained in:
Florian Sylvain committed 2026-11-03 00:12:29 +01:00
1 parent 339e646eb8
commit 45fbbc33b1
93 files changed
+7630 -294

No files matched your search

+52
View File
@@ -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
}
+216
View File
@@ -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) {
+6 -1
View File
@@ -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