package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "encoding/json" "net/http" "reflect" "strconv" "strings" "testing" ) func capabilitiesOf(t *testing.T, response storyResponse) []string { t.Helper() var capabilities []string if err := json.Unmarshal(response.fields(t)["capabilities"], &capabilities); err != nil { t.Fatalf("capabilities: %v in %s", err, response.text()) } return capabilities } func translationsOf(t *testing.T, response storyResponse) []map[string]json.RawMessage { t.Helper() var translations []map[string]json.RawMessage if err := json.Unmarshal(response.fields(t)["translations"], &translations); err != nil { t.Fatalf("translations: %v in %s", err, response.text()) } return translations } func languagesIn(translations []map[string]json.RawMessage) []string { tags := []string{} for _, entry := range translations { tags = append(tags, strings.Trim(string(entry["lang"]), `"`)) } return tags } func disableOnJournal(w *storyWorld, tag string) { rows := w.rows.rows[storyJournal] for index := range rows { if rows[index].Tag == tag { rows[index].Enabled = false } } } func TestArticleStoryLifecycleHTTP(t *testing.T) { t.Run("Capabilities of the owner", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "") if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, []string{"read", "edit", "publish", "delete", "create_translation"}) { t.Errorf("capabilities %v", got) } }) t.Run("Capabilities per caller and version", func(t *testing.T) { cases := []struct { name string user uint32 id string online11 bool want []string }{ {"writer1 on its own draft", storyWriter1, "12", false, []string{"read", "edit", "publish", "delete", "create_translation"}}, {"writer1 on a readable sibling of another user", storyWriter1, "11", true, []string{"read", "create_translation"}}, {"reader", storyReader, "10", false, []string{"read"}}, {"update and read all", storyUpdater, "12", false, []string{"read", "edit"}}, {"publish and read all", storyPublish, "12", false, []string{"read", "publish"}}, {"anonymous", storyAnon, "10", false, []string{"read"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := newStoryWorld(t) if c.online11 { sibling := w.stored(11) sibling.IsOnline = true w.articles.articles[11] = sibling } response := w.send(c.user, http.MethodGet, "/v1/article/"+c.id, "") if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, c.want) { t.Errorf("capabilities %v, expected %v", got, c.want) } }) } }) t.Run("Translations list readable versions only", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyWriter1, http.MethodGet, "/v1/article/10", "") translations := translationsOf(t, response) if got := languagesIn(translations); !reflect.DeepEqual(got, []string{"en", "ar", "fr"}) { t.Errorf("translations %v", got) } for _, entry := range translations { for _, key := range []string{"id", "lang", "title", "is_online", "language_enabled", "capabilities"} { if _, present := entry[key]; !present { t.Errorf("entry misses %q: %v", key, entry) } } } if strings.Contains(response.text(), "Kosten") || strings.Contains(response.text(), `"de"`) { t.Errorf("the hidden version leaks: %s", response.text()) } }) t.Run("Translations are ordered default language first, then by tag", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "") if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) { t.Errorf("translations %v", got) } }) t.Run("Disabled languages are marked", func(t *testing.T) { w := newStoryWorld(t) disableOnJournal(w, "fr") response := w.send(storyOwner, http.MethodGet, "/v1/article/10", "") for _, entry := range translationsOf(t, response) { enabled := string(entry["language_enabled"]) if lang := string(entry["lang"]); (lang == `"fr"`) == (enabled == "true") { t.Errorf("fr must be flagged disabled and still listed: %v", entry) } } if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) { t.Errorf("fr must still be listed: %v", got) } }) t.Run("A single-version article lists itself", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8}) response := w.send(storyOwner, http.MethodGet, "/v1/article/20", "") translations := translationsOf(t, response) if len(translations) != 1 || string(translations[0]["id"]) != "20" { t.Errorf("translations %v", translations) } }) t.Run("Anonymous reads expose nothing about siblings", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyAnon, http.MethodGet, "/v1/article/10", "") fields := response.fields(t) if string(fields["translations"]) != "[]" || string(fields["capabilities"]) != `["read"]` || string(fields["source"]) != "null" { t.Errorf("unexpected anonymous fields: %s", response.text()) } expected := append([]string{"lang", "story_id", "revision", "capabilities", "translations", "source", "body_format"}, legacyArticleKeys...) for key := range fields { if !contains(expected, key) { t.Errorf("unexpected key %q in the public shape", key) } } }) t.Run("The source on the read", func(t *testing.T) { w := newStoryWorld(t) source := uint32(10) w.put(article.Article{ID: 14, Title: "Copy", CreatedBy: storyRestrict, Slug: "copy", Lang: "en", StoryID: 14, SourceArticleID: &source, SourceRevision: 4}) w.put(article.Article{ID: 15, Title: "Empty", CreatedBy: storyWriter1, Slug: "empty", Lang: "en", StoryID: 15}) response := w.send(storyOwner, http.MethodGet, "/v1/article/14", "") if string(response.fields(t)["source"]) != `{"article_id":10,"revision":4}` { t.Errorf("source %s", response.fields(t)["source"]) } if string(w.send(storyOwner, http.MethodGet, "/v1/article/15", "").fields(t)["source"]) != "null" { t.Errorf("an empty version has no source") } draft := w.stored(10) draft.IsOnline = false w.articles.articles[10] = draft if string(w.send(storyReader, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) == "null" { t.Errorf("the reader reads everything and sees the source") } if string(w.send(storyRestrict, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" { t.Errorf("an unreadable source is null") } delete(w.articles.articles, 10) if string(w.send(storyOwner, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" { t.Errorf("a deleted source is null") } }) t.Run("Publishing respects the language configuration", func(t *testing.T) { cases := []struct { name string id uint32 status int message string online bool }{ {"fr enabled", 12, 200, "", true}, {"es disabled, offline version", 15, 422, apperr.LanguageDisabledErrorMessage, false}, {"es disabled, already online", 16, 200, "", true}, {"unassigned language", 30, 200, "", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15}) w.put(article.Article{ID: 16, Title: "Online es", CreatedBy: 1, Slug: "es-online", Lang: "es", StoryID: 16, IsOnline: true}) w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30}) response := w.send(storyPublish, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(c.id))+"/publish", "") 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 w.stored(c.id).IsOnline != c.online { t.Errorf("is_online = %v, expected %v", w.stored(c.id).IsOnline, c.online) } }) } }) t.Run("Unpublish and edit stay available in a disabled language", func(t *testing.T) { w := newStoryWorld(t) fr := w.stored(12) fr.IsOnline = true w.articles.articles[12] = fr disableOnJournal(w, "fr") w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "") w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "") }) t.Run("Publishing one version leaves the others alone", func(t *testing.T) { w := newStoryWorld(t) before := w.snapshot() w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "") for _, id := range []uint32{10, 11, 13} { if !articlesEqual(before[id], w.stored(id)) { t.Errorf("article %d changed", id) } } }) t.Run("Re-enabling a language allows publishing again", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15}) w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 422, apperr.LanguageDisabledErrorMessage) w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar", "es"}, nil) w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 200, "") }) t.Run("Existing publish errors are unchanged", func(t *testing.T) { w := newStoryWorld(t) w.send(storyOwner, http.MethodPatch, "/v1/article/9999/publish", "").expect(t, 404, apperr.ArticleNotFoundErrorMessage) w.send(storyOwner, http.MethodPatch, "/v1/article/abc/publish", "").expect(t, 400, apperr.IdUint32ErrorMessage) w.send(storyAnon, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 401, "") w.send(storyReader, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 403, apperr.MissingPermissionErrorMessage) }) t.Run("Deleting one version keeps the story", func(t *testing.T) { w := newStoryWorld(t) w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "") for _, id := range []uint32{10, 11, 13} { if _, present := w.articles.articles[id]; !present { t.Errorf("article %d must remain", id) } } listing := w.send(storyOwner, http.MethodGet, "/v1/article/10", "") if got := languagesIn(translationsOf(t, listing)); !reflect.DeepEqual(got, []string{"en", "ar", "de"}) { t.Errorf("translations %v", got) } }) t.Run("A deleted language can be created again", func(t *testing.T) { w := newStoryWorld(t) w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "") response := w.send(storyOwner, http.MethodPost, "/v1/article/10/translations", `{"lang":"fr","title":"Couts","copy":false}`) response.expect(t, 201, "") }) t.Run("Deleting the last version removes the story", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8}) w.send(storyOwner, http.MethodDelete, "/v1/article/20", "").expect(t, 204, "") if versions, _ := w.articles.ListByStory(8); len(versions) != 0 { t.Errorf("story 8 must be gone") } w.send(storyOwner, http.MethodPost, "/v1/article/20/translations", `{"lang":"fr","title":"Couts","copy":false}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage) }) t.Run("Deleting the last of several versions", func(t *testing.T) { w := newStoryWorld(t) for position, id := range []string{"10", "11", "12", "13"} { w.send(storyOwner, http.MethodDelete, "/v1/article/"+id, "").expect(t, 204, "") versions, _ := w.articles.ListByStory(storyStory) if (position < 3) != (len(versions) > 0) { t.Errorf("after deleting %s, %d versions remain", id, len(versions)) } } }) t.Run("Delete is checked per version", func(t *testing.T) { w := newStoryWorld(t) w.send(storyWriter1, http.MethodDelete, "/v1/article/11", "").expect(t, 403, apperr.MissingPermissionErrorMessage) if _, present := w.articles.articles[11]; !present { t.Errorf("article 11 must remain") } }) t.Run("A story with a hidden version", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 80, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "en", StoryID: 80}) w.put(article.Article{ID: 81, Title: "Hidden", CreatedBy: 2, Slug: "hidden", Lang: "de", StoryID: 80}) response := w.send(storyWriter1, http.MethodDelete, "/v1/article/80", "") response.expect(t, 204, "") if versions, _ := w.articles.ListByStory(80); len(versions) != 1 || response.text() != "" { t.Errorf("the story must remain and the response mention nothing: %d %q", len(versions), response.text()) } }) moveBody := `{"site":"atelier"}` t.Run("Move a story", func(t *testing.T) { w := newStoryWorld(t) before := w.snapshot() response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 200, "") if response.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` { t.Errorf("unexpected body %s", response.text()) } for id, previous := range before { moved := w.stored(id) if moved.SiteID != 2 || moved.EditRevision != previous.EditRevision+1 || moved.ContentRevision != previous.ContentRevision { t.Errorf("article %d: %+v", id, moved) } previous.SiteID, previous.EditRevision = moved.SiteID, moved.EditRevision if !articlesEqual(previous, moved) { t.Errorf("article %d changed beyond its site: %+v", id, moved) } } }) t.Run("A move requires every language to be enabled at the destination", func(t *testing.T) { w := newStoryWorld(t) w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil) before := w.snapshot() response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage) if response.header(apperr.MissingLanguagesHeader) != "ar" { t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader)) } w.expectUnchanged(before) }) t.Run("A language disabled at the destination counts as missing", func(t *testing.T) { w := newStoryWorld(t) w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "ar"}, []string{"de"}) response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage) if !strings.Contains(response.header(apperr.MissingLanguagesHeader), "de") { t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader)) } }) t.Run("A language disabled at the source still needs the destination", func(t *testing.T) { w := newStoryWorld(t) disableOnJournal(w, "fr") w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "") w = newStoryWorld(t) disableOnJournal(w, "fr") w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "de", "ar"}, nil) response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage) if response.header(apperr.MissingLanguagesHeader) != "fr" { t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader)) } }) t.Run("Address conflict at the destination", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 11, Title: "Kosten", CreatedBy: storyPartial, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * 60e9)}) w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyPartial, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(60e9)}) hidden := w.stored(13) hidden.IsOnline = false w.articles.articles[13] = hidden w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 91}) w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier ar", CreatedBy: 1, Slug: "takalif", Lang: "ar", StoryID: 92}) before := w.snapshot() response := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 409, apperr.SlugConflictErrorMessage) if response.header(apperr.ConflictingArticlesHeader) != "12" { t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader)) } w.expectUnchanged(before) }) t.Run("Every conflict of the story is checked", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier en", CreatedBy: 1, Slug: "renovation-costs", Lang: "en", StoryID: 91}) w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 92}) before := w.snapshot() response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 409, apperr.SlugConflictErrorMessage) if response.header(apperr.ConflictingArticlesHeader) != "10,12" { t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader)) } w.expectUnchanged(before) }) t.Run("A move needs update permission on every version, readable or not", func(t *testing.T) { cases := []struct { name string user uint32 offline bool status int message string readable bool }{ {"an anonymous client", storyAnon, false, 401, "", true}, {"writer1", storyWriter1, false, 403, apperr.MissingPermissionErrorMessage, true}, {"a user without content.update", storyReader, false, 403, apperr.MissingPermissionErrorMessage, true}, {"a user who can read no version", storyPartial, true, 404, "", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := newStoryWorld(t) if c.offline { for _, id := range []uint32{10, 13} { draft := w.stored(id) draft.IsOnline = false w.articles.articles[id] = draft } } before := w.snapshot() response := w.send(c.user, http.MethodPost, "/v1/story/5/move", moveBody) if c.readable { if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") { t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text()) } } else { response.expect(t, 404, apperr.ArticleNotFoundErrorMessage) } w.expectUnchanged(before) }) } }) t.Run("Hidden versions never leak through the move", func(t *testing.T) { w := newStoryWorld(t) hidden := w.stored(13) hidden.IsOnline = false w.articles.articles[13] = hidden response := w.send(storyWriter1, http.MethodPost, "/v1/story/5/move", moveBody) response.expect(t, 403, apperr.MissingPermissionErrorMessage) if strings.Contains(response.text(), "11") || strings.Contains(response.text(), "13") || strings.Contains(response.Header().Get(apperr.MissingPermissionHeader), "1") { t.Errorf("the response names a hidden version") } w = newStoryWorld(t) hidden = w.stored(13) hidden.IsOnline = false w.articles.articles[13] = hidden for _, id := range []uint32{10, 11, 12} { readable := w.stored(id) readable.CreatedBy = storyPartial w.articles.articles[id] = readable } success := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody) success.expect(t, 200, "") if success.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11]}` { t.Errorf("article_ids must list readable versions only: %s", success.text()) } }) t.Run("Move validation", func(t *testing.T) { cases := []struct { name string path string body string status int message string }{ {"unknown site", "/v1/story/5/move", `{"site":"nowhere"}`, 404, apperr.SiteNotFoundErrorMessage}, {"empty body", "/v1/story/5/move", `{}`, 400, apperr.BodyErrorMessage}, {"malformed JSON", "/v1/story/5/move", `{"site":`, 400, apperr.BodyErrorMessage}, {"unknown story", "/v1/story/9999/move", moveBody, 404, apperr.ArticleNotFoundErrorMessage}, {"bad story id", "/v1/story/abc/move", moveBody, 400, apperr.IdUint32ErrorMessage}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := newStoryWorld(t) w.send(storyOwner, http.MethodPost, c.path, c.body).expect(t, c.status, c.message) }) } }) t.Run("Moving to the current site", func(t *testing.T) { w := newStoryWorld(t) before := w.snapshot() response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"journal"}`) response.expect(t, 200, "") w.expectUnchanged(before) if len(w.recorder.events) != 0 { t.Errorf("no activity event expected, got %d", len(w.recorder.events)) } }) t.Run("The move is atomic and revalidated inside the transaction", func(t *testing.T) { w := newStoryWorld(t) w.articles.beforeMove = func() { w.put(article.Article{ID: 95, SiteID: storyAtelier, Title: "Late", CreatedBy: 1, Slug: "kosten", Lang: "de", StoryID: 95}) } before := w.snapshot() delete(before, 95) late := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) late.expect(t, 409, apperr.SlugConflictErrorMessage) delete(w.articles.articles, 95) w.expectUnchanged(before) w.articles.moveFailsAt = 3 failed := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody) if failed.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", failed.Code) } w.expectUnchanged(before) }) t.Run("PATCH site_id cannot split a story", func(t *testing.T) { w := newStoryWorld(t) before := w.snapshot() w.send(storyOwner, http.MethodPatch, "/v1/article/10", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage) w.expectUnchanged(before) }) t.Run("PATCH site_id counts readable versions only", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 60, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 60}) w.put(article.Article{ID: 61, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden", Lang: "de", StoryID: 60}) w.put(article.Article{ID: 62, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "fr", StoryID: 62}) w.put(article.Article{ID: 63, Title: "Not mine", CreatedBy: storyOwner, Slug: "not-mine", Lang: "de", StoryID: 62}) w.put(article.Article{ID: 64, Title: "First", CreatedBy: storyPartial, Slug: "first", Lang: "fr", StoryID: 64}) w.put(article.Article{ID: 65, Title: "Second", CreatedBy: storyPartial, Slug: "second", Lang: "de", StoryID: 64}) w.put(article.Article{ID: 66, Title: "Third hidden", CreatedBy: storyOwner, Slug: "third-hidden", Lang: "en", StoryID: 64}) denied := w.send(storyWriter1, http.MethodPatch, "/v1/article/62", `{"site_id":2}`) denied.expect(t, 403, apperr.MissingPermissionErrorMessage) if denied.header("X-Missing-Permission") != "content.update" || strings.Contains(denied.text(), "hidden") { t.Errorf("the refusal must only name the permission: %v %q", denied.Header(), denied.text()) } if w.stored(62).SiteID != storyJournal || w.stored(63).SiteID != storyJournal { t.Errorf("a refused move must change nothing") } w.send(storyPartial, http.MethodPatch, "/v1/article/64", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage) if w.stored(64).SiteID != storyJournal { t.Errorf("a refused move must change nothing") } w.send(storyPartial, http.MethodPatch, "/v1/article/60", `{"site_id":2}`).expect(t, 200, "") if w.stored(60).SiteID != 2 || w.stored(61).SiteID != 2 { t.Errorf("every version of the story moves together: %d %d", w.stored(60).SiteID, w.stored(61).SiteID) } }) t.Run("PATCH site_id with a hidden version applies the story move rules", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70}) w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70}) w.put(article.Article{ID: 72, SiteID: storyAtelier, Title: "Taken", CreatedBy: 1, Slug: "hidden-de", Lang: "de", StoryID: 72}) before := w.snapshot() response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2}`) response.expect(t, 409, apperr.SlugConflictErrorMessage) if strings.Contains(response.text(), "hidden") || response.header(apperr.ConflictingArticlesHeader) != "" { t.Errorf("the refusal must not name hidden versions: %v %q", response.Header(), response.text()) } w.expectUnchanged(before) }) t.Run("PATCH site_id with a hidden version raises revision by exactly 1", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3, ContentRevision: 4}) w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5, ContentRevision: 6}) w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":3}`).expect(t, 200, "") if edit, content := revisionsOf(w, 70); w.stored(70).SiteID != 2 || edit != 4 || content != 4 { t.Errorf("site %d revision %d content_revision %d, expected 2, 4 and 4", w.stored(70).SiteID, edit, content) } if edit, content := revisionsOf(w, 71); w.stored(71).SiteID != 2 || edit != 6 || content != 6 { t.Errorf("hidden version: site %d revision %d content_revision %d, expected 2, 6 and 6", w.stored(71).SiteID, edit, content) } }) t.Run("PATCH site_id with a stale if_revision moves nobody", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3}) w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5}) before := w.snapshot() response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":2}`) if response.Code != 409 { t.Errorf("expected 409, got %d %s", response.Code, response.text()) } w.expectUnchanged(before) }) t.Run("PATCH site_id on a single-version story applies the move rules", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "fr", StoryID: 8}) w.put(article.Article{ID: 21, Title: "Alone es", CreatedBy: storyWriter1, Slug: "alone-es", Lang: "es", StoryID: 21}) w.put(article.Article{ID: 22, Title: "Alone unassigned", CreatedBy: storyWriter1, Slug: "alone-unassigned", Lang: "", StoryID: 22}) w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`).expect(t, 200, "") w.send(storyOwner, http.MethodPatch, "/v1/article/21", `{"site_id":2}`).expect(t, 422, apperr.LanguageNotEnabledErrorMessage) w.send(storyOwner, http.MethodPatch, "/v1/article/22", `{"site_id":2}`).expect(t, 200, "") if w.stored(20).SiteID != 2 || w.stored(21).SiteID != 1 || w.stored(22).SiteID != 2 { t.Errorf("unexpected sites %d %d %d", w.stored(20).SiteID, w.stored(21).SiteID, w.stored(22).SiteID) } }) t.Run("Activity events carry the language", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30}) w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "") w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "") w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "") w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "") w.send(storyOwner, http.MethodPatch, "/v1/article/30/publish", "").expect(t, 200, "") for _, action := range []string{audit.ActionArticlePublish, audit.ActionArticleUnpublish, audit.ActionArticleEdit, audit.ActionArticleDelete} { events := w.eventsOf(action) if len(events) == 0 || events[0].Params[audit.ParamLang] != "fr" { t.Errorf("%s must carry lang fr: %+v", action, events) } } if publishes := w.eventsOf(audit.ActionArticlePublish); len(publishes) != 2 { t.Fatalf("expected two publish events, got %d", len(publishes)) } else if _, present := publishes[1].Params[audit.ParamLang]; present { t.Errorf("an unassigned article carries no lang") } w = newStoryWorld(t) w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "") moves := w.eventsOf(audit.ActionArticleEdit) if len(moves) != 4 { t.Fatalf("one edit event per moved version expected, got %d", len(moves)) } for _, event := range moves { if event.Params[audit.ParamLang] == nil || len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldSite || *event.Changes[0].After != "Atelier" { t.Errorf("unexpected move event %+v", event) } } w = newStoryWorld(t) w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil) w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 422, apperr.LanguageNotEnabledErrorMessage) refusals := w.eventsOf(audit.ActionArticleEdit) if len(refusals) != 1 || refusals[0].Outcome != audit.OutcomeFailed || refusals[0].FailureReason == nil { t.Fatalf("one failed event expected: %+v", refusals) } }) } func contains(values []string, wanted string) bool { for _, value := range values { if value == wanted { return true } } return false }