feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user