348 lines
13 KiB
Go
348 lines
13 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|