feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
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: "<p>Kosten</p>", 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")
}
})
}