feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
|
||||
if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
|
||||
if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), "héllo wörld", article.BodyFormatHTML); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -68,6 +68,8 @@ var routesNotYetInTheCatalogue = []string{
|
||||
"POST /v1/site/transfer-ownership",
|
||||
"PUT /v1/role/{id}",
|
||||
"PUT /v1/user/{id}/role",
|
||||
"POST /v1/article/{id}/translations",
|
||||
"POST /v1/story/{story_id}/move",
|
||||
}
|
||||
|
||||
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
|
||||
|
||||
@@ -112,7 +112,9 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images, newFakeLocalizationStore(env.articles), plainStoryMover(env.articles)),
|
||||
CreateTranslationUseCase: plainTranslation(env.articles),
|
||||
DescribeArticleUseCase: plainDescribe(env.articles),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
|
||||
@@ -47,7 +47,9 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -27,7 +27,9 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -39,7 +39,9 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(e.articles), plainStoryMover(e.articles)),
|
||||
CreateTranslationUseCase: plainTranslation(e.articles),
|
||||
DescribeArticleUseCase: plainDescribe(e.articles),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Use(authtoken.Verifier(e.token))
|
||||
@@ -215,11 +217,15 @@ func TestArticleCreatedByHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anonymous and signed-in encodings differ only by created_by", func(t *testing.T) {
|
||||
t.Run("anonymous and signed-in encodings differ only by created_by and capabilities", func(t *testing.T) {
|
||||
e, router := newCreatedByEnv(t)
|
||||
anonymous := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "").Body.Bytes())
|
||||
signedIn := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID)).Body.Bytes())
|
||||
delete(signedIn, "created_by")
|
||||
delete(signedIn, "capabilities")
|
||||
delete(signedIn, "translations")
|
||||
delete(anonymous, "translations")
|
||||
delete(anonymous, "capabilities")
|
||||
if len(anonymous) != len(signedIn) {
|
||||
t.Fatalf("field sets differ: %d vs %d", len(anonymous), len(signedIn))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
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":"<p>x</p>"}`)
|
||||
|
||||
response.expect(t, 200, "")
|
||||
if stored := w.stored(10); stored.Title != "New title" || stored.Body != "<p>x</p>" || 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":"<p>changed</p>"}`, 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":"<p>b</p>","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":"<p>Costs</p>","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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArticleImpactOfImageIdentity(t *testing.T) {
|
||||
one, sameOne, two := uint32(1), uint32(1), uint32(2)
|
||||
cases := []struct {
|
||||
name string
|
||||
before, after *uint32
|
||||
edited bool
|
||||
}{
|
||||
{"both absent", nil, nil, false},
|
||||
{"added", nil, &one, true},
|
||||
{"removed", &one, nil, true},
|
||||
{"same id", &one, &sameOne, false},
|
||||
{"other id", &one, &two, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
for _, social := range []bool{false, true} {
|
||||
before, after := article.Article{}, article.Article{}
|
||||
if social {
|
||||
before.SocialImageID, after.SocialImageID = c.before, c.after
|
||||
} else {
|
||||
before.CoverImageID, after.CoverImageID = c.before, c.after
|
||||
}
|
||||
impact := article.ImpactOf(before, after, false)
|
||||
if impact.Edited != c.edited || impact.Content {
|
||||
t.Errorf("social=%v: got %+v, want edited=%v content=false", social, impact, c.edited)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleImpactOfPerField(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(a *article.Article)
|
||||
authors bool
|
||||
edited bool
|
||||
content bool
|
||||
}{
|
||||
{"nothing", func(a *article.Article) {}, false, false, false},
|
||||
{"title", func(a *article.Article) { a.Title = "other" }, false, true, true},
|
||||
{"body", func(a *article.Article) { a.Body = "other" }, false, true, true},
|
||||
{"excerpt", func(a *article.Article) { a.Excerpt = "other" }, false, true, true},
|
||||
{"slug", func(a *article.Article) { a.Slug = "other" }, false, true, true},
|
||||
{"cover alt", func(a *article.Article) { a.CoverImageAlt = "other" }, false, true, true},
|
||||
{"seo title", func(a *article.Article) { a.SEOTitle = "other" }, false, true, true},
|
||||
{"seo description", func(a *article.Article) { a.SEODescription = "other" }, false, true, true},
|
||||
{"social title", func(a *article.Article) { a.SocialTitle = "other" }, false, true, true},
|
||||
{"social description", func(a *article.Article) { a.SocialDescription = "other" }, false, true, true},
|
||||
{"authors", func(a *article.Article) {}, true, true, false},
|
||||
{"site", func(a *article.Article) { a.SiteID = 9 }, false, true, false},
|
||||
{"canonical url", func(a *article.Article) { a.CanonicalURL = "https://example.org/a" }, false, true, false},
|
||||
{"noindex", func(a *article.Article) { a.Noindex = true }, false, true, false},
|
||||
{"seo dismissed", func(a *article.Article) { a.SEODismissed = []string{"title"} }, false, true, false},
|
||||
{"is online", func(a *article.Article) { a.IsOnline = true }, false, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
before := article.Article{}
|
||||
after := before
|
||||
c.change(&after)
|
||||
|
||||
impact := article.ImpactOf(before, after, c.authors)
|
||||
|
||||
if impact.Edited != c.edited || impact.Content != c.content {
|
||||
t.Errorf("got %+v, want edited=%v content=%v", impact, c.edited, c.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -78,19 +78,14 @@ func TestArticleLocalizationStorageHTTP(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Article write payloads cannot set lang or story_id", func(t *testing.T) {
|
||||
t.Run("PATCH ignores lang and story_id", func(t *testing.T) {
|
||||
e, router := newCreatedByEnv(t)
|
||||
e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false)
|
||||
|
||||
created := sendCreatedBy(router, http.MethodPost, "/v1/article/", `{"title":"Fresh draft title","body":"","lang":"en","story_id":99}`, asUser(e, createdByWriterID))
|
||||
patched := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"excerpt":"Short","lang":"en","story_id":99}`, asUser(e, activityOwnerID))
|
||||
|
||||
if created.Code != http.StatusCreated || patched.Code != http.StatusOK {
|
||||
t.Fatalf("statuses %d and %d: %s %s", created.Code, patched.Code, created.Body, patched.Body)
|
||||
}
|
||||
createdFields := rawFields(decodeArticleFields(t, created.Body.Bytes()))
|
||||
if createdFields["lang"] == `"en"` || createdFields["story_id"] == "99" {
|
||||
t.Errorf("the create payload set lang or story_id: %v", createdFields)
|
||||
if patched.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", patched.Code, patched.Body)
|
||||
}
|
||||
assertFields(t, rawFields(decodeArticleFields(t, patched.Body.Bytes())), "fr-CA", legacyArticleKeys)
|
||||
if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory {
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
languageSuggestionsMarker = "site_language_suggestions"
|
||||
storyLanguageIndexName = "idx_articles_story_lang"
|
||||
siteSlugIndexName = "idx_articles_site_slug"
|
||||
retiredIDsMarker = "article_retired_ids"
|
||||
slugPerLanguageMarker = "article_slug_index_per_language"
|
||||
injectedBackfillFailure = "injected backfill failure"
|
||||
raceCreators = 8
|
||||
@@ -73,7 +74,7 @@ func regressToPreLocalizationSchema(t *testing.T, db *gorm.DB) {
|
||||
"UPDATE articles SET story_id = 0, lang = ''",
|
||||
"DELETE FROM stories",
|
||||
"DELETE FROM site_languages",
|
||||
"DELETE FROM migration_markers WHERE name IN ('" + storiesBackfillMarker + "', '" + languageSuggestionsMarker + "', '" + slugPerLanguageMarker + "')",
|
||||
"DELETE FROM migration_markers WHERE name IN ('" + storiesBackfillMarker + "', '" + languageSuggestionsMarker + "', '" + slugPerLanguageMarker + "', '" + retiredIDsMarker + "')",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
|
||||
@@ -27,7 +27,9 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func capabilitiesOf(t *testing.T, response storyResponse) []string {
|
||||
t.Helper()
|
||||
var capabilities []string
|
||||
if err := json.Unmarshal(response.fields(t)["capabilities"], &capabilities); err != nil {
|
||||
t.Fatalf("capabilities: %v in %s", err, response.text())
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
|
||||
func translationsOf(t *testing.T, response storyResponse) []map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var translations []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(response.fields(t)["translations"], &translations); err != nil {
|
||||
t.Fatalf("translations: %v in %s", err, response.text())
|
||||
}
|
||||
return translations
|
||||
}
|
||||
|
||||
func languagesIn(translations []map[string]json.RawMessage) []string {
|
||||
tags := []string{}
|
||||
for _, entry := range translations {
|
||||
tags = append(tags, strings.Trim(string(entry["lang"]), `"`))
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func disableOnJournal(w *storyWorld, tag string) {
|
||||
rows := w.rows.rows[storyJournal]
|
||||
for index := range rows {
|
||||
if rows[index].Tag == tag {
|
||||
rows[index].Enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleStoryLifecycleHTTP(t *testing.T) {
|
||||
t.Run("Capabilities of the owner", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "")
|
||||
|
||||
if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, []string{"read", "edit", "publish", "delete", "create_translation"}) {
|
||||
t.Errorf("capabilities %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Capabilities per caller and version", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
id string
|
||||
online11 bool
|
||||
want []string
|
||||
}{
|
||||
{"writer1 on its own draft", storyWriter1, "12", false, []string{"read", "edit", "publish", "delete", "create_translation"}},
|
||||
{"writer1 on a readable sibling of another user", storyWriter1, "11", true, []string{"read", "create_translation"}},
|
||||
{"reader", storyReader, "10", false, []string{"read"}},
|
||||
{"update and read all", storyUpdater, "12", false, []string{"read", "edit"}},
|
||||
{"publish and read all", storyPublish, "12", false, []string{"read", "publish"}},
|
||||
{"anonymous", storyAnon, "10", false, []string{"read"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
if c.online11 {
|
||||
sibling := w.stored(11)
|
||||
sibling.IsOnline = true
|
||||
w.articles.articles[11] = sibling
|
||||
}
|
||||
|
||||
response := w.send(c.user, http.MethodGet, "/v1/article/"+c.id, "")
|
||||
|
||||
if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("capabilities %v, expected %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Translations list readable versions only", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
response := w.send(storyWriter1, http.MethodGet, "/v1/article/10", "")
|
||||
|
||||
translations := translationsOf(t, response)
|
||||
if got := languagesIn(translations); !reflect.DeepEqual(got, []string{"en", "ar", "fr"}) {
|
||||
t.Errorf("translations %v", got)
|
||||
}
|
||||
for _, entry := range translations {
|
||||
for _, key := range []string{"id", "lang", "title", "is_online", "language_enabled", "capabilities"} {
|
||||
if _, present := entry[key]; !present {
|
||||
t.Errorf("entry misses %q: %v", key, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(response.text(), "Kosten") || strings.Contains(response.text(), `"de"`) {
|
||||
t.Errorf("the hidden version leaks: %s", response.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Translations are ordered default language first, then by tag", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "")
|
||||
|
||||
if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) {
|
||||
t.Errorf("translations %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Disabled languages are marked", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
disableOnJournal(w, "fr")
|
||||
|
||||
response := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
|
||||
|
||||
for _, entry := range translationsOf(t, response) {
|
||||
enabled := string(entry["language_enabled"])
|
||||
if lang := string(entry["lang"]); (lang == `"fr"`) == (enabled == "true") {
|
||||
t.Errorf("fr must be flagged disabled and still listed: %v", entry)
|
||||
}
|
||||
}
|
||||
if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) {
|
||||
t.Errorf("fr must still be listed: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A single-version article lists itself", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8})
|
||||
|
||||
response := w.send(storyOwner, http.MethodGet, "/v1/article/20", "")
|
||||
|
||||
translations := translationsOf(t, response)
|
||||
if len(translations) != 1 || string(translations[0]["id"]) != "20" {
|
||||
t.Errorf("translations %v", translations)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Anonymous reads expose nothing about siblings", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
response := w.send(storyAnon, http.MethodGet, "/v1/article/10", "")
|
||||
|
||||
fields := response.fields(t)
|
||||
if string(fields["translations"]) != "[]" || string(fields["capabilities"]) != `["read"]` || string(fields["source"]) != "null" {
|
||||
t.Errorf("unexpected anonymous fields: %s", response.text())
|
||||
}
|
||||
expected := append([]string{"lang", "story_id", "revision", "capabilities", "translations", "source", "body_format"}, legacyArticleKeys...)
|
||||
for key := range fields {
|
||||
if !contains(expected, key) {
|
||||
t.Errorf("unexpected key %q in the public shape", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The source on the read", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
source := uint32(10)
|
||||
w.put(article.Article{ID: 14, Title: "Copy", CreatedBy: storyRestrict, Slug: "copy", Lang: "en", StoryID: 14, SourceArticleID: &source, SourceRevision: 4})
|
||||
w.put(article.Article{ID: 15, Title: "Empty", CreatedBy: storyWriter1, Slug: "empty", Lang: "en", StoryID: 15})
|
||||
|
||||
response := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
|
||||
if string(response.fields(t)["source"]) != `{"article_id":10,"revision":4}` {
|
||||
t.Errorf("source %s", response.fields(t)["source"])
|
||||
}
|
||||
if string(w.send(storyOwner, http.MethodGet, "/v1/article/15", "").fields(t)["source"]) != "null" {
|
||||
t.Errorf("an empty version has no source")
|
||||
}
|
||||
draft := w.stored(10)
|
||||
draft.IsOnline = false
|
||||
w.articles.articles[10] = draft
|
||||
if string(w.send(storyReader, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) == "null" {
|
||||
t.Errorf("the reader reads everything and sees the source")
|
||||
}
|
||||
if string(w.send(storyRestrict, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" {
|
||||
t.Errorf("an unreadable source is null")
|
||||
}
|
||||
delete(w.articles.articles, 10)
|
||||
if string(w.send(storyOwner, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" {
|
||||
t.Errorf("a deleted source is null")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Publishing respects the language configuration", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
id uint32
|
||||
status int
|
||||
message string
|
||||
online bool
|
||||
}{
|
||||
{"fr enabled", 12, 200, "", true},
|
||||
{"es disabled, offline version", 15, 422, apperr.LanguageDisabledErrorMessage, false},
|
||||
{"es disabled, already online", 16, 200, "", true},
|
||||
{"unassigned language", 30, 200, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15})
|
||||
w.put(article.Article{ID: 16, Title: "Online es", CreatedBy: 1, Slug: "es-online", Lang: "es", StoryID: 16, IsOnline: true})
|
||||
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30})
|
||||
|
||||
response := w.send(storyPublish, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(c.id))+"/publish", "")
|
||||
|
||||
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 w.stored(c.id).IsOnline != c.online {
|
||||
t.Errorf("is_online = %v, expected %v", w.stored(c.id).IsOnline, c.online)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unpublish and edit stay available in a disabled language", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
fr := w.stored(12)
|
||||
fr.IsOnline = true
|
||||
w.articles.articles[12] = fr
|
||||
disableOnJournal(w, "fr")
|
||||
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "")
|
||||
})
|
||||
|
||||
t.Run("Publishing one version leaves the others alone", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
|
||||
|
||||
for _, id := range []uint32{10, 11, 13} {
|
||||
if !articlesEqual(before[id], w.stored(id)) {
|
||||
t.Errorf("article %d changed", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Re-enabling a language allows publishing again", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15})
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 422, apperr.LanguageDisabledErrorMessage)
|
||||
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar", "es"}, nil)
|
||||
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 200, "")
|
||||
})
|
||||
|
||||
t.Run("Existing publish errors are unchanged", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/9999/publish", "").expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/abc/publish", "").expect(t, 400, apperr.IdUint32ErrorMessage)
|
||||
w.send(storyAnon, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 401, "")
|
||||
w.send(storyReader, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 403, apperr.MissingPermissionErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("Deleting one version keeps the story", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
|
||||
|
||||
for _, id := range []uint32{10, 11, 13} {
|
||||
if _, present := w.articles.articles[id]; !present {
|
||||
t.Errorf("article %d must remain", id)
|
||||
}
|
||||
}
|
||||
listing := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
|
||||
if got := languagesIn(translationsOf(t, listing)); !reflect.DeepEqual(got, []string{"en", "ar", "de"}) {
|
||||
t.Errorf("translations %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A deleted language can be created again", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
|
||||
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/article/10/translations", `{"lang":"fr","title":"Couts","copy":false}`)
|
||||
|
||||
response.expect(t, 201, "")
|
||||
})
|
||||
|
||||
t.Run("Deleting the last version removes the story", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8})
|
||||
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/article/20", "").expect(t, 204, "")
|
||||
|
||||
if versions, _ := w.articles.ListByStory(8); len(versions) != 0 {
|
||||
t.Errorf("story 8 must be gone")
|
||||
}
|
||||
w.send(storyOwner, http.MethodPost, "/v1/article/20/translations", `{"lang":"fr","title":"Couts","copy":false}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("Deleting the last of several versions", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
for position, id := range []string{"10", "11", "12", "13"} {
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/article/"+id, "").expect(t, 204, "")
|
||||
versions, _ := w.articles.ListByStory(storyStory)
|
||||
if (position < 3) != (len(versions) > 0) {
|
||||
t.Errorf("after deleting %s, %d versions remain", id, len(versions))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete is checked per version", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
w.send(storyWriter1, http.MethodDelete, "/v1/article/11", "").expect(t, 403, apperr.MissingPermissionErrorMessage)
|
||||
|
||||
if _, present := w.articles.articles[11]; !present {
|
||||
t.Errorf("article 11 must remain")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A story with a hidden version", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 80, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "en", StoryID: 80})
|
||||
w.put(article.Article{ID: 81, Title: "Hidden", CreatedBy: 2, Slug: "hidden", Lang: "de", StoryID: 80})
|
||||
|
||||
response := w.send(storyWriter1, http.MethodDelete, "/v1/article/80", "")
|
||||
|
||||
response.expect(t, 204, "")
|
||||
if versions, _ := w.articles.ListByStory(80); len(versions) != 1 || response.text() != "" {
|
||||
t.Errorf("the story must remain and the response mention nothing: %d %q", len(versions), response.text())
|
||||
}
|
||||
})
|
||||
|
||||
moveBody := `{"site":"atelier"}`
|
||||
t.Run("Move a story", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 200, "")
|
||||
if response.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` {
|
||||
t.Errorf("unexpected body %s", response.text())
|
||||
}
|
||||
for id, previous := range before {
|
||||
moved := w.stored(id)
|
||||
if moved.SiteID != 2 || moved.EditRevision != previous.EditRevision+1 || moved.ContentRevision != previous.ContentRevision {
|
||||
t.Errorf("article %d: %+v", id, moved)
|
||||
}
|
||||
previous.SiteID, previous.EditRevision = moved.SiteID, moved.EditRevision
|
||||
if !articlesEqual(previous, moved) {
|
||||
t.Errorf("article %d changed beyond its site: %+v", id, moved)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A move requires every language to be enabled at the destination", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
||||
if response.header(apperr.MissingLanguagesHeader) != "ar" {
|
||||
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("A language disabled at the destination counts as missing", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "ar"}, []string{"de"})
|
||||
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
||||
if !strings.Contains(response.header(apperr.MissingLanguagesHeader), "de") {
|
||||
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A language disabled at the source still needs the destination", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
disableOnJournal(w, "fr")
|
||||
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "")
|
||||
|
||||
w = newStoryWorld(t)
|
||||
disableOnJournal(w, "fr")
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "de", "ar"}, nil)
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
||||
if response.header(apperr.MissingLanguagesHeader) != "fr" {
|
||||
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Address conflict at the destination", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 11, Title: "Kosten", CreatedBy: storyPartial, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * 60e9)})
|
||||
w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyPartial, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(60e9)})
|
||||
hidden := w.stored(13)
|
||||
hidden.IsOnline = false
|
||||
w.articles.articles[13] = hidden
|
||||
w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 91})
|
||||
w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier ar", CreatedBy: 1, Slug: "takalif", Lang: "ar", StoryID: 92})
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 409, apperr.SlugConflictErrorMessage)
|
||||
if response.header(apperr.ConflictingArticlesHeader) != "12" {
|
||||
t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader))
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("Every conflict of the story is checked", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier en", CreatedBy: 1, Slug: "renovation-costs", Lang: "en", StoryID: 91})
|
||||
w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 92})
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 409, apperr.SlugConflictErrorMessage)
|
||||
if response.header(apperr.ConflictingArticlesHeader) != "10,12" {
|
||||
t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader))
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("A move needs update permission on every version, readable or not", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
offline bool
|
||||
status int
|
||||
message string
|
||||
readable bool
|
||||
}{
|
||||
{"an anonymous client", storyAnon, false, 401, "", true},
|
||||
{"writer1", storyWriter1, false, 403, apperr.MissingPermissionErrorMessage, true},
|
||||
{"a user without content.update", storyReader, false, 403, apperr.MissingPermissionErrorMessage, true},
|
||||
{"a user who can read no version", storyPartial, true, 404, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
if c.offline {
|
||||
for _, id := range []uint32{10, 13} {
|
||||
draft := w.stored(id)
|
||||
draft.IsOnline = false
|
||||
w.articles.articles[id] = draft
|
||||
}
|
||||
}
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(c.user, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
if c.readable {
|
||||
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
|
||||
t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
||||
}
|
||||
} else {
|
||||
response.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Hidden versions never leak through the move", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
hidden := w.stored(13)
|
||||
hidden.IsOnline = false
|
||||
w.articles.articles[13] = hidden
|
||||
|
||||
response := w.send(storyWriter1, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
response.expect(t, 403, apperr.MissingPermissionErrorMessage)
|
||||
if strings.Contains(response.text(), "11") || strings.Contains(response.text(), "13") || strings.Contains(response.Header().Get(apperr.MissingPermissionHeader), "1") {
|
||||
t.Errorf("the response names a hidden version")
|
||||
}
|
||||
w = newStoryWorld(t)
|
||||
hidden = w.stored(13)
|
||||
hidden.IsOnline = false
|
||||
w.articles.articles[13] = hidden
|
||||
for _, id := range []uint32{10, 11, 12} {
|
||||
readable := w.stored(id)
|
||||
readable.CreatedBy = storyPartial
|
||||
w.articles.articles[id] = readable
|
||||
}
|
||||
success := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
success.expect(t, 200, "")
|
||||
if success.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11]}` {
|
||||
t.Errorf("article_ids must list readable versions only: %s", success.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Move validation", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"unknown site", "/v1/story/5/move", `{"site":"nowhere"}`, 404, apperr.SiteNotFoundErrorMessage},
|
||||
{"empty body", "/v1/story/5/move", `{}`, 400, apperr.BodyErrorMessage},
|
||||
{"malformed JSON", "/v1/story/5/move", `{"site":`, 400, apperr.BodyErrorMessage},
|
||||
{"unknown story", "/v1/story/9999/move", moveBody, 404, apperr.ArticleNotFoundErrorMessage},
|
||||
{"bad story id", "/v1/story/abc/move", moveBody, 400, apperr.IdUint32ErrorMessage},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
|
||||
w.send(storyOwner, http.MethodPost, c.path, c.body).expect(t, c.status, c.message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Moving to the current site", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"journal"}`)
|
||||
|
||||
response.expect(t, 200, "")
|
||||
w.expectUnchanged(before)
|
||||
if len(w.recorder.events) != 0 {
|
||||
t.Errorf("no activity event expected, got %d", len(w.recorder.events))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The move is atomic and revalidated inside the transaction", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.articles.beforeMove = func() {
|
||||
w.put(article.Article{ID: 95, SiteID: storyAtelier, Title: "Late", CreatedBy: 1, Slug: "kosten", Lang: "de", StoryID: 95})
|
||||
}
|
||||
before := w.snapshot()
|
||||
delete(before, 95)
|
||||
|
||||
late := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
late.expect(t, 409, apperr.SlugConflictErrorMessage)
|
||||
delete(w.articles.articles, 95)
|
||||
w.expectUnchanged(before)
|
||||
|
||||
w.articles.moveFailsAt = 3
|
||||
failed := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
||||
|
||||
if failed.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", failed.Code)
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("PATCH site_id cannot split a story", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/10", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage)
|
||||
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("PATCH site_id counts readable versions only", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 60, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 60})
|
||||
w.put(article.Article{ID: 61, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden", Lang: "de", StoryID: 60})
|
||||
w.put(article.Article{ID: 62, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "fr", StoryID: 62})
|
||||
w.put(article.Article{ID: 63, Title: "Not mine", CreatedBy: storyOwner, Slug: "not-mine", Lang: "de", StoryID: 62})
|
||||
w.put(article.Article{ID: 64, Title: "First", CreatedBy: storyPartial, Slug: "first", Lang: "fr", StoryID: 64})
|
||||
w.put(article.Article{ID: 65, Title: "Second", CreatedBy: storyPartial, Slug: "second", Lang: "de", StoryID: 64})
|
||||
w.put(article.Article{ID: 66, Title: "Third hidden", CreatedBy: storyOwner, Slug: "third-hidden", Lang: "en", StoryID: 64})
|
||||
|
||||
denied := w.send(storyWriter1, http.MethodPatch, "/v1/article/62", `{"site_id":2}`)
|
||||
denied.expect(t, 403, apperr.MissingPermissionErrorMessage)
|
||||
if denied.header("X-Missing-Permission") != "content.update" || strings.Contains(denied.text(), "hidden") {
|
||||
t.Errorf("the refusal must only name the permission: %v %q", denied.Header(), denied.text())
|
||||
}
|
||||
if w.stored(62).SiteID != storyJournal || w.stored(63).SiteID != storyJournal {
|
||||
t.Errorf("a refused move must change nothing")
|
||||
}
|
||||
|
||||
w.send(storyPartial, http.MethodPatch, "/v1/article/64", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage)
|
||||
if w.stored(64).SiteID != storyJournal {
|
||||
t.Errorf("a refused move must change nothing")
|
||||
}
|
||||
|
||||
w.send(storyPartial, http.MethodPatch, "/v1/article/60", `{"site_id":2}`).expect(t, 200, "")
|
||||
if w.stored(60).SiteID != 2 || w.stored(61).SiteID != 2 {
|
||||
t.Errorf("every version of the story moves together: %d %d", w.stored(60).SiteID, w.stored(61).SiteID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PATCH site_id with a hidden version applies the story move rules", 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})
|
||||
w.put(article.Article{ID: 72, SiteID: storyAtelier, Title: "Taken", CreatedBy: 1, Slug: "hidden-de", Lang: "de", StoryID: 72})
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2}`)
|
||||
|
||||
response.expect(t, 409, apperr.SlugConflictErrorMessage)
|
||||
if strings.Contains(response.text(), "hidden") || response.header(apperr.ConflictingArticlesHeader) != "" {
|
||||
t.Errorf("the refusal must not name hidden versions: %v %q", response.Header(), response.text())
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("PATCH site_id with a hidden version 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,"if_revision":3}`).expect(t, 200, "")
|
||||
|
||||
if edit, content := revisionsOf(w, 70); w.stored(70).SiteID != 2 || edit != 4 || content != 4 {
|
||||
t.Errorf("site %d revision %d content_revision %d, expected 2, 4 and 4", w.stored(70).SiteID, edit, content)
|
||||
}
|
||||
if edit, content := revisionsOf(w, 71); w.stored(71).SiteID != 2 || edit != 6 || content != 6 {
|
||||
t.Errorf("hidden version: site %d revision %d content_revision %d, expected 2, 6 and 6", w.stored(71).SiteID, edit, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PATCH site_id with a stale if_revision moves nobody", 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})
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":2}`)
|
||||
|
||||
if response.Code != 409 {
|
||||
t.Errorf("expected 409, got %d %s", response.Code, response.text())
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("PATCH site_id on a single-version story applies the move rules", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "fr", StoryID: 8})
|
||||
w.put(article.Article{ID: 21, Title: "Alone es", CreatedBy: storyWriter1, Slug: "alone-es", Lang: "es", StoryID: 21})
|
||||
w.put(article.Article{ID: 22, Title: "Alone unassigned", CreatedBy: storyWriter1, Slug: "alone-unassigned", Lang: "", StoryID: 22})
|
||||
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`).expect(t, 200, "")
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/21", `{"site_id":2}`).expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/22", `{"site_id":2}`).expect(t, 200, "")
|
||||
|
||||
if w.stored(20).SiteID != 2 || w.stored(21).SiteID != 1 || w.stored(22).SiteID != 2 {
|
||||
t.Errorf("unexpected sites %d %d %d", w.stored(20).SiteID, w.stored(21).SiteID, w.stored(22).SiteID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Activity events carry the language", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30})
|
||||
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "")
|
||||
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
|
||||
w.send(storyOwner, http.MethodPatch, "/v1/article/30/publish", "").expect(t, 200, "")
|
||||
|
||||
for _, action := range []string{audit.ActionArticlePublish, audit.ActionArticleUnpublish, audit.ActionArticleEdit, audit.ActionArticleDelete} {
|
||||
events := w.eventsOf(action)
|
||||
if len(events) == 0 || events[0].Params[audit.ParamLang] != "fr" {
|
||||
t.Errorf("%s must carry lang fr: %+v", action, events)
|
||||
}
|
||||
}
|
||||
if publishes := w.eventsOf(audit.ActionArticlePublish); len(publishes) != 2 {
|
||||
t.Fatalf("expected two publish events, got %d", len(publishes))
|
||||
} else if _, present := publishes[1].Params[audit.ParamLang]; present {
|
||||
t.Errorf("an unassigned article carries no lang")
|
||||
}
|
||||
|
||||
w = newStoryWorld(t)
|
||||
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "")
|
||||
moves := w.eventsOf(audit.ActionArticleEdit)
|
||||
if len(moves) != 4 {
|
||||
t.Fatalf("one edit event per moved version expected, got %d", len(moves))
|
||||
}
|
||||
for _, event := range moves {
|
||||
if event.Params[audit.ParamLang] == nil || len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldSite || *event.Changes[0].After != "Atelier" {
|
||||
t.Errorf("unexpected move event %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
w = newStoryWorld(t)
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
|
||||
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
||||
refusals := w.eventsOf(audit.ActionArticleEdit)
|
||||
if len(refusals) != 1 || refusals[0].Outcome != audit.OutcomeFailed || refusals[0].FailureReason == nil {
|
||||
t.Fatalf("one failed event expected: %+v", refusals)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
writeCallbackName = "test:fail-third-move"
|
||||
writeInjectedFailure = "injected move failure"
|
||||
writeRaceWriters = 6
|
||||
|
||||
concurrentEditCallbackName = "test:concurrent-title-edit"
|
||||
)
|
||||
|
||||
type storyDatabase struct {
|
||||
db *gorm.DB
|
||||
articles *persistence.ArticleRepository
|
||||
journal entity.Site
|
||||
atelier entity.Site
|
||||
}
|
||||
|
||||
func newStoryDatabase(t *testing.T, open func(*testing.T) *gorm.DB) *storyDatabase {
|
||||
t.Helper()
|
||||
db := open(t)
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
journal, atelier := mustCreateSite(t, sites, "Journal", "journal", false), mustCreateSite(t, sites, "Atelier", "atelier", false)
|
||||
for _, siteID := range []uint32{journal.ID, atelier.ID} {
|
||||
for _, tag := range []string{"en", "fr", "de"} {
|
||||
row := entity.SiteLanguage{SiteID: siteID, Lang: tag, IsDefault: tag == "en", Enabled: true, Confirmed: tag == "en"}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &storyDatabase{db: db, articles: persistence.NewArticleRepository(db), journal: entity.Site{ID: journal.ID, Key: "journal"}, atelier: entity.Site{ID: atelier.ID, Key: "atelier"}}
|
||||
}
|
||||
|
||||
func (s *storyDatabase) add(t *testing.T, siteID uint32, storyID uint32, lang string, slug string) domainArticle.Article {
|
||||
t.Helper()
|
||||
created, err := s.articles.Create(domainArticle.Article{Title: "Article " + slug, Body: "Body", SiteID: siteID, StoryID: storyID, Lang: lang, Slug: slug, SEODismissed: []string{}, CreatedBy: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("create %s/%s: %v", lang, slug, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func (s *storyDatabase) stories(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := s.db.Model(&entity.Story{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *storyDatabase) translations() *useCases.CreateTranslationUseCase {
|
||||
return useCases.NewCreateTranslationUseCase(persistence.NewTransactor(s.db), s.articles, persistence.NewLocalizationRepository(s.db), &fakeBodyRenderer{}, discardRecorder)
|
||||
}
|
||||
|
||||
func editTitleBeforeNextWrite(t *testing.T, s *storyDatabase, id uint32, title string) {
|
||||
t.Helper()
|
||||
armed := true
|
||||
err := s.db.Callback().Update().Before("gorm:update").Register(concurrentEditCallbackName, func(tx *gorm.DB) {
|
||||
if !armed || tx.Statement.Table != "articles" {
|
||||
return
|
||||
}
|
||||
armed = false
|
||||
if _, err := s.articles.Update(id, domainArticle.Update{Title: &title}); err != nil {
|
||||
_ = tx.AddError(err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.db.Callback().Update().Remove(concurrentEditCallbackName) })
|
||||
}
|
||||
|
||||
func yes() *bool {
|
||||
value := true
|
||||
return &value
|
||||
}
|
||||
|
||||
func runArticleStoryWritePath(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
t.Run("A second version in the same language is refused, another language is stored", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
first := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
|
||||
_, duplicate := s.articles.Create(domainArticle.Article{Title: "Beta", SiteID: s.journal.ID, StoryID: first.StoryID, Lang: "en", Slug: "beta", SEODismissed: []string{}})
|
||||
other := s.add(t, s.journal.ID, first.StoryID, "fr", "alpha-fr")
|
||||
|
||||
if !errors.Is(duplicate, domainArticle.ErrLanguageTaken) || other.StoryID != first.StoryID {
|
||||
t.Errorf("expected ErrLanguageTaken then a stored fr version, got %v and %+v", duplicate, other)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A deleted language can be created again and still counts as in use", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
for round := 0; round < 3; round++ {
|
||||
french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
|
||||
if err := s.articles.Delete(french.ID); err != nil {
|
||||
t.Fatalf("round %d: delete: %v", round, err)
|
||||
}
|
||||
}
|
||||
|
||||
counts, err := persistence.NewLocalizationRepository(s.db).VersionCounts(s.journal.ID)
|
||||
|
||||
if err != nil || counts["fr"].Trashed != 3 || counts["fr"].Total != 0 {
|
||||
t.Errorf("deleted fr versions must still count as in use: %+v %v", counts["fr"], err)
|
||||
}
|
||||
if _, err := s.articles.Create(domainArticle.Article{Title: "Again", SiteID: s.journal.ID, StoryID: english.StoryID, Lang: "fr", Slug: "alpha-fr", SEODismissed: []string{}}); err != nil {
|
||||
t.Errorf("fr can be created again: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting one version keeps the story, deleting the last removes it", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
|
||||
alone := s.add(t, s.journal.ID, 0, "en", "alone")
|
||||
stories := s.stories(t)
|
||||
|
||||
if err := s.articles.Delete(french.ID); err != nil || s.stories(t) != stories {
|
||||
t.Fatalf("deleting one of two versions keeps the story: %v %d", err, s.stories(t))
|
||||
}
|
||||
if err := s.articles.Delete(english.ID); err != nil || s.stories(t) != stories-1 {
|
||||
t.Fatalf("deleting the last version removes the story: %v %d", err, s.stories(t))
|
||||
}
|
||||
if err := s.articles.Delete(alone.ID); err != nil || s.stories(t) != stories-2 {
|
||||
t.Fatalf("deleting a single version removes its story: %v %d", err, s.stories(t))
|
||||
}
|
||||
if _, err := s.articles.Create(domainArticle.Article{Title: "Orphan", SiteID: s.journal.ID, StoryID: english.StoryID, Lang: "de", Slug: "orphan", SEODismissed: []string{}}); !errors.Is(err, domainArticle.ErrArticleNotFound) {
|
||||
t.Errorf("creating in a removed story is refused: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The free-slug retry gives the next suffixed slug", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
first := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
second := s.add(t, s.journal.ID, 0, "en", "other")
|
||||
caller := ownerCaller(1)
|
||||
|
||||
one, err := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: first.ID, Lang: "fr", Title: "Meme titre", Copy: yes(), Caller: caller})
|
||||
two, errTwo := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: second.ID, Lang: "fr", Title: "Meme titre", Copy: yes(), Caller: caller})
|
||||
|
||||
if err != nil || errTwo != nil || one.Slug != "meme-titre" || two.Slug != "meme-titre-2" {
|
||||
t.Errorf("unexpected slugs %q %q (%v, %v)", one.Slug, two.Slug, err, errTwo)
|
||||
}
|
||||
if one.SourceArticleID == nil || *one.SourceArticleID != first.ID || one.SourceRevision != 1 {
|
||||
t.Errorf("the source must be captured with the copy: %+v", one)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Concurrent translation creates keep exactly one version", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
var created atomic.Int32
|
||||
var refused atomic.Int32
|
||||
jobs := []func(){}
|
||||
for range writeRaceWriters {
|
||||
jobs = append(jobs, func() {
|
||||
_, err := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: english.ID, Lang: "fr", Title: "Couts", Copy: yes(), Caller: ownerCaller(1)})
|
||||
var existing *domainArticle.LanguageExistsError
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domainArticle.ErrLanguageUnavailable), errors.As(err, &existing):
|
||||
refused.Add(1)
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
runTogether(jobs...)
|
||||
|
||||
versions, err := s.articles.ListByStory(english.StoryID)
|
||||
french := 0
|
||||
for _, version := range versions {
|
||||
if version.Lang == "fr" {
|
||||
french++
|
||||
}
|
||||
}
|
||||
if err != nil || created.Load() != 1 || refused.Load() != writeRaceWriters-1 || french != 1 {
|
||||
t.Errorf("created %d, refused %d, fr versions %d (%v)", created.Load(), refused.Load(), french, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The compare and the write of an edit are one atomic step", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
created := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
var succeeded atomic.Int32
|
||||
var conflicted atomic.Int32
|
||||
jobs := []func(){}
|
||||
for range writeRaceWriters {
|
||||
jobs = append(jobs, func() {
|
||||
body := "edited"
|
||||
revision := created.EditRevision
|
||||
_, err := s.articles.Update(created.ID, domainArticle.Update{Body: &body, IfRevision: &revision})
|
||||
var conflict *domainArticle.RevisionConflictError
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded.Add(1)
|
||||
case errors.As(err, &conflict):
|
||||
conflicted.Add(1)
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
runTogether(jobs...)
|
||||
|
||||
stored := storedArticle(t, s.db, created.ID)
|
||||
if succeeded.Load() != 1 || conflicted.Load() != writeRaceWriters-1 || stored.EditRevision != created.EditRevision+1 || stored.ContentRevision != created.ContentRevision+1 {
|
||||
t.Errorf("succeeded %d, conflicted %d, stored %+v", succeeded.Load(), conflicted.Load(), stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A story moves as a whole with a revision bump per version", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
|
||||
s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de")
|
||||
mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder)
|
||||
|
||||
result, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)})
|
||||
|
||||
if err != nil || len(result.ArticleIDs) != 3 || result.Site.ID != s.atelier.ID {
|
||||
t.Fatalf("move: %+v %v", result, err)
|
||||
}
|
||||
versions, _ := s.articles.ListByStory(english.StoryID)
|
||||
for _, version := range versions {
|
||||
if version.SiteID != s.atelier.ID || version.EditRevision != 2 || version.ContentRevision != 1 {
|
||||
t.Errorf("version %d: %+v", version.ID, version)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A unique address violation at the destination rolls the whole move back", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
|
||||
s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de")
|
||||
s.add(t, s.atelier.ID, 0, "de", "alpha-de")
|
||||
mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder)
|
||||
|
||||
_, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)})
|
||||
|
||||
var conflict *domainArticle.SlugConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("expected a slug conflict, got %v", err)
|
||||
}
|
||||
versions, _ := s.articles.ListByStory(english.StoryID)
|
||||
for _, version := range versions {
|
||||
if version.SiteID != s.journal.ID || version.EditRevision != 1 {
|
||||
t.Errorf("version %d must not move: %+v", version.ID, version)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A write failing on the third version leaves every version unchanged", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
|
||||
s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de")
|
||||
updates := 0
|
||||
err := s.db.Callback().Update().Before("gorm:update").Register(writeCallbackName, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table != "articles" {
|
||||
return
|
||||
}
|
||||
updates++
|
||||
if updates == 3 {
|
||||
_ = tx.AddError(errors.New(writeInjectedFailure))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s.db.Callback().Update().Remove(writeCallbackName) }()
|
||||
mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder)
|
||||
|
||||
_, failure := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)})
|
||||
|
||||
if failure == nil {
|
||||
t.Fatal("the injected failure must abort the move")
|
||||
}
|
||||
versions, _ := s.articles.ListByStory(english.StoryID)
|
||||
for _, version := range versions {
|
||||
if version.SiteID != s.journal.ID || version.EditRevision != 1 {
|
||||
t.Errorf("version %d must be rolled back: %+v", version.ID, version)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A publish does not clobber a concurrent title edit", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
created := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
editTitleBeforeNextWrite(t, s, created.ID, "Edited while publishing")
|
||||
|
||||
published, err := s.articles.UpdateIsOnline(created.ID, true)
|
||||
|
||||
stored := storedArticle(t, s.db, created.ID)
|
||||
if err != nil || !published.IsOnline || !stored.IsOnline || stored.Title != "Edited while publishing" {
|
||||
t.Errorf("publish %+v %v, stored %+v", published, err, stored)
|
||||
}
|
||||
if stored.EditRevision != created.EditRevision+1 || stored.ContentRevision != created.ContentRevision+1 {
|
||||
t.Errorf("only the edit raises the revisions: %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A body rewrite does not clobber a concurrent title edit", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
created := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
editTitleBeforeNextWrite(t, s, created.ID, "Edited while rewriting")
|
||||
|
||||
rewritten, err := s.articles.UpdateBody(created.ID, "Rewritten body")
|
||||
|
||||
stored := storedArticle(t, s.db, created.ID)
|
||||
if err != nil || rewritten.Body != "Rewritten body" || stored.Body != "Rewritten body" || stored.Title != "Edited while rewriting" {
|
||||
t.Errorf("rewrite %+v %v, stored %+v", rewritten, err, stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A publish of a missing article is not found", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
|
||||
if _, err := s.articles.UpdateIsOnline(9999, true); !errors.Is(err, domainArticle.ErrArticleNotFound) {
|
||||
t.Errorf("expected not found, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Migration gives existing rows revision 1 and retires soft-deleted rows", func(t *testing.T) {
|
||||
db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2)
|
||||
|
||||
migrateStorage(t, db)
|
||||
|
||||
for _, row := range storedArticles(t, db) {
|
||||
if row.EditRevision != 1 || row.SourceArticleID != nil || (row.ID == 2) != (row.RetiredID == row.ID) {
|
||||
t.Errorf("article %d: %+v", row.ID, row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The retired-id backfill runs once per database", func(t *testing.T) {
|
||||
db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2)
|
||||
migrateStorage(t, db)
|
||||
if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", 2).UpdateColumn("retired_id", 0).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
migrateStorage(t, db)
|
||||
|
||||
if retired := storedArticle(t, db, 2).RetiredID; retired != 0 {
|
||||
t.Errorf("the second start must not run the backfill again, retired_id %d", retired)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Duplicate versions of a language stop the unique index with a descriptive error", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
if err := s.db.Migrator().DropIndex(&entity.Article{}, "idx_articles_story_lang"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.db.Exec("DELETE FROM migration_markers WHERE name = 'article_story_language_retired'").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate := entity.Article{StoryID: english.StoryID, Title: "Duplicate", SiteID: s.journal.ID, Lang: "en", Slug: "alpha-duplicate"}
|
||||
if err := s.db.Create(&duplicate).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := database.Migrate(s.db, migrationPublicURL)
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot create the unique index") || !strings.Contains(err.Error(), "en") {
|
||||
t.Errorf("expected a descriptive refusal, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A language row stays in use while only a deleted version exists", func(t *testing.T) {
|
||||
s := newStoryDatabase(t, open)
|
||||
english := s.add(t, s.journal.ID, 0, "en", "alpha")
|
||||
french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr")
|
||||
if err := s.articles.Delete(french.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := persistence.NewLocalizationRepository(s.db)
|
||||
rows, _ := repository.Rows(s.journal.ID)
|
||||
counts, _ := repository.VersionCounts(s.journal.ID)
|
||||
|
||||
_, err := localization.Plan(rows, counts, localization.Change{Default: "en", Languages: []localization.Entry{{Tag: "en", Enabled: true}, {Tag: "de", Enabled: true}}})
|
||||
|
||||
if !errors.Is(err, localization.ErrLanguageInUse) {
|
||||
t.Errorf("removing fr while a deleted fr version exists must stay refused, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestArticleStoryWritePathSQLite(t *testing.T) {
|
||||
runArticleStoryWritePath(t, openContractSQLite)
|
||||
}
|
||||
|
||||
func TestArticleStoryWritePathMySQL(t *testing.T) {
|
||||
runArticleStoryWritePath(t, openContractMySQL)
|
||||
}
|
||||
|
||||
func TestArticleStoryWritePathPostgres(t *testing.T) {
|
||||
runArticleStoryWritePath(t, openContractPostgres)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -30,6 +31,12 @@ type mockArticleRepo struct {
|
||||
createAlwaysRaces bool
|
||||
afterFindByBody func()
|
||||
updateBodyIDs []uint32
|
||||
moveFailsAt int
|
||||
beforeCreate func()
|
||||
beforeUpdate func()
|
||||
beforeMove func()
|
||||
onGet func(id uint32) error
|
||||
reverseStory bool
|
||||
}
|
||||
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
@@ -210,6 +217,11 @@ func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if m.onGet != nil {
|
||||
if err := m.onGet(id); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
}
|
||||
a, ok := m.articles[id]
|
||||
if !ok {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
@@ -330,6 +342,10 @@ func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32)
|
||||
|
||||
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
m.createCalled = true
|
||||
if hook := m.beforeCreate; hook != nil {
|
||||
m.beforeCreate = nil
|
||||
hook()
|
||||
}
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
@@ -343,6 +359,16 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, 0) {
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
if a.StoryID != 0 {
|
||||
if !m.storyHasVersions(a.StoryID) {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
}
|
||||
if m.storyHasLanguage(a.StoryID, a.Lang) {
|
||||
return article.Article{}, article.ErrLanguageTaken
|
||||
}
|
||||
}
|
||||
a.ContentRevision = 1
|
||||
a.EditRevision = 1
|
||||
m.nextID++
|
||||
a.ID = m.nextID
|
||||
m.articles[a.ID] = a
|
||||
@@ -373,6 +399,70 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) storyHasVersions(storyID uint32) bool {
|
||||
for _, stored := range m.articles {
|
||||
if stored.StoryID == storyID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) storyHasLanguage(storyID uint32, lang string) bool {
|
||||
for _, stored := range m.articles {
|
||||
if stored.StoryID == storyID && stored.Lang == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error) {
|
||||
if err := m.fail.trigger("ListByStory"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions := []article.Article{}
|
||||
for _, id := range m.sortedIds() {
|
||||
if stored := m.articles[id]; stored.StoryID == storyID {
|
||||
versions = append(versions, m.withAuthors(stored))
|
||||
}
|
||||
}
|
||||
if m.reverseStory {
|
||||
slices.Reverse(versions)
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) MoveToSite(ids []uint32, siteID uint32) error {
|
||||
if err := m.fail.trigger("MoveToSite"); err != nil {
|
||||
return err
|
||||
}
|
||||
if hook := m.beforeMove; hook != nil {
|
||||
m.beforeMove = nil
|
||||
hook()
|
||||
}
|
||||
snapshot := maps.Clone(m.articles)
|
||||
for position, id := range ids {
|
||||
moving, ok := m.articles[id]
|
||||
if !ok {
|
||||
m.articles = snapshot
|
||||
return article.ErrArticleNotFound
|
||||
}
|
||||
if m.moveFailsAt == position+1 {
|
||||
m.articles = snapshot
|
||||
return errRepositoryFailure
|
||||
}
|
||||
if m.slugUsedOn(siteID, moving.Lang, moving.Slug, id) {
|
||||
m.articles = snapshot
|
||||
return article.ErrSlugTaken
|
||||
}
|
||||
moving.SiteID = siteID
|
||||
moving.EditRevision++
|
||||
m.articles[id] = moving
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
|
||||
if err := m.fail.trigger("Update"); err != nil {
|
||||
return article.Article{}, err
|
||||
@@ -381,6 +471,18 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
|
||||
if !ok {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
}
|
||||
if hook := m.beforeUpdate; hook != nil {
|
||||
m.beforeUpdate = nil
|
||||
hook()
|
||||
a = m.articles[id]
|
||||
}
|
||||
if update.IfRevision != nil && a.EditRevision != *update.IfRevision {
|
||||
return article.Article{}, &article.RevisionConflictError{Current: a.EditRevision}
|
||||
}
|
||||
before := a
|
||||
if update.Title != nil {
|
||||
a.Title = *update.Title
|
||||
}
|
||||
if update.Body != nil {
|
||||
if err, failing := m.failUpdateBodyFor[id]; failing {
|
||||
return article.Article{}, err
|
||||
@@ -400,9 +502,13 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
applySeoUpdate(&a, update)
|
||||
authorsChanged := update.AuthorIDs != nil && !slices.Equal(m.credits[id], *update.AuthorIDs)
|
||||
if update.AuthorIDs != nil {
|
||||
m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
|
||||
}
|
||||
if before.EditRevision > 0 {
|
||||
a = a.Revised(article.ImpactOf(before, a, authorsChanged))
|
||||
}
|
||||
m.articles[id] = a
|
||||
return m.withAuthors(a), nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
|
||||
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo)),
|
||||
plainTranslation(repo),
|
||||
plainDescribe(repo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const translationsPath = "/v1/article/10/translations"
|
||||
|
||||
func createdTranslation(t *testing.T, w *storyWorld, response storyResponse) article.Article {
|
||||
t.Helper()
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d %q", response.Code, response.text())
|
||||
}
|
||||
return w.stored(response.article(t).ID)
|
||||
}
|
||||
|
||||
func TestArticleTranslationCreateHTTP(t *testing.T) {
|
||||
t.Run("Copy from the source version", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":true}`)
|
||||
|
||||
created := createdTranslation(t, w, response)
|
||||
source := before[10]
|
||||
if created.ID == 0 || created.Lang != "fr" || created.StoryID != storyStory || created.SiteID != storyJournal || created.IsOnline || created.Title != "Couts de renovation" {
|
||||
t.Errorf("unexpected identity: %+v", created)
|
||||
}
|
||||
if created.Body != source.Body || created.Excerpt != source.Excerpt || created.SEOTitle != source.SEOTitle || created.SEODescription != source.SEODescription ||
|
||||
created.SocialTitle != source.SocialTitle || created.SocialDescription != source.SocialDescription || created.CoverImageAlt != source.CoverImageAlt ||
|
||||
*created.CoverImageID != *source.CoverImageID || *created.SocialImageID != *source.SocialImageID {
|
||||
t.Errorf("the content must equal article 10: %+v", created)
|
||||
}
|
||||
if got := w.articles.credits[created.ID]; !reflect.DeepEqual(got, []uint32{1}) {
|
||||
t.Errorf("authors must equal article 10's, got %v", got)
|
||||
}
|
||||
if created.Slug != "couts-de-renovation" || created.CanonicalURL != "" || created.Noindex || len(created.SEODismissed) != 0 {
|
||||
t.Errorf("slug generated, canonical empty, noindex false, dismissed empty expected: %+v", created)
|
||||
}
|
||||
if created.CreatedBy != storyWriter || created.EditRevision != 1 {
|
||||
t.Errorf("created_by must be the caller and revision 1: %+v", created)
|
||||
}
|
||||
if created.SourceArticleID == nil || *created.SourceArticleID != 10 || created.SourceRevision != 4 {
|
||||
t.Errorf("the source must be article 10 at revision 4: %+v", created)
|
||||
}
|
||||
w.expectUnchanged(before, created.ID)
|
||||
fields := response.fields(t)
|
||||
if string(fields["revision"]) != "1" || string(fields["source"]) != `{"article_id":10,"revision":4}` {
|
||||
t.Errorf("revision or source missing from the body: %s", response.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Copy from a chosen source", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Kosten","copy":true,"source_article_id":11}`)
|
||||
|
||||
created := createdTranslation(t, w, response)
|
||||
if created.Body != "<p>Kosten</p>" || created.SourceArticleID == nil || *created.SourceArticleID != 11 || created.SourceRevision != 1 {
|
||||
t.Errorf("the content must be copied from article 11: %+v", created)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Start empty", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
w.authors.authors[2] = withUser(w.authors.authors[2], storyWriter)
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":false}`)
|
||||
|
||||
created := createdTranslation(t, w, response)
|
||||
if created.Body != "" || created.Excerpt != "" || created.SEOTitle != "" || created.SocialTitle != "" || created.CoverImageID != nil || created.SocialImageID != nil {
|
||||
t.Errorf("content must be empty: %+v", created)
|
||||
}
|
||||
if got := w.articles.credits[created.ID]; !reflect.DeepEqual(got, []uint32{2}) {
|
||||
t.Errorf("authors must be the creating user's byline, got %v", got)
|
||||
}
|
||||
if created.SourceArticleID != nil || string(response.fields(t)["source"]) != "null" {
|
||||
t.Errorf("there must be no source: %+v %s", created, response.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Request validation", func(t *testing.T) {
|
||||
long := strings.Repeat("a", 201)
|
||||
cases := []struct {
|
||||
body string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{`{"title":"Couts","copy":true}`, 400, ""},
|
||||
{`{"lang":"fr","copy":true}`, 400, ""},
|
||||
{`{"lang":"fr","title":"ab","copy":true}`, 400, ""},
|
||||
{`{"lang":"fr","title":"` + long + `","copy":true}`, 400, ""},
|
||||
{`{"lang":"fr","title":"Couts"}`, 400, apperr.BodyErrorMessage},
|
||||
{`{"lang":"fr","title":"Couts","copy":false,"source_article_id":10}`, 400, apperr.BodyErrorMessage},
|
||||
{`{"lang":"fr",`, 400, apperr.BodyErrorMessage},
|
||||
{`{"lang":"english","title":"Couts","copy":true}`, 422, apperr.InvalidLanguageErrorMessage},
|
||||
{`{"lang":"xx","title":"Couts","copy":true}`, 422, apperr.UnsupportedLanguageErrorMessage},
|
||||
{`{"lang":"ja-JP","title":"Couts","copy":true}`, 422, apperr.UnsupportedLanguageErrorMessage},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.body, func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, c.body)
|
||||
|
||||
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") || response.text() == "\n" {
|
||||
t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The language tag is normalized", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"FR","title":"Couts","copy":true}`)
|
||||
|
||||
if created := createdTranslation(t, w, response); created.Lang != "fr" {
|
||||
t.Errorf("stored lang %q", created.Lang)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Language not configured on the site", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"pt","title":"Custos","copy":true}`)
|
||||
|
||||
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("One neutral answer when the language cannot be used", func(t *testing.T) {
|
||||
unreadable := func(t *testing.T) storyResponse {
|
||||
w := newTranslationWorld(t)
|
||||
w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory})
|
||||
return w.send(storyRestrict, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
||||
}
|
||||
disabled := func(t *testing.T) storyResponse {
|
||||
w := newTranslationWorld(t)
|
||||
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "de", "ar"}, []string{"fr"})
|
||||
return w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
||||
}
|
||||
raced := func(t *testing.T) storyResponse {
|
||||
w := newTranslationWorld(t)
|
||||
w.articles.fail.failNextWith("Create", article.ErrLanguageTaken)
|
||||
return w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
||||
}
|
||||
situations := map[string]func(*testing.T) storyResponse{"an fr version unreadable to the caller": unreadable, "fr disabled": disabled, "a concurrent request created fr first": raced}
|
||||
var reference storyResponse
|
||||
for name, run := range situations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
response := run(t)
|
||||
|
||||
response.expect(t, 409, apperr.LanguageUnavailableErrorMessage)
|
||||
if response.header(apperr.ExistingArticleHeader) != "" {
|
||||
t.Errorf("X-Existing-Article-Id must be absent")
|
||||
}
|
||||
if reference.ResponseRecorder == nil {
|
||||
reference = response
|
||||
}
|
||||
if response.text() != reference.text() || !reflect.DeepEqual(response.Header(), reference.Header()) {
|
||||
t.Errorf("body and headers must be identical across situations: %v vs %v", response.Header(), reference.Header())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The language already exists and is readable", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
german := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"de","title":"Kosten","copy":true}`)
|
||||
english := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"en","title":"Costs","copy":true}`)
|
||||
|
||||
german.expect(t, 409, apperr.LanguageExistsErrorMessage)
|
||||
english.expect(t, 409, apperr.LanguageExistsErrorMessage)
|
||||
if german.header(apperr.ExistingArticleHeader) != "11" || english.header(apperr.ExistingArticleHeader) != "10" {
|
||||
t.Errorf("unexpected existing ids %q %q", german.header(apperr.ExistingArticleHeader), english.header(apperr.ExistingArticleHeader))
|
||||
}
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("Two concurrent requests for the same language", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
w.articles.beforeCreate = func() {
|
||||
w.putCommitted(article.Article{ID: 90, Title: "Winner", CreatedBy: storyWriter, Slug: "winner", Lang: "fr", StoryID: storyStory})
|
||||
}
|
||||
|
||||
loser := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`)
|
||||
|
||||
loser.expect(t, 409, apperr.LanguageUnavailableErrorMessage)
|
||||
count := 0
|
||||
for _, stored := range w.articles.articles {
|
||||
if stored.StoryID == storyStory && stored.Lang == "fr" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("story 5 must hold exactly one fr version, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The path version and the source must be readable", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
w.put(article.Article{ID: 20, Title: "Other story", CreatedBy: storyWriter1, Slug: "other", Lang: "en", StoryID: 8})
|
||||
cases := []struct {
|
||||
name string
|
||||
user uint32
|
||||
path string
|
||||
body string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"anonymous", storyAnon, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`, 401, ""},
|
||||
{"reader without content.create", storyReader, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`, 403, apperr.MissingPermissionErrorMessage},
|
||||
{"missing article", storyWriter, "/v1/article/9999/translations", `{"lang":"fr","title":"Couts","copy":true}`, 404, apperr.ArticleNotFoundErrorMessage},
|
||||
{"unreadable path version", storyRestrict, "/v1/article/11/translations", `{"lang":"fr","title":"Couts","copy":true}`, 404, apperr.ArticleNotFoundErrorMessage},
|
||||
{"bad id", storyWriter, "/v1/article/abc/translations", `{"lang":"fr","title":"Couts","copy":true}`, 400, apperr.IdUint32ErrorMessage},
|
||||
{"missing source", storyWriter, translationsPath, `{"lang":"pt","title":"Couts","copy":true,"source_article_id":9999}`, 404, apperr.SourceUnavailableErrorMessage},
|
||||
{"unreadable source", storyRestrict, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`, 404, apperr.SourceUnavailableErrorMessage},
|
||||
{"source of another story", storyWriter, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":20}`, 422, apperr.SourceOtherStoryErrorMessage},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(c.user, http.MethodPost, c.path, c.body)
|
||||
|
||||
response.expect(t, c.status, c.message)
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Access to the source is rechecked at submit", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
delete(w.articles.articles, 11)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":11}`)
|
||||
|
||||
response.expect(t, 404, apperr.SourceUnavailableErrorMessage)
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("Unassigned articles cannot be translated yet", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: storyWriter1, Slug: "unassigned", Lang: "", StoryID: 9, IsOnline: true})
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, "/v1/article/30/translations", `{"lang":"fr","title":"Couts","copy":true}`)
|
||||
|
||||
response.expect(t, 409, apperr.SetupRequiredErrorMessage)
|
||||
w.expectUnchanged(before)
|
||||
})
|
||||
|
||||
t.Run("The slug of the new version is free within site and language", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
w.put(article.Article{ID: 40, Title: "Taken", CreatedBy: storyWriter1, Slug: "couts-de-renovation", Lang: "fr", StoryID: 77})
|
||||
|
||||
clash := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts de renovation","copy":true}`))
|
||||
plain := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, "/v1/article/10/translations", `{"lang":"ar","title":"Kosten Renovierung","copy":false}`))
|
||||
|
||||
if clash.Slug != "couts-de-renovation-2" || plain.Slug != "kosten-renovierung" {
|
||||
t.Errorf("unexpected slugs %q and %q", clash.Slug, plain.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Entry point 2 behaves exactly like entry point 1", func(t *testing.T) {
|
||||
first, second := newTranslationWorld(t), newTranslationWorld(t)
|
||||
|
||||
viaPath := first.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"source_article_id":10}`)
|
||||
viaCollection := second.send(storyWriter, http.MethodPost, "/v1/article/", `{"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true}`)
|
||||
|
||||
if viaPath.Code != 201 || viaCollection.Code != 201 || viaPath.text() != viaCollection.text() {
|
||||
t.Fatalf("responses differ: %d %s / %d %s", viaPath.Code, viaPath.text(), viaCollection.Code, viaCollection.text())
|
||||
}
|
||||
if !reflect.DeepEqual(first.articles.articles, second.articles.articles) {
|
||||
t.Errorf("stored data differ")
|
||||
}
|
||||
for _, body := range []string{`{"title":"Couts","lang":"de","story_id":5,"copy":true}`, `{"title":"Couts","lang":"xx","story_id":5,"copy":true}`} {
|
||||
a := newTranslationWorld(t).send(storyWriter, http.MethodPost, "/v1/article/", body)
|
||||
b := newTranslationWorld(t).send(storyWriter, http.MethodPost, translationsPath, strings.Replace(body, `"story_id":5,`, "", 1))
|
||||
if a.Code != b.Code || a.text() != b.text() || !reflect.DeepEqual(a.Header(), b.Header()) {
|
||||
t.Errorf("errors differ for %s: %d %q / %d %q", body, a.Code, a.text(), b.Code, b.text())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST /v1/article/ is unchanged when it is not a translation", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
status int
|
||||
message string
|
||||
lang string
|
||||
}{
|
||||
{`{"title":"Plain","body":"x"}`, 201, "", "en"},
|
||||
{`{"title":"Neuf","lang":"fr"}`, 201, "", "fr"},
|
||||
{`{"title":"Neuf","lang":"pt"}`, 422, apperr.LanguageNotEnabledErrorMessage, ""},
|
||||
{`{"title":"Neuf","lang":"es"}`, 409, apperr.LanguageUnavailableErrorMessage, ""},
|
||||
{`{"title":"Neuf","story_id":5}`, 400, apperr.BodyErrorMessage, ""},
|
||||
{`{"title":"Neuf","lang":"fr","story_id":999}`, 404, apperr.ArticleNotFoundErrorMessage, ""},
|
||||
{`{"title":"Neuf","copy":true}`, 400, apperr.BodyErrorMessage, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.body, func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
before := w.snapshot()
|
||||
|
||||
response := w.send(storyWriter, http.MethodPost, "/v1/article/", c.body)
|
||||
|
||||
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 c.status != http.StatusCreated {
|
||||
w.expectUnchanged(before)
|
||||
return
|
||||
}
|
||||
created := w.stored(response.article(t).ID)
|
||||
if created.Lang != c.lang || created.StoryID == storyStory {
|
||||
t.Errorf("a new story in %q expected, got %+v", c.lang, created)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PATCH still ignores lang, story_id and source fields", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
|
||||
response := w.send(storyWriter, http.MethodPatch, "/v1/article/10", `{"body":"x","lang":"fr","story_id":9,"source_article_id":11,"copy":true}`)
|
||||
|
||||
response.expect(t, 200, "")
|
||||
if stored := w.stored(10); stored.Lang != "en" || stored.StoryID != storyStory || stored.SourceArticleID != nil {
|
||||
t.Errorf("lang, story and source must be unchanged: %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Activity records the creation", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
|
||||
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`))
|
||||
w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"de","title":"Kosten","copy":true}`)
|
||||
w.send(storyRestrict, http.MethodPost, translationsPath, `{"lang":"fr","title":"Autre","copy":true}`)
|
||||
|
||||
events := w.eventsOf(audit.ActionArticleCreate)
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("expected one success and two failures, got %d", len(events))
|
||||
}
|
||||
success := events[0]
|
||||
if success.Outcome != audit.OutcomeSuccess || success.Target.ID != idText(created.ID) || success.Params[audit.ParamLang] != "fr" || success.Params[audit.ParamStoryID] != storyStory || success.Params[audit.ParamSourceArticleID] != uint32(10) {
|
||||
t.Errorf("unexpected success event %+v for article %d", success, created.ID)
|
||||
}
|
||||
for _, failure := range events[1:] {
|
||||
if failure.Outcome != audit.OutcomeFailed || failure.FailureReason == nil || *failure.FailureReason == "" {
|
||||
t.Errorf("a failed event with a plain reason expected: %+v", failure)
|
||||
}
|
||||
if strings.Contains(*failure.FailureReason, "11") || strings.Contains(*failure.FailureReason, "Kosten") {
|
||||
t.Errorf("the reason must not name a version: %q", *failure.FailureReason)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A new version does not appear publicly", func(t *testing.T) {
|
||||
w := newTranslationWorld(t)
|
||||
created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true}`))
|
||||
|
||||
response := w.send(storyAnon, http.MethodGet, "/v1/article/"+idText(created.ID), "")
|
||||
|
||||
response.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func idText(id uint32) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func withUser(found author.Author, userID uint32) author.Author {
|
||||
found.UserID = &userID
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
)
|
||||
|
||||
func plainStoryMover(articles article.Repository) useCases.StoryMover {
|
||||
return useCases.NewMoveStoryUseCase(passthroughCreateTransactor(articles), articles, discardRecorder)
|
||||
}
|
||||
|
||||
func plainTranslation(articles *mockArticleRepo) *useCases.CreateTranslationUseCase {
|
||||
return useCases.NewCreateTranslationUseCase(passthroughCreateTransactor(articles), articles, newFakeLocalizationStore(articles), &fakeBodyRenderer{}, discardRecorder)
|
||||
}
|
||||
|
||||
func plainDescribe(articles *mockArticleRepo) *useCases.DescribeArticleUseCase {
|
||||
return useCases.NewDescribeArticleUseCase(articles, newFakeLocalizationStore(articles))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -24,7 +24,9 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -360,7 +360,9 @@ func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
|
||||
c.CreateTranslationUseCase = plainTranslation(p.articles)
|
||||
c.DescribeArticleUseCase = plainDescribe(p.articles)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
|
||||
c.MergeMediaUseCase = mergeMedia
|
||||
|
||||
@@ -28,7 +28,9 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -32,8 +32,12 @@ func TestDriverLockContention(t *testing.T) {
|
||||
{"sqlite plain error", database.DriverSQLite, errors.New("database is locked"), false},
|
||||
{"mysql nowait", database.DriverMySQL, &mysqldriver.MySQLError{Number: 3572}, true},
|
||||
{"mysql lock wait timeout", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1205}), true},
|
||||
{"mysql deadlock", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1213}), true},
|
||||
{"mariadb record changed since last read", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1020}, true},
|
||||
{"mysql duplicate", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1062}, false},
|
||||
{"postgres lock not available", database.DriverPostgres, &pgconn.PgError{Code: "55P03"}, true},
|
||||
{"postgres deadlock detected", database.DriverPostgres, &pgconn.PgError{Code: "40P01"}, true},
|
||||
{"postgres serialization failure", database.DriverPostgres, fmt.Errorf("wrapped: %w", &pgconn.PgError{Code: "40001"}), true},
|
||||
{"postgres unique violation", database.DriverPostgres, &pgconn.PgError{Code: "23505"}, false},
|
||||
{"unknown driver", "oracle", codedSQLiteError{5}, false},
|
||||
{"nil error", database.DriverSQLite, nil, false},
|
||||
|
||||
@@ -70,7 +70,9 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images, newFakeLocalizationStore(env.articles), plainStoryMover(env.articles)),
|
||||
CreateTranslationUseCase: plainTranslation(env.articles),
|
||||
DescribeArticleUseCase: plainDescribe(env.articles),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
|
||||
@@ -130,7 +130,9 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
|
||||
@@ -46,7 +46,9 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -28,7 +28,9 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -234,11 +234,11 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
id uint32
|
||||
allowed bool
|
||||
}{
|
||||
{"update own", updateCall, "content.update", callerWith(21, "content.update=own"), 10, true},
|
||||
{"update foreign", updateCall, "content.update", callerWith(21, "content.update=own"), 11, false},
|
||||
{"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own"), 14, false},
|
||||
{"update all", updateCall, "content.update", callerWith(9, "content.update=all"), 14, true},
|
||||
{"update refused by a typed none row", updateCall, "content.update", callerWith(25, "content.update=all", "content.update:article=none"), 10, false},
|
||||
{"update own", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 10, true},
|
||||
{"update foreign", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 11, false},
|
||||
{"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own", "content.read=all"), 14, false},
|
||||
{"update all", updateCall, "content.update", callerWith(9, "content.update=all", "content.read=all"), 14, true},
|
||||
{"update refused by a typed none row", updateCall, "content.update", callerWith(25, "content.update=all", "content.read=all", "content.update:article=none"), 10, false},
|
||||
{"publish own", publishCall, "content.publish", callerWith(24, "content.publish=own"), 17, true},
|
||||
{"publish foreign", publishCall, "content.publish", callerWith(24, "content.publish=own"), 10, false},
|
||||
{"publish is not implied by update", publishCall, "content.publish", callerWith(9, "content.update=all"), 10, false},
|
||||
@@ -267,15 +267,29 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Update of an article the caller cannot read is not found, after the permission check", func(t *testing.T) {
|
||||
repo, sites, authors, images := newRepos()
|
||||
unreadable := repo.articles[10]
|
||||
unreadable.IsOnline = false
|
||||
repo.articles[10] = unreadable
|
||||
snapshot := repo.articles[10]
|
||||
err := updateCall(repo, sites, authors, images, callerWith(21, "content.update=own"), 10)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) || !sameArticle(repo.articles[10], snapshot) {
|
||||
t.Errorf("expected not found and no change, got %v", err)
|
||||
}
|
||||
err = updateCall(repo, sites, authors, images, callerWith(21, "content.read=all"), 10)
|
||||
requireMissingKey(t, err, "content.update")
|
||||
})
|
||||
|
||||
t.Run("A refused update performs no site, author or image lookup and records nothing", func(t *testing.T) {
|
||||
repo, sites, authors, images := newRepos()
|
||||
sites.fail.failNext("Get")
|
||||
authors.fail.failNext("Get")
|
||||
images.fail.failNext("Get")
|
||||
recorder := &contentScopeRecorder{}
|
||||
update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, recorder, sites, authors, images)
|
||||
update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, recorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
missingSite := uint32(5)
|
||||
_, err := update.Update(testCtx, 11, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{SiteID: &missingSite, AuthorIDs: &[]uint32{7}})
|
||||
_, err := update.Update(testCtx, 11, callerWith(21, "content.update=own", "content.read=all"), useCases.UpdateArticleCommand{SiteID: &missingSite, AuthorIDs: &[]uint32{7}})
|
||||
requireMissingKey(t, err, "content.update")
|
||||
if sites.fail.method != "Get" || authors.fail.method != "Get" || len(recorder.events) != 0 {
|
||||
t.Errorf("a refused update must not look anything up or record an event")
|
||||
@@ -284,9 +298,9 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
|
||||
t.Run("An unknown id is not found even under own scope", func(t *testing.T) {
|
||||
repo, sites, authors, images := newRepos()
|
||||
update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images)
|
||||
update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
body := "x"
|
||||
_, err := update.Update(testCtx, 999, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{Body: &body})
|
||||
_, err := update.Update(testCtx, 999, callerWith(21, "content.update=own", "content.read=all"), useCases.UpdateArticleCommand{Body: &body})
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("expected not found, got %v", err)
|
||||
}
|
||||
@@ -344,12 +358,12 @@ func sortIDs(ids []uint32) {
|
||||
|
||||
func updateCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error {
|
||||
body := "new"
|
||||
_, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).Update(testCtx, id, caller, useCases.UpdateArticleCommand{Body: &body})
|
||||
_, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo)).Update(testCtx, id, caller, useCases.UpdateArticleCommand{Body: &body})
|
||||
return err
|
||||
}
|
||||
|
||||
func publishCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error {
|
||||
_, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).UpdateIsOnline(testCtx, id, caller, true, article.BodyFormatHTML)
|
||||
_, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images, newFakeLocalizationStore(repo), plainStoryMover(repo)).UpdateIsOnline(testCtx, id, caller, true, article.BodyFormatHTML)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
CreateTranslationUseCase: plainTranslation(articleRepo),
|
||||
DescribeArticleUseCase: plainDescribe(articleRepo),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
|
||||
migrateSeo(t, db)
|
||||
|
||||
found, err := persistence.NewArticleRepository(db).Get(1)
|
||||
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1}
|
||||
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1, EditRevision: 1}
|
||||
if err != nil || !reflect.DeepEqual(found, want) {
|
||||
t.Errorf("found %+v, %v", found, err)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
|
||||
c.CreateTranslationUseCase = plainTranslation(p.articles)
|
||||
c.DescribeArticleUseCase = plainDescribe(p.articles)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
|
||||
@@ -79,7 +81,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
}
|
||||
|
||||
func (e *seoEnv) seedArticle(id uint32, title string, siteID uint32, slug string) article.Article {
|
||||
seeded := article.Article{ID: id, Title: title, Body: "Body of " + title, SiteID: siteID, Slug: slug, SEODismissed: []string{}}
|
||||
seeded := article.Article{ID: id, Title: title, Body: "Body of " + title, SiteID: siteID, Slug: slug, StoryID: id, SEODismissed: []string{}}
|
||||
e.articles.articles[id] = seeded
|
||||
if id > e.articles.nextID {
|
||||
e.articles.nextID = id
|
||||
|
||||
@@ -2,6 +2,7 @@ package test
|
||||
|
||||
import (
|
||||
articlehandler "RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"math"
|
||||
@@ -59,12 +60,11 @@ func TestSeoOutputListPagingNeverPanics(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSeoOutputContractParity(t *testing.T) {
|
||||
oneof := "oneof=" + strings.Join(article.DismissCodes(), " ")
|
||||
max := "max=" + strconv.Itoa(article.MaxCanonicalURLRunes)
|
||||
for _, subject := range []reflect.Type{reflect.TypeOf(articlehandler.CheckArticleSEOBody{}), reflect.TypeOf(articlehandler.UpdateArticleBody{})} {
|
||||
dismissed, _ := subject.FieldByName("SEODismissed")
|
||||
if !strings.HasSuffix(dismissed.Tag.Get("validate"), oneof) {
|
||||
t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), dismissed.Tag.Get("validate"), oneof)
|
||||
if !strings.HasSuffix(dismissed.Tag.Get("validate"), validator.SEODismissedTag) {
|
||||
t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), dismissed.Tag.Get("validate"), validator.SEODismissedTag)
|
||||
}
|
||||
canonical, _ := subject.FieldByName("CanonicalURL")
|
||||
if !strings.Contains(canonical.Tag.Get("validate"), max) {
|
||||
@@ -82,3 +82,17 @@ func TestSeoOutputContractParity(t *testing.T) {
|
||||
t.Errorf("seo=bogus: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeoDismissedValidatorFollowsTheDomainCodes(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
for _, code := range article.DismissCodes() {
|
||||
request := articlehandler.UpdateArticleBody{SEODismissed: &[]string{code}}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
t.Errorf("%s must be accepted: %v", code, err)
|
||||
}
|
||||
}
|
||||
request := articlehandler.UpdateArticleBody{SEODismissed: &[]string{"bogus"}}
|
||||
if err := validator.Validate.Struct(request); err == nil {
|
||||
t.Error("an unknown code must be refused")
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func TestArticlesBelongToASite(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("An empty or unknown update is refused", func(t *testing.T) {
|
||||
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"title": "New title"}`, `{"body": null}`} {
|
||||
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"body": null}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestSiteUseCaseRules(t *testing.T) {
|
||||
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
|
||||
sites := newMockSiteRepo(repo)
|
||||
sites.seed(1, "Main", "main", true)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
missing := uint32(9)
|
||||
|
||||
_, err := uc.Update(testCtx, 1, ownerCaller(1), useCases.UpdateArticleCommand{SiteID: &missing})
|
||||
|
||||
@@ -327,7 +327,9 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors())
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
|
||||
c.CreateTranslationUseCase = plainTranslation(p.articles)
|
||||
c.DescribeArticleUseCase = plainDescribe(p.articles)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles)
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
|
||||
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
|
||||
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision"}
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision", "edit_revision", "source_article_id", "source_revision", "retired_id"}
|
||||
|
||||
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
@@ -27,7 +27,9 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const storyStudio uint32 = 3
|
||||
|
||||
type shiftingStoryRepo struct {
|
||||
*mockArticleRepo
|
||||
listings int
|
||||
shiftFrom int
|
||||
shift func()
|
||||
}
|
||||
|
||||
func (r *shiftingStoryRepo) ListByStory(storyID uint32) ([]article.Article, error) {
|
||||
r.listings++
|
||||
if r.listings >= r.shiftFrom {
|
||||
r.shift()
|
||||
}
|
||||
return r.mockArticleRepo.ListByStory(storyID)
|
||||
}
|
||||
|
||||
type repositoriesTransactor struct {
|
||||
repositories transaction.Repositories
|
||||
}
|
||||
|
||||
func (r repositoriesTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
|
||||
return work(r.repositories)
|
||||
}
|
||||
|
||||
func TestMoveStoryRelocksUntilTheSiteSetIsStable(t *testing.T) {
|
||||
t.Run("A version that appears on another site after the first lock gets that site locked too", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
w.sites.seed(storyStudio, "Studio", "studio", false)
|
||||
listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 2}
|
||||
listing.shift = func() {
|
||||
shifted := w.articles.articles[13]
|
||||
shifted.SiteID = storyStudio
|
||||
w.articles.articles[13] = shifted
|
||||
}
|
||||
mover := useCases.NewMoveStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites, Authors: w.authors, Localization: w.rows}}, listing, discardRecorder)
|
||||
|
||||
_, _ = mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: storyStory, SiteKey: "atelier", Caller: ownerCaller(storyOwner)})
|
||||
|
||||
if !slices.Contains(w.sites.lockedSites, storyStudio) {
|
||||
t.Errorf("site %d must be locked, locked %v", storyStudio, w.sites.lockedSites)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A set that never settles is refused as lock contention", func(t *testing.T) {
|
||||
w := newStoryWorld(t)
|
||||
listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 2}
|
||||
listing.shift = func() {
|
||||
shifted := w.articles.articles[13]
|
||||
shifted.SiteID = 100 + uint32(listing.listings)
|
||||
w.articles.articles[13] = shifted
|
||||
}
|
||||
mover := useCases.NewMoveStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites, Authors: w.authors, Localization: w.rows}}, listing, discardRecorder)
|
||||
|
||||
_, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: storyStory, SiteKey: "atelier", Caller: ownerCaller(storyOwner)})
|
||||
|
||||
if !errors.Is(err, transaction.ErrLockContention) {
|
||||
t.Errorf("expected lock contention, got %v", err)
|
||||
}
|
||||
if listing.listings != 7 {
|
||||
t.Errorf("expected two listings plus five relock rounds, got %d listings", listing.listings)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
imageDomain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"RenewCMS/api/pkg/validator"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
storyJournal uint32 = 1
|
||||
storyAtelier uint32 = 2
|
||||
storyWriter1 uint32 = 1
|
||||
storyOwner uint32 = 50
|
||||
storyReader uint32 = 51
|
||||
storyWriter uint32 = 52
|
||||
storyRestrict uint32 = 53
|
||||
storyUpdater uint32 = 54
|
||||
storyPublish uint32 = 55
|
||||
storyNobody uint32 = 56
|
||||
storyPartial uint32 = 57
|
||||
storyAnon uint32 = 0
|
||||
storyStory uint32 = 5
|
||||
)
|
||||
|
||||
type storyWorld struct {
|
||||
t *testing.T
|
||||
token *authtoken.Auth
|
||||
users *mockUserRepo
|
||||
roles *mockRoleRepo
|
||||
articles *mockArticleRepo
|
||||
sites *mockSiteRepo
|
||||
rows *fakeLocalizationStore
|
||||
authors *mockAuthorRepo
|
||||
images *mockImageRepo
|
||||
recorder *contentScopeRecorder
|
||||
transactor *storyTransactor
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
type storyTransactor struct {
|
||||
world *storyWorld
|
||||
runs int
|
||||
external map[uint32]article.Article
|
||||
}
|
||||
|
||||
var _ transaction.Transactor = &storyTransactor{}
|
||||
|
||||
func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
|
||||
s.runs++
|
||||
w := s.world
|
||||
articles := maps.Clone(w.articles.articles)
|
||||
credits := maps.Clone(w.articles.credits)
|
||||
rows, _ := w.rows.snapshot()
|
||||
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: map[uint32]string{storyJournal: "en", storyAtelier: "en"}}})
|
||||
if err != nil {
|
||||
w.articles.articles = articles
|
||||
w.articles.credits = credits
|
||||
w.rows.rows = rows
|
||||
maps.Copy(w.articles.articles, s.external)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var storyEpoch = time.Date(2026, time.October, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
func storyRows(defaultTag string, enabled []string, disabled []string) []localization.Row {
|
||||
rows := []localization.Row{}
|
||||
for _, tag := range enabled {
|
||||
rows = append(rows, localization.Row{Tag: tag, IsDefault: tag == defaultTag, Enabled: true, Confirmed: tag == defaultTag})
|
||||
}
|
||||
for _, tag := range disabled {
|
||||
rows = append(rows, localization.Row{Tag: tag, Enabled: false})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func newStoryWorld(t *testing.T) *storyWorld {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
w := &storyWorld{t: t, token: authtoken.New([]byte("test-secret")), users: newMockUserRepo(), roles: newSeededMockRoleRepo(), articles: newMockArticleRepo(), recorder: &contentScopeRecorder{}}
|
||||
w.sites = newMockSiteRepo(w.articles)
|
||||
w.sites.seed(storyJournal, "Journal", "journal", true)
|
||||
w.sites.seed(storyAtelier, "Atelier", "atelier", false)
|
||||
w.authors = newMockAuthorRepo(w.articles)
|
||||
w.images = newMockImageRepo()
|
||||
for _, id := range []uint32{3, 4, 7, 8} {
|
||||
w.images.images[id] = imageDomain.Image{ID: id, Filename: "image" + strconv.FormatUint(uint64(id), 10) + ".png"}
|
||||
}
|
||||
w.authors.seed(1, "Camille", "camille")
|
||||
w.authors.seed(2, "Noor", "noor")
|
||||
w.rows = newFakeLocalizationStore(w.articles)
|
||||
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar"}, []string{"es"})
|
||||
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de", "ar"}, nil)
|
||||
w.transactor = &storyTransactor{world: w}
|
||||
w.seedUsers()
|
||||
w.seedStory()
|
||||
w.wire()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *storyWorld) seedUsers() {
|
||||
w.addUser(storyOwner, ownerRoleID, "owner")
|
||||
custom := map[uint32][]string{
|
||||
storyWriter1: {"content.create=all", "content.read=own", "content.update=own", "content.publish=own", "content.delete=own"},
|
||||
storyReader: {"content.read=all"},
|
||||
storyWriter: {"content.create=all", "content.update=all", "content.read=all"},
|
||||
storyRestrict: {"content.create=all", "content.read=own"},
|
||||
storyUpdater: {"content.update=all", "content.read=all"},
|
||||
storyPublish: {"content.publish=all", "content.read=all"},
|
||||
storyNobody: {"schema.view=all"},
|
||||
storyPartial: {"content.update=all", "content.read=own"},
|
||||
}
|
||||
for id, grants := range custom {
|
||||
created, _ := w.roles.Create(role.Role{Name: "story-role-" + strconv.FormatUint(uint64(id), 10), Version: 1, Grants: grantRows(grants...)})
|
||||
w.addUser(id, created.ID, "user"+strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *storyWorld) addUser(id uint32, roleID uint32, name string) {
|
||||
w.users.users[id] = user.User{ID: id, Username: name, Email: name + "@example.com", IsVerified: true, RoleID: roleID}
|
||||
}
|
||||
|
||||
func (w *storyWorld) put(a article.Article) article.Article {
|
||||
if a.SiteID == 0 {
|
||||
a.SiteID = storyJournal
|
||||
}
|
||||
if a.EditRevision == 0 {
|
||||
a.EditRevision = 1
|
||||
}
|
||||
if a.ContentRevision == 0 {
|
||||
a.ContentRevision = 1
|
||||
}
|
||||
if a.SEODismissed == nil {
|
||||
a.SEODismissed = []string{}
|
||||
}
|
||||
w.articles.articles[a.ID] = a
|
||||
if a.ID > w.articles.nextID {
|
||||
w.articles.nextID = a.ID
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (w *storyWorld) putCommitted(a article.Article) {
|
||||
w.put(a)
|
||||
if w.transactor.external == nil {
|
||||
w.transactor.external = map[uint32]article.Article{}
|
||||
}
|
||||
w.transactor.external[a.ID] = w.stored(a.ID)
|
||||
}
|
||||
|
||||
func (w *storyWorld) seedStory() {
|
||||
cover, social := uint32(3), uint32(4)
|
||||
w.put(article.Article{ID: 10, Title: "Renovation costs", Body: "<p>Costs</p>", IsOnline: true, CreatedBy: storyWriter1, Slug: "renovation-costs", Lang: "en", StoryID: storyStory,
|
||||
Excerpt: "Excerpt", SEOTitle: "SEO title", SEODescription: "SEO description", SocialTitle: "Social title", SocialDescription: "Social description",
|
||||
CoverImageID: &cover, CoverImageAlt: "Cover alt", SocialImageID: &social, CanonicalURL: "https://journal.example/renovation", Noindex: true, SEODismissed: []string{"short"},
|
||||
EditRevision: 3, ContentRevision: 4, CreatedAt: storyEpoch})
|
||||
w.articles.credit(10, 1)
|
||||
w.put(article.Article{ID: 12, Title: "Couts", Body: "<p>Couts</p>", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(time.Minute)})
|
||||
w.put(article.Article{ID: 11, Title: "Kosten", Body: "<p>Kosten</p>", CreatedBy: 2, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * time.Minute)})
|
||||
w.put(article.Article{ID: 13, Title: "Takalif", Body: "<p>Takalif</p>", IsOnline: true, CreatedBy: 3, Slug: "takalif", Lang: "ar", StoryID: storyStory, CreatedAt: storyEpoch.Add(3 * time.Minute)})
|
||||
}
|
||||
|
||||
func (w *storyWorld) wire() {
|
||||
renderer := &fakeBodyRenderer{}
|
||||
keys := newMockAPIKeyRepo()
|
||||
moveStory := useCases.NewMoveStoryUseCase(w.transactor, w.articles, w.recorder)
|
||||
container := &api.UseCases{
|
||||
Token: w.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(w.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(w.roles),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(keys, nil, w.users, time.Now),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(w.transactor, renderer, w.recorder, w.sites, w.authors),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(w.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(w.articles, renderer, w.sites),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(w.articles, w.recorder),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(w.articles, renderer, w.recorder, w.sites, w.authors, w.images, w.rows, moveStory),
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder),
|
||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(w.articles, w.rows),
|
||||
MoveStoryUseCase: moveStory,
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Use(authtoken.Verifier(w.token))
|
||||
router.Mount("/v1/article", api.NewArticleRouter(container, nil))
|
||||
router.Mount("/v1/story", api.NewStoryRouter(container, nil))
|
||||
w.router = router
|
||||
}
|
||||
|
||||
type storyResponse struct {
|
||||
*httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
func (w *storyWorld) send(userID uint32, method string, path string, body string) storyResponse {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = activityPeer
|
||||
if userID != storyAnon {
|
||||
tokenString, _ := w.token.Encode(map[string]any{"user_id": userID, "token_version": 0})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
w.router.ServeHTTP(rec, req)
|
||||
return storyResponse{rec}
|
||||
}
|
||||
|
||||
func (r storyResponse) text() string {
|
||||
return r.Body.String()
|
||||
}
|
||||
|
||||
func (r storyResponse) fields(t *testing.T) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
return decodeArticleFields(t, r.Body.Bytes())
|
||||
}
|
||||
|
||||
func (r storyResponse) article(t *testing.T) article.Article {
|
||||
t.Helper()
|
||||
var decoded article.Article
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("invalid article json %s: %v", r.Body, err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func (r storyResponse) expect(t *testing.T, status int, body string) {
|
||||
t.Helper()
|
||||
if r.Code != status || (body != "" && r.text() != body+"\n") {
|
||||
t.Fatalf("expected %d %q, got %d %q", status, body, r.Code, r.text())
|
||||
}
|
||||
}
|
||||
|
||||
func (r storyResponse) header(name string) string {
|
||||
return r.Header().Get(name)
|
||||
}
|
||||
|
||||
func (w *storyWorld) stored(id uint32) article.Article {
|
||||
return w.articles.articles[id]
|
||||
}
|
||||
|
||||
func (w *storyWorld) snapshot() map[uint32]article.Article {
|
||||
return maps.Clone(w.articles.articles)
|
||||
}
|
||||
|
||||
func (w *storyWorld) expectUnchanged(before map[uint32]article.Article, added ...uint32) {
|
||||
w.t.Helper()
|
||||
if len(before)+len(added) != len(w.articles.articles) {
|
||||
w.t.Fatalf("the article count changed from %d to %d", len(before), len(w.articles.articles))
|
||||
}
|
||||
for id, stored := range before {
|
||||
if current := w.articles.articles[id]; !articlesEqual(stored, current) {
|
||||
w.t.Fatalf("article %d changed: %+v -> %+v", id, stored, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func articlesEqual(left article.Article, right article.Article) bool {
|
||||
left.SEODismissed, right.SEODismissed = slices.Clone(left.SEODismissed), slices.Clone(right.SEODismissed)
|
||||
leftJSON, _ := json.Marshal(left)
|
||||
rightJSON, _ := json.Marshal(right)
|
||||
return string(leftJSON) == string(rightJSON) && left.EditRevision == right.EditRevision && left.ContentRevision == right.ContentRevision && left.UpdatedAt.Equal(right.UpdatedAt) && left.StoryID == right.StoryID && left.Lang == right.Lang
|
||||
}
|
||||
|
||||
func (w *storyWorld) eventsOf(action string) []audit.Event {
|
||||
events := []audit.Event{}
|
||||
for _, event := range w.recorder.events {
|
||||
if event.Action == action {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func newTranslationWorld(t *testing.T) *storyWorld {
|
||||
t.Helper()
|
||||
w := newStoryWorld(t)
|
||||
delete(w.articles.articles, 12)
|
||||
delete(w.articles.articles, 13)
|
||||
return w
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateBody_id1", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
result, err := uc.UpdateBody(testCtx, 1, ownerCaller(1), "New body", article.BodyFormatHTML)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -256,7 +256,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateBody_id9", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
_, err := uc.UpdateBody(testCtx, 9, ownerCaller(1), "New body", article.BodyFormatHTML)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||
@@ -265,7 +265,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
result, err := uc.UpdateIsOnline(testCtx, 1, ownerCaller(1), true, article.BodyFormatHTML)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -277,7 +277,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo))
|
||||
_, err := uc.UpdateIsOnline(testCtx, 9, ownerCaller(1), true, article.BodyFormatHTML)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||
|
||||
@@ -25,7 +25,9 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -217,7 +217,9 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
plainDescribe(articleRepo),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
Reference in new issue
Block a user