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 != "
Kosten
" || 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 }