392 lines
17 KiB
Go
392 lines
17 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/domain/author"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const translationsPath = "/v1/article/10/translations"
|
|
|
|
func createdTranslation(t *testing.T, w *storyWorld, response storyResponse) article.Article {
|
|
t.Helper()
|
|
if response.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d %q", response.Code, response.text())
|
|
}
|
|
return w.stored(response.article(t).ID)
|
|
}
|
|
|
|
func TestArticleTranslationCreateHTTP(t *testing.T) {
|
|
t.Run("Copy from the source version", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":true}`)
|
|
|
|
created := createdTranslation(t, w, response)
|
|
source := before[10]
|
|
if created.ID == 0 || created.Lang != "fr" || created.StoryID != storyStory || created.SiteID != storyJournal || created.IsOnline || created.Title != "Couts de renovation" {
|
|
t.Errorf("unexpected identity: %+v", created)
|
|
}
|
|
if created.Body != source.Body || created.Excerpt != source.Excerpt || created.SEOTitle != source.SEOTitle || created.SEODescription != source.SEODescription ||
|
|
created.SocialTitle != source.SocialTitle || created.SocialDescription != source.SocialDescription || created.CoverImageAlt != source.CoverImageAlt ||
|
|
*created.CoverImageID != *source.CoverImageID || *created.SocialImageID != *source.SocialImageID {
|
|
t.Errorf("the content must equal article 10: %+v", created)
|
|
}
|
|
if got := w.articles.credits[created.ID]; !reflect.DeepEqual(got, []uint32{1}) {
|
|
t.Errorf("authors must equal article 10's, got %v", got)
|
|
}
|
|
if created.Slug != "couts-de-renovation" || created.CanonicalURL != "" || created.Noindex || len(created.SEODismissed) != 0 {
|
|
t.Errorf("slug generated, canonical empty, noindex false, dismissed empty expected: %+v", created)
|
|
}
|
|
if created.CreatedBy != storyWriter || created.EditRevision != 1 {
|
|
t.Errorf("created_by must be the caller and revision 1: %+v", created)
|
|
}
|
|
if created.SourceArticleID == nil || *created.SourceArticleID != 10 || created.SourceRevision != 4 {
|
|
t.Errorf("the source must be article 10 at revision 4: %+v", created)
|
|
}
|
|
w.expectUnchanged(before, created.ID)
|
|
fields := response.fields(t)
|
|
if string(fields["revision"]) != "1" || string(fields["source"]) != `{"article_id":10,"revision":4}` {
|
|
t.Errorf("revision or source missing from the body: %s", response.text())
|
|
}
|
|
})
|
|
|
|
t.Run("Copy from a chosen source", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Kosten","copy":true,"source_article_id":11}`)
|
|
|
|
created := createdTranslation(t, w, response)
|
|
if created.Body != "<p>Kosten</p>" || created.SourceArticleID == nil || *created.SourceArticleID != 11 || created.SourceRevision != 1 {
|
|
t.Errorf("the content must be copied from article 11: %+v", created)
|
|
}
|
|
})
|
|
|
|
t.Run("Start empty", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
w.authors.authors[2] = withUser(w.authors.authors[2], storyWriter)
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":false}`)
|
|
|
|
created := createdTranslation(t, w, response)
|
|
if created.Body != "" || created.Excerpt != "" || created.SEOTitle != "" || created.SocialTitle != "" || created.CoverImageID != nil || created.SocialImageID != nil {
|
|
t.Errorf("content must be empty: %+v", created)
|
|
}
|
|
if got := w.articles.credits[created.ID]; !reflect.DeepEqual(got, []uint32{2}) {
|
|
t.Errorf("authors must be the creating user's byline, got %v", got)
|
|
}
|
|
if created.SourceArticleID != nil || string(response.fields(t)["source"]) != "null" {
|
|
t.Errorf("there must be no source: %+v %s", created, response.text())
|
|
}
|
|
})
|
|
|
|
t.Run("Request validation", func(t *testing.T) {
|
|
long := strings.Repeat("a", 201)
|
|
cases := []struct {
|
|
body string
|
|
status int
|
|
message string
|
|
}{
|
|
{`{"title":"Couts","copy":true}`, 400, ""},
|
|
{`{"lang":"fr","copy":true}`, 400, ""},
|
|
{`{"lang":"fr","title":"ab","copy":true}`, 400, ""},
|
|
{`{"lang":"fr","title":"` + long + `","copy":true}`, 400, ""},
|
|
{`{"lang":"fr","title":"Couts"}`, 400, apperr.BodyErrorMessage},
|
|
{`{"lang":"fr","title":"Couts","copy":false,"source_article_id":10}`, 400, apperr.BodyErrorMessage},
|
|
{`{"lang":"fr",`, 400, apperr.BodyErrorMessage},
|
|
{`{"lang":"english","title":"Couts","copy":true}`, 422, apperr.InvalidLanguageErrorMessage},
|
|
{`{"lang":"xx","title":"Couts","copy":true}`, 422, apperr.UnsupportedLanguageErrorMessage},
|
|
{`{"lang":"ja-JP","title":"Couts","copy":true}`, 422, apperr.UnsupportedLanguageErrorMessage},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.body, func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, c.body)
|
|
|
|
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") || response.text() == "\n" {
|
|
t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The language tag is normalized", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"FR","title":"Couts","copy":true}`)
|
|
|
|
if created := createdTranslation(t, w, response); created.Lang != "fr" {
|
|
t.Errorf("stored lang %q", created.Lang)
|
|
}
|
|
})
|
|
|
|
t.Run("Language not configured on the site", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"pt","title":"Custos","copy":true}`)
|
|
|
|
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("One neutral answer when the language cannot be used", func(t *testing.T) {
|
|
unreadable := func(t *testing.T) storyResponse {
|
|
w := newTranslationWorld(t)
|
|
w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory})
|
|
return w.send(storyRestrict, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
|
}
|
|
disabled := func(t *testing.T) storyResponse {
|
|
w := newTranslationWorld(t)
|
|
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "de", "ar"}, []string{"fr"})
|
|
return w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
|
}
|
|
raced := func(t *testing.T) storyResponse {
|
|
w := newTranslationWorld(t)
|
|
w.articles.fail.failNextWith("Create", article.ErrLanguageTaken)
|
|
return w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
|
}
|
|
situations := map[string]func(*testing.T) storyResponse{"an fr version unreadable to the caller": unreadable, "fr disabled": disabled, "a concurrent request created fr first": raced}
|
|
var reference storyResponse
|
|
for name, run := range situations {
|
|
t.Run(name, func(t *testing.T) {
|
|
response := run(t)
|
|
|
|
response.expect(t, 409, apperr.LanguageUnavailableErrorMessage)
|
|
if response.header(apperr.ExistingArticleHeader) != "" {
|
|
t.Errorf("X-Existing-Article-Id must be absent")
|
|
}
|
|
if reference.ResponseRecorder == nil {
|
|
reference = response
|
|
}
|
|
if response.text() != reference.text() || !reflect.DeepEqual(response.Header(), reference.Header()) {
|
|
t.Errorf("body and headers must be identical across situations: %v vs %v", response.Header(), reference.Header())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The language already exists and is readable", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
before := w.snapshot()
|
|
|
|
german := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"de","title":"Kosten","copy":true}`)
|
|
english := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"en","title":"Costs","copy":true}`)
|
|
|
|
german.expect(t, 409, apperr.LanguageExistsErrorMessage)
|
|
english.expect(t, 409, apperr.LanguageExistsErrorMessage)
|
|
if german.header(apperr.ExistingArticleHeader) != "11" || english.header(apperr.ExistingArticleHeader) != "10" {
|
|
t.Errorf("unexpected existing ids %q %q", german.header(apperr.ExistingArticleHeader), english.header(apperr.ExistingArticleHeader))
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("Two concurrent requests for the same language", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
w.articles.beforeCreate = func() {
|
|
w.putCommitted(article.Article{ID: 90, Title: "Winner", CreatedBy: storyWriter, Slug: "winner", Lang: "fr", StoryID: storyStory})
|
|
}
|
|
|
|
loser := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
|
|
|
loser.expect(t, 409, apperr.LanguageUnavailableErrorMessage)
|
|
count := 0
|
|
for _, stored := range w.articles.articles {
|
|
if stored.StoryID == storyStory && stored.Lang == "fr" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("story 5 must hold exactly one fr version, got %d", count)
|
|
}
|
|
})
|
|
|
|
t.Run("The path version and the source must be readable", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
w.put(article.Article{ID: 20, Title: "Other story", CreatedBy: storyWriter1, Slug: "other", Lang: "en", StoryID: 8})
|
|
cases := []struct {
|
|
name string
|
|
user uint32
|
|
path string
|
|
body string
|
|
status int
|
|
message string
|
|
}{
|
|
{"anonymous", storyAnon, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`, 401, ""},
|
|
{"reader without content.create", storyReader, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`, 403, apperr.MissingPermissionErrorMessage},
|
|
{"missing article", storyWriter, "/v1/article/9999/translations", `{"lang":"fr","title":"Couts","copy":true}`, 404, apperr.ArticleNotFoundErrorMessage},
|
|
{"unreadable path version", storyRestrict, "/v1/article/11/translations", `{"lang":"fr","title":"Couts","copy":true}`, 404, apperr.ArticleNotFoundErrorMessage},
|
|
{"bad id", storyWriter, "/v1/article/abc/translations", `{"lang":"fr","title":"Couts","copy":true}`, 400, apperr.IdUint32ErrorMessage},
|
|
{"missing source", storyWriter, translationsPath, `{"lang":"pt","title":"Couts","copy":true,"source_article_id":9999}`, 404, apperr.SourceUnavailableErrorMessage},
|
|
{"unreadable source", storyRestrict, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`, 404, apperr.SourceUnavailableErrorMessage},
|
|
{"source of another story", storyWriter, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":20}`, 422, apperr.SourceOtherStoryErrorMessage},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
before := w.snapshot()
|
|
|
|
response := w.send(c.user, http.MethodPost, c.path, c.body)
|
|
|
|
response.expect(t, c.status, c.message)
|
|
w.expectUnchanged(before)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Access to the source is rechecked at submit", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
delete(w.articles.articles, 11)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`)
|
|
|
|
response.expect(t, 404, apperr.SourceUnavailableErrorMessage)
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("Unassigned articles cannot be translated yet", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: storyWriter1, Slug: "unassigned", Lang: "", StoryID: 9, IsOnline: true})
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyWriter, http.MethodPost, "/v1/article/30/translations", `{"lang":"fr","title":"Couts","copy":true}`)
|
|
|
|
response.expect(t, 409, apperr.SetupRequiredErrorMessage)
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("The slug of the new version is free within site and language", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
w.put(article.Article{ID: 40, Title: "Taken", CreatedBy: storyWriter1, Slug: "couts-de-renovation", Lang: "fr", StoryID: 77})
|
|
|
|
clash := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":true}`))
|
|
plain := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, "/v1/article/10/translations", `{"lang":"ar","title":"Kosten Renovierung","copy":false}`))
|
|
|
|
if clash.Slug != "couts-de-renovation-2" || plain.Slug != "kosten-renovierung" {
|
|
t.Errorf("unexpected slugs %q and %q", clash.Slug, plain.Slug)
|
|
}
|
|
})
|
|
|
|
t.Run("Entry point 2 behaves exactly like entry point 1", func(t *testing.T) {
|
|
first, second := newTranslationWorld(t), newTranslationWorld(t)
|
|
|
|
viaPath := first.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":10}`)
|
|
viaCollection := second.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true}`)
|
|
|
|
if viaPath.Code != 201 || viaCollection.Code != 201 || viaPath.text() != viaCollection.text() {
|
|
t.Fatalf("responses differ: %d %s / %d %s", viaPath.Code, viaPath.text(), viaCollection.Code, viaCollection.text())
|
|
}
|
|
if !reflect.DeepEqual(first.articles.articles, second.articles.articles) {
|
|
t.Errorf("stored data differ")
|
|
}
|
|
for _, body := range []string{`{"title":"Couts","lang":"de","story_id":5,"copy":true}`, `{"title":"Couts","lang":"xx","story_id":5,"copy":true}`} {
|
|
a := newTranslationWorld(t).send(storyWriter, http.MethodPost, "/v1/article/", body)
|
|
b := newTranslationWorld(t).send(storyWriter, http.MethodPost, translationsPath, strings.Replace(body, `"story_id":5,`, "", 1))
|
|
if a.Code != b.Code || a.text() != b.text() || !reflect.DeepEqual(a.Header(), b.Header()) {
|
|
t.Errorf("errors differ for %s: %d %q / %d %q", body, a.Code, a.text(), b.Code, b.text())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("POST /v1/article/ is unchanged when it is not a translation", func(t *testing.T) {
|
|
cases := []struct {
|
|
body string
|
|
status int
|
|
message string
|
|
lang string
|
|
}{
|
|
{`{"title":"Plain","body":"x"}`, 201, "", "en"},
|
|
{`{"title":"Neuf","lang":"fr"}`, 201, "", "fr"},
|
|
{`{"title":"Neuf","lang":"pt"}`, 422, apperr.LanguageNotEnabledErrorMessage, ""},
|
|
{`{"title":"Neuf","lang":"es"}`, 409, apperr.LanguageUnavailableErrorMessage, ""},
|
|
{`{"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, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.body, func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyWriter, http.MethodPost, "/v1/article/", c.body)
|
|
|
|
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
|
|
t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
|
}
|
|
if c.status != http.StatusCreated {
|
|
w.expectUnchanged(before)
|
|
return
|
|
}
|
|
created := w.stored(response.article(t).ID)
|
|
if created.Lang != c.lang || created.StoryID == storyStory {
|
|
t.Errorf("a new story in %q expected, got %+v", c.lang, created)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("PATCH still ignores lang, story_id and source fields", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
|
|
response := w.send(storyWriter, http.MethodPatch, "/v1/article/10", `{"body":"x","lang":"fr","story_id":9,"source_article_id":11,"copy":true}`)
|
|
|
|
response.expect(t, 200, "")
|
|
if stored := w.stored(10); stored.Lang != "en" || stored.StoryID != storyStory || stored.SourceArticleID != nil {
|
|
t.Errorf("lang, story and source must be unchanged: %+v", stored)
|
|
}
|
|
})
|
|
|
|
t.Run("Activity records the creation", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
|
|
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`))
|
|
w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"de","title":"Kosten","copy":true}`)
|
|
w.send(storyRestrict, http.MethodPost, translationsPath, `{"lang":"fr","title":"Autre","copy":true}`)
|
|
|
|
events := w.eventsOf(audit.ActionArticleCreate)
|
|
if len(events) != 3 {
|
|
t.Fatalf("expected one success and two failures, got %d", len(events))
|
|
}
|
|
success := events[0]
|
|
if success.Outcome != audit.OutcomeSuccess || success.Target.ID != idText(created.ID) || success.Params[audit.ParamLang] != "fr" || success.Params[audit.ParamStoryID] != storyStory || success.Params[audit.ParamSourceArticleID] != uint32(10) {
|
|
t.Errorf("unexpected success event %+v for article %d", success, created.ID)
|
|
}
|
|
for _, failure := range events[1:] {
|
|
if failure.Outcome != audit.OutcomeFailed || failure.FailureReason == nil || *failure.FailureReason == "" {
|
|
t.Errorf("a failed event with a plain reason expected: %+v", failure)
|
|
}
|
|
if strings.Contains(*failure.FailureReason, "11") || strings.Contains(*failure.FailureReason, "Kosten") {
|
|
t.Errorf("the reason must not name a version: %q", *failure.FailureReason)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("A new version does not appear publicly", func(t *testing.T) {
|
|
w := newTranslationWorld(t)
|
|
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`))
|
|
|
|
response := w.send(storyAnon, http.MethodGet, "/v1/article/"+idText(created.ID), "")
|
|
|
|
response.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
|
})
|
|
}
|
|
|
|
func idText(id uint32) string {
|
|
return strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
|
|
func withUser(found author.Author, userID uint32) author.Author {
|
|
found.UserID = &userID
|
|
return found
|
|
}
|