package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "encoding/json" "net/http" "strconv" "strings" "testing" ) func revisionsOf(w *storyWorld, id uint32) (uint32, uint32) { stored := w.stored(id) return stored.EditRevision, stored.ContentRevision } func TestArticleEditRevisionHTTP(t *testing.T) { t.Run("Title boundaries on PATCH, counted in runes", func(t *testing.T) { cases := []struct { name string title string status int message string stored bool }{ {"2 ASCII characters", `"ab"`, 400, apperr.ArticleTitleErrorMessage, false}, {"3 ASCII characters", `"abc"`, 200, "", true}, {"200 ASCII characters", `"` + strings.Repeat("a", 200) + `"`, 200, "", true}, {"201 ASCII characters", `"` + strings.Repeat("a", 201) + `"`, 400, apperr.ArticleTitleErrorMessage, false}, {"200 four-byte runes", `"` + strings.Repeat("😀", 200) + `"`, 200, "", true}, {"empty", `""`, 400, apperr.ArticleTitleErrorMessage, false}, {"null", `null`, 400, apperr.BodyErrorMessage, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":`+c.title+`}`) 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 changed := w.stored(10).Title != "Renovation costs"; changed != c.stored { t.Errorf("stored title changed = %v, expected %v", changed, c.stored) } }) } }) t.Run("A domain title rejection on update is a 400, not a 500", func(t *testing.T) { w := newStoryWorld(t) w.articles.fail.failNextWith("Update", article.ErrInvalidTitle) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Valid title"}`).expect(t, 400, apperr.ArticleTitleErrorMessage) }) t.Run("Editing the title never changes the slug", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title"}`) response.expect(t, 200, "") if stored := w.stored(10); stored.Slug != "renovation-costs" || !stored.IsOnline { t.Errorf("slug and publication must be unchanged: %+v", stored) } }) t.Run("Title and body in one request", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title","body":"

x

"}`) response.expect(t, 200, "") if stored := w.stored(10); stored.Title != "New title" || stored.Body != "

x

" || stored.EditRevision != 4 { t.Errorf("both must be stored with revision 4: %+v", stored) } }) t.Run("The title is editable on a single-language site", func(t *testing.T) { w := newStoryWorld(t) w.sites.seed(3, "Archive", "archive", false) w.rows.rows[3] = storyRows("en", []string{"en"}, nil) w.put(article.Article{ID: 60, Title: "Old archive", SiteID: 3, CreatedBy: storyWriter1, Slug: "old-archive", Lang: "en", StoryID: 60}) response := w.send(storyUpdater, http.MethodPatch, "/v1/article/60", `{"title":"New title"}`) response.expect(t, 200, "") if w.stored(60).Slug != "old-archive" { t.Errorf("the slug must be unchanged") } }) t.Run("Explicit slug edits", func(t *testing.T) { cases := []struct { name string id string body string status int message string slug string header string }{ {"online article", "10", `{"slug":"new-address"}`, 200, "", "new-address", ""}, {"offline article", "12", `{"slug":"new-address"}`, 200, "", "new-address", ""}, {"same as current", "10", `{"slug":"renovation-costs"}`, 200, "", "renovation-costs", ""}, {"not a slug", "10", `{"slug":"Not A Slug"}`, 400, apperr.InvalidSlugErrorMessage, "renovation-costs", ""}, {"used by another language of the same story", "10", `{"slug":"couts"}`, 200, "", "couts", ""}, {"used by another en article", "10", `{"slug":"taken-en"}`, 409, apperr.ArticleSlugTakenErrorMessage, "renovation-costs", "taken-en-2"}, {"empty regenerates from the title", "10", `{"slug":""}`, 200, "", "renovation-costs", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 70, Title: "Taken", SiteID: storyJournal, CreatedBy: 1, Slug: "taken-en", Lang: "en", StoryID: 70}) id, _ := strconv.ParseUint(c.id, 10, 32) editBefore, _ := revisionsOf(w, uint32(id)) response := w.send(storyUpdater, http.MethodPatch, "/v1/article/"+c.id, c.body) response.expect(t, c.status, c.message) if got := w.stored(uint32(id)).Slug; got != c.slug { t.Errorf("slug %q, expected %q", got, c.slug) } if response.header(apperr.SlugSuggestionHeader) != c.header { t.Errorf("X-Slug-Suggestion %q, expected %q", response.header(apperr.SlugSuggestionHeader), c.header) } if editAfter, _ := revisionsOf(w, uint32(id)); (c.name == "same as current" || c.name == "empty regenerates from the title") && editAfter != editBefore { t.Errorf("a no-op slug must not change the revision") } if w.stored(uint32(id)).IsOnline != (c.id == "10") { t.Errorf("publication must be unchanged") } }) } }) t.Run("The same slug in two languages is not a conflict", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"slug":"couts"}`).expect(t, 200, "") if w.stored(10).Slug != "couts" || w.stored(12).Slug != "couts" { t.Errorf("both articles must hold the slug couts") } }) t.Run("Revision is exposed on every article read and write", func(t *testing.T) { w := newStoryWorld(t) requests := []struct { method string path string body string status int }{ {http.MethodGet, "/v1/article/10", "", 200}, {http.MethodGet, "/v1/article/", "", 200}, {http.MethodPost, "/v1/article/", `{"title":"Plain draft"}`, 201}, {http.MethodPatch, "/v1/article/10", `{"body":"x"}`, 200}, {http.MethodPatch, "/v1/article/12/publish", "", 200}, {http.MethodPatch, "/v1/article/12/unpublish", "", 200}, } for _, request := range requests { response := w.send(storyOwner, request.method, request.path, request.body) if response.Code != request.status { t.Fatalf("%s %s: status %d %s", request.method, request.path, response.Code, response.text()) } if strings.HasSuffix(request.path, "/") && request.method == http.MethodGet { for _, item := range decodeArticleItems(t, response.Body.Bytes()) { assertPositiveRevision(t, item) } continue } fields := response.fields(t) assertPositiveRevision(t, fields) for _, key := range legacyArticleKeys { if _, present := fields[key]; !present { t.Errorf("%s %s: legacy key %q missing", request.method, request.path, key) } } } }) fieldCases := []struct { field string body string content bool setup func(*storyWorld) }{ {"title", `{"title":"Changed title"}`, true, nil}, {"body", `{"body":"

changed

"}`, true, nil}, {"excerpt", `{"excerpt":"changed"}`, true, nil}, {"slug", `{"slug":"new-address"}`, true, nil}, {"cover_image_alt", `{"cover_image_alt":"changed"}`, true, nil}, {"seo_title", `{"seo_title":"changed"}`, true, nil}, {"seo_description", `{"seo_description":"changed"}`, true, nil}, {"social_title", `{"social_title":"changed"}`, true, nil}, {"social_description", `{"social_description":"changed"}`, true, nil}, {"author_ids", `{"author_ids":[2]}`, false, nil}, {"cover_image_id", `{"cover_image_id":7}`, false, nil}, {"social_image_id", `{"social_image_id":8}`, false, nil}, {"canonical_url", `{"canonical_url":"https://journal.example/other"}`, false, nil}, {"noindex", `{"noindex":false}`, false, nil}, {"seo_dismissed", `{"seo_dismissed":["cover_alt"]}`, false, nil}, } t.Run("What raises revision and content_revision", func(t *testing.T) { for _, c := range fieldCases { t.Run(c.field, func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", c.body).expect(t, 200, "") edit, content := revisionsOf(w, 10) wantContent := uint32(4) if c.content { wantContent = 5 } if edit != 4 || content != wantContent { t.Errorf("revision %d and content_revision %d, expected 4 and %d", edit, content, wantContent) } }) } t.Run("site_id of a single-version story", func(t *testing.T) { w := newStoryWorld(t) w.put(article.Article{ID: 60, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 60, EditRevision: 3, ContentRevision: 4}) w.send(storyUpdater, http.MethodPatch, "/v1/article/60", `{"site_id":2}`).expect(t, 200, "") if edit, content := revisionsOf(w, 60); edit != 4 || content != 4 { t.Errorf("revision %d and content_revision %d, expected 4 and 4", edit, content) } }) }) t.Run("Several content fields in one PATCH raise each counter once", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Changed title","body":"

b

","excerpt":"e"}`).expect(t, 200, "") if edit, content := revisionsOf(w, 10); edit != 4 || content != 5 { t.Errorf("revision %d and content_revision %d, expected 4 and 5", edit, content) } }) t.Run("A PATCH that changes nothing leaves both counters alone", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Renovation costs","body":"

Costs

","noindex":true}`).expect(t, 200, "") if edit, content := revisionsOf(w, 10); edit != 3 || content != 4 { t.Errorf("revision %d and content_revision %d, expected 3 and 4", edit, content) } }) t.Run("Publishing and unpublishing do not touch revisions", func(t *testing.T) { w := newStoryWorld(t) editBefore, contentBefore := revisionsOf(w, 12) w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "") w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "") if edit, content := revisionsOf(w, 12); edit != editBefore || content != contentBefore { t.Errorf("revisions changed to %d and %d", edit, content) } }) t.Run("if_revision matches", func(t *testing.T) { w := newStoryWorld(t) response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":3}`) response.expect(t, 200, "") if string(response.fields(t)["revision"]) != "4" { t.Errorf("the response revision must be 4: %s", response.text()) } }) t.Run("if_revision is stale", func(t *testing.T) { w := newStoryWorld(t) stored := w.stored(10) stored.EditRevision = 4 w.articles.articles[10] = stored before := w.snapshot() response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":3}`) response.expect(t, 409, apperr.RevisionConflictErrorMessage) if response.header(apperr.CurrentRevisionHeader) != "4" { t.Errorf("X-Current-Revision %q", response.header(apperr.CurrentRevisionHeader)) } w.expectUnchanged(before) for _, event := range w.eventsOf(audit.ActionArticleEdit) { if event.Outcome == audit.OutcomeSuccess { t.Errorf("no successful edit event expected: %+v", event) } } }) t.Run("A stale if_revision wins over a no-op PATCH", func(t *testing.T) { w := newStoryWorld(t) stored := w.stored(10) stored.EditRevision = 4 w.articles.articles[10] = stored response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"Renovation costs","if_revision":3}`) response.expect(t, 409, apperr.RevisionConflictErrorMessage) }) t.Run("Overwrite uses the revision the conflict dialog showed", func(t *testing.T) { w := newStoryWorld(t) stored := w.stored(10) stored.EditRevision = 4 w.articles.articles[10] = stored overwrite := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"mine","if_revision":4}`) overwrite.expect(t, 200, "") if string(overwrite.fields(t)["revision"]) != "5" { t.Errorf("revision must be 5: %s", overwrite.text()) } w.articles.beforeUpdate = func() { saved := w.stored(10) saved.EditRevision = 6 saved.Body = "theirs" w.articles.articles[10] = saved } raced := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"again","if_revision":5}`) raced.expect(t, 409, apperr.RevisionConflictErrorMessage) if raced.header(apperr.CurrentRevisionHeader) != "6" || w.stored(10).Body != "theirs" { t.Errorf("the then-current revision 6 and their body expected: %q %q", raced.header(apperr.CurrentRevisionHeader), w.stored(10).Body) } }) t.Run("Without if_revision the last write wins", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x"}`).expect(t, 200, "") if edit, _ := revisionsOf(w, 10); edit != 4 { t.Errorf("revision still rises by 1, got %d", edit) } }) t.Run("Invalid if_revision", func(t *testing.T) { for _, value := range []string{`0`, `-1`, `"3"`, `1.5`, `null`} { t.Run(value, func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":`+value+`}`).expect(t, 400, apperr.BodyErrorMessage) }) } }) t.Run("if_revision alone is not an update", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"if_revision":3}`).expect(t, 400, apperr.BodyErrorMessage) }) t.Run("Other languages are untouched by an overwrite", func(t *testing.T) { w := newStoryWorld(t) before := w.stored(12) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":3}`).expect(t, 200, "") if !articlesEqual(before, w.stored(12)) { t.Errorf("article 12 changed") } }) t.Run("Permissions are unchanged", func(t *testing.T) { w := newStoryWorld(t) w.send(storyReader, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":1}`).expect(t, 403, apperr.MissingPermissionErrorMessage) w.send(storyAnon, http.MethodPatch, "/v1/article/10", `{"body":"x","if_revision":1}`).expect(t, 401, "") w.send(storyUpdater, http.MethodPatch, "/v1/article/9999", `{"body":"x"}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage) before := w.snapshot() hidden := w.send(storyPartial, http.MethodPatch, "/v1/article/11", `{"body":"x","if_revision":99}`) hidden.expect(t, 404, apperr.ArticleNotFoundErrorMessage) if hidden.header("X-Current-Revision") != "" { t.Errorf("a caller who cannot read the article must not learn its revision") } w.send(storyPartial, http.MethodPatch, "/v1/article/11", `{"body":"x"}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage) w.expectUnchanged(before) }) t.Run("Editing an online article without content.publish changes the live page right away", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title"}`).expect(t, 200, "") public := w.send(storyAnon, http.MethodGet, "/v1/article/10", "") public.expect(t, 200, "") if string(public.fields(t)["title"]) != `"New title"` { t.Errorf("the public read must show the new title: %s", public.text()) } }) t.Run("Title and slug edits are recorded", func(t *testing.T) { w := newStoryWorld(t) w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"title":"New title","slug":"new-address"}`).expect(t, 200, "") events := w.eventsOf(audit.ActionArticleEdit) if len(events) != 1 { t.Fatalf("one edit event expected, got %d", len(events)) } fields := []string{} for _, change := range events[0].Changes { fields = append(fields, change.Field) } if !strings.Contains(strings.Join(fields, ","), audit.FieldTitle) || !strings.Contains(strings.Join(fields, ","), audit.FieldSlug) || events[0].Params[audit.ParamLang] != "en" { t.Errorf("title, slug and lang expected: %v %v", fields, events[0].Params) } }) } func assertPositiveRevision(t *testing.T, fields map[string]json.RawMessage) { t.Helper() value, err := strconv.Atoi(string(fields["revision"])) if err != nil || value < 1 { t.Errorf("revision must be a positive integer: %s", fields["revision"]) } }