Files
RenewCMS/test/article_story_lifecycle_http_test.go
T

721 lines
29 KiB
Go

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
}