package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "net/http" "testing" "time" ) func siteChangesOf(w *storyWorld) []audit.Change { changes := []audit.Change{} for _, event := range w.eventsOf(audit.ActionArticleEdit) { for _, change := range event.Changes { if change.Field == audit.FieldSite { changes = append(changes, change) } } } return changes } func TestArticleWritePathEdgesStoryMove(t *testing.T) { t.Run("A busy site lock refuses the story move", func(t *testing.T) { w := newStoryWorld(t) w.sites.busy = true before := w.snapshot() w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"atelier"}`).expect(t, 409, apperr.StoryBusyErrorMessage) w.expectUnchanged(before) }) t.Run("The story move lists versions by creation then id", func(t *testing.T) { w := newStoryWorld(t) for id, stored := range w.articles.articles { stored.CreatedAt = time.Time{} w.articles.articles[id] = stored } w.articles.reverseStory = true response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"atelier"}`) response.expect(t, 200, "") if response.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,11,12,13]}` { t.Errorf("unexpected body %q", response.text()) } }) t.Run("A whole-story move through PATCH returns the moved article with the other edits", 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}) response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"title":"Renamed visible"}`) response.expect(t, 200, "") returned := response.article(t) if returned.ID != 70 || returned.Title != "Renamed visible" || returned.SiteID != storyAtelier { t.Errorf("unexpected response: %+v", returned) } if w.stored(70).Title != "Renamed visible" || w.stored(70).SiteID != storyAtelier || w.stored(71).SiteID != storyAtelier { t.Errorf("the story and the edit must be stored: %+v %+v", w.stored(70), w.stored(71)) } if got := len(siteChangesOf(w)); got != 2 { t.Errorf("one site change per moved version expected, got %d", got) } }) t.Run("A combined site_id and field edit 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,"title":"Renamed visible","if_revision":3}`).expect(t, 200, "") if edit, content := revisionsOf(w, 70); w.stored(70).SiteID != storyAtelier || edit != 4 || content != 5 { t.Errorf("site %d revision %d content_revision %d, expected 2, 4 and 5", w.stored(70).SiteID, edit, content) } if edit, _ := revisionsOf(w, 71); w.stored(71).SiteID != storyAtelier || edit != 6 { t.Errorf("hidden version: site %d revision %d, expected 2 and 6", w.stored(71).SiteID, edit) } }) t.Run("A failure after the move step leaves every version on its original site", func(t *testing.T) { cases := []struct { name string body string arrange func(w *storyWorld) status int }{ {"unknown author", `{"site_id":2,"author_ids":[99]}`, func(*storyWorld) {}, http.StatusNotFound}, {"invalid canonical url", `{"site_id":2,"canonical_url":"not a url"}`, func(*storyWorld) {}, http.StatusUnprocessableEntity}, {"invalid slug", `{"site_id":2,"slug":"Not A Slug"}`, func(*storyWorld) {}, http.StatusBadRequest}, {"slug taken at the destination", `{"site_id":2,"slug":"claimed"}`, func(w *storyWorld) { w.put(article.Article{ID: 72, SiteID: storyAtelier, Title: "Claimed", CreatedBy: 1, Slug: "claimed", Lang: "fr", StoryID: 72}) }, http.StatusConflict}, {"slug race on the write", `{"site_id":2,"title":"Renamed visible"}`, func(w *storyWorld) { w.articles.reportSlugRace = true }, http.StatusConflict}, {"repository failure on the write", `{"site_id":2,"title":"Renamed visible"}`, func(w *storyWorld) { w.articles.fail.failNext("Update") }, http.StatusInternalServerError}, } for _, example := range cases { t.Run(example.name, 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}) example.arrange(w) before := w.snapshot() response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", example.body) if response.Code != example.status { t.Errorf("expected %d, got %d %q", example.status, response.Code, response.text()) } w.expectUnchanged(before) }) } }) } func TestArticleWritePathEdgesStoryMoveSlugRace(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}) w.articles.reportSlugRace = true response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"title":"Renamed visible"}`) response.expect(t, http.StatusConflict, apperr.ArticleSlugTakenErrorMessage) if response.header(apperr.SlugSuggestionHeader) != "visible-2" { t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader)) } } func TestArticleWritePathEdgesSingleVersionMove(t *testing.T) { seed := func(t *testing.T) *storyWorld { w := newStoryWorld(t) w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "fr", StoryID: 8}) return w } t.Run("A stale if_revision refuses the move", func(t *testing.T) { w := seed(t) before := w.snapshot() response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2,"if_revision":9}`) if response.Code != http.StatusConflict { t.Errorf("expected 409, got %d %q", response.Code, response.text()) } w.expectUnchanged(before) }) t.Run("A move records the site change with both names", func(t *testing.T) { w := seed(t) w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2,"if_revision":1}`).expect(t, 200, "") changes := siteChangesOf(w) if len(changes) != 1 || *changes[0].Before != "Journal" || *changes[0].After != "Atelier" { t.Errorf("unexpected site changes %+v", changes) } }) t.Run("The site change falls back to the site id when the origin is unknown", func(t *testing.T) { w := seed(t) delete(w.sites.sites, storyJournal) w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`).expect(t, 200, "") changes := siteChangesOf(w) if len(changes) != 1 || *changes[0].Before != "1" || *changes[0].After != "Atelier" { t.Errorf("unexpected site changes %+v", changes) } }) t.Run("Targeting the current site records no site change", func(t *testing.T) { w := seed(t) w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":1,"title":"Alone again"}`).expect(t, 200, "") if len(siteChangesOf(w)) != 0 { t.Errorf("no site change expected") } if len(w.eventsOf(audit.ActionArticleEdit)) != 1 { t.Errorf("the title edit must still be recorded") } }) t.Run("An unchanged slug on the current site skips the slug check", func(t *testing.T) { w := seed(t) w.articles.fail.failNext("SlugTaken") w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":1,"title":"Alone again"}`).expect(t, 200, "") }) t.Run("A slug already used on the destination refuses the move", func(t *testing.T) { w := seed(t) w.put(article.Article{ID: 80, SiteID: storyAtelier, Title: "Alone", CreatedBy: 1, Slug: "alone", Lang: "fr", StoryID: 80}) before := w.snapshot() response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`) response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage) if response.header(apperr.SlugSuggestionHeader) != "alone-2" { t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader)) } w.expectUnchanged(before) }) t.Run("A slug race on a move keeps the slug and suggests a free one", func(t *testing.T) { w := seed(t) w.articles.reportSlugRace = true before := w.snapshot() response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`) response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage) if response.header(apperr.SlugSuggestionHeader) != "alone-2" { t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader)) } w.expectUnchanged(before) }) t.Run("A slug race on a slug edit suggests from the requested slug", func(t *testing.T) { w := seed(t) w.articles.reportSlugRace = true before := w.snapshot() response := w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"slug":"new-address"}`) response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage) if response.header(apperr.SlugSuggestionHeader) != "new-address-2" { t.Errorf("suggestion %q", response.header(apperr.SlugSuggestionHeader)) } w.expectUnchanged(before) }) } func TestArticleWritePathEdgesAuthors(t *testing.T) { t.Run("An author lookup failure aborts the edit", func(t *testing.T) { w := newStoryWorld(t) w.authors.fail.failNext("Get") before := w.snapshot() response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"author_ids":[1]}`) if response.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d %q", response.Code, response.text()) } w.expectUnchanged(before) }) t.Run("An unknown later author aborts the edit", func(t *testing.T) { w := newStoryWorld(t) before := w.snapshot() response := w.send(storyUpdater, http.MethodPatch, "/v1/article/10", `{"author_ids":[1,999]}`) if response.Code == http.StatusOK { t.Errorf("an unknown author must refuse the edit: %q", response.text()) } w.expectUnchanged(before) }) t.Run("A creator lookup failure aborts the creation", func(t *testing.T) { w := newStoryWorld(t) w.authors.fail.failNext("GetByUserID") before := w.snapshot() response := w.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Plain","body":"x"}`) if response.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d %q", response.Code, response.text()) } w.expectUnchanged(before) }) } func TestArticleWritePathEdgesSlugAttempts(t *testing.T) { t.Run("Creation gives up after five racing attempts", func(t *testing.T) { w := newStoryWorld(t) w.articles.createAlwaysRaces = true before := w.snapshot() response := w.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Plain","body":"x"}`) response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage) if w.articles.raceCreates != 5 { t.Errorf("expected 5 attempts, got %d", w.articles.raceCreates) } w.expectUnchanged(before) }) t.Run("A translation gives up after five racing attempts", func(t *testing.T) { w := newTranslationWorld(t) w.articles.createAlwaysRaces = true before := w.snapshot() response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`) response.expect(t, 409, apperr.ArticleSlugTakenErrorMessage) if w.articles.raceCreates != 5 { t.Errorf("expected 5 attempts, got %d", w.articles.raceCreates) } w.expectUnchanged(before) }) } func TestArticleWritePathEdgesTranslationSource(t *testing.T) { t.Run("A source of another story the caller cannot read is unavailable, not other-story", func(t *testing.T) { w := newTranslationWorld(t) w.put(article.Article{ID: 20, Title: "Other story", CreatedBy: storyOwner, Slug: "other", Lang: "en", StoryID: 8}) before := w.snapshot() response := w.send(storyWriter1, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":20}`) response.expect(t, 404, apperr.SourceUnavailableErrorMessage) w.expectUnchanged(before) }) t.Run("A source that becomes unreadable before the transaction is unavailable", func(t *testing.T) { w := newTranslationWorld(t) w.put(article.Article{ID: 11, Title: "Kosten", Body: "
Kosten
", CreatedBy: storyWriter1, Slug: "kosten", Lang: "de", StoryID: storyStory}) calls := 0 w.articles.onGet = func(id uint32) error { if id != 11 { return nil } calls++ if calls == 2 { hidden := w.articles.articles[11] hidden.CreatedBy = storyOwner w.articles.articles[11] = hidden } return nil } count := len(w.articles.articles) response := w.send(storyWriter1, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`) response.expect(t, 404, apperr.SourceUnavailableErrorMessage) if len(w.articles.articles) != count { t.Errorf("no translation may be created") } }) }