Files
RenewCMS/test/article_translation_create_http_test.go
T

395 lines
18 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, ""},
{`{"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) {
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
}