Files
RenewCMS/test/authors_backend_edit_http_test.go

563 lines
20 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
)
func (e *authorEnv) seedEditable() {
e.authors.seed(2, "Camille Roche", "camille-roche")
e.authors.update(2, func(a *author.Author) {
a.Bio = "Writer."
a.Website = stringPointer("https://camille.example")
a.AvatarID = idPointer(5)
a.SocialLinks = []author.SocialLink{{Type: "x", URL: "https://x.com/c"}, {Type: "github", URL: "https://github.com/c"}}
})
e.images.images[5] = image.Image{ID: 5, Filename: "avatar.png", Path: "/static/uploadedImages/5.png"}
}
func (e *authorEnv) unchanged(t *testing.T, id uint32, before author.Author) {
t.Helper()
if !reflect.DeepEqual(e.authors.authors[id], before) {
t.Errorf("author %d changed: %+v, was %+v", id, e.authors.authors[id], before)
}
}
func TestAuthorsBackendEditing(t *testing.T) {
t.Run("The owner edits any author", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille R.", "slug": "camille-r", "bio": "New.", "website": "https://camille.example/new", "social_links": [{"type": "github", "url": "https://github.com/camille"}]}`)
body := decodeAuthorBody(t, rec)
if rec.Code != http.StatusOK || body["name"] != "Camille R." || body["slug"] != "camille-r" || body["bio"] != "New." || body["website"] != "https://camille.example/new" {
t.Errorf("status %d body %v", rec.Code, body)
}
if links := e.authors.authors[2].SocialLinks; len(links) != 1 || links[0].Type != "github" {
t.Errorf("links = %+v", links)
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
before := e.authors.authors[2]
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille R."}`)
expected := before
expected.Name = "Camille R."
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
e.unchanged(t, 2, expected)
})
t.Run("Nullable fields are cleared", func(t *testing.T) {
cases := []struct {
body string
assert func(a author.Author) bool
}{
{`{"avatar_id": null}`, func(a author.Author) bool { return a.AvatarID == nil }},
{`{"website": null}`, func(a author.Author) bool { return a.Website == nil }},
{`{"website": ""}`, func(a author.Author) bool { return a.Website == nil }},
{`{"social_links": []}`, func(a author.Author) bool { return len(a.SocialLinks) == 0 }},
{`{"bio": ""}`, func(a author.Author) bool { return a.Bio == "" }},
}
for _, c := range cases {
t.Run(c.body, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
rec := e.owner(t, http.MethodPatch, "/v1/author/2", c.body)
if rec.Code != http.StatusOK || !c.assert(e.authors.authors[2]) {
t.Errorf("status %d stored %+v", rec.Code, e.authors.authors[2])
}
})
}
})
t.Run("The avatar is set to an existing image", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
e.authors.update(2, func(a *author.Author) { a.AvatarID = nil })
e.owner(t, http.MethodPatch, "/v1/author/2", `{"avatar_id": 5}`)
if avatar := e.authors.authors[2].AvatarID; avatar == nil || *avatar != 5 {
t.Errorf("avatar = %v", avatar)
}
})
t.Run("Social links are replaced as a whole", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
e.owner(t, http.MethodPatch, "/v1/author/2", `{"social_links": [{"type": "x", "url": "https://x.com/a"}]}`)
if !reflect.DeepEqual(e.authors.authors[2].SocialLinks, []author.SocialLink{{Type: "x", URL: "https://x.com/a"}}) {
t.Errorf("links = %+v", e.authors.authors[2].SocialLinks)
}
})
t.Run("Editing is validated like creation", func(t *testing.T) {
cases := []struct {
name string
body string
status int
token string
}{
{"empty name", `{"name": ""}`, 400, ""},
{"bad slug", `{"slug": "Bad Slug"}`, 400, "invalid_slug"},
{"empty slug", `{"slug": ""}`, 400, "invalid_slug"},
{"long bio", `{"bio": "` + strings.Repeat("a", 281) + `"}`, 422, "bio_too_long"},
{"http website", `{"website": "http://example.com"}`, 422, "invalid_url"},
{"six links", `{"social_links": [` + strings.TrimSuffix(strings.Repeat(`{"type": "x", "url": "https://x.com/a"},`, 6), ",") + `]}`, 422, "too_many_links"},
{"unknown avatar", `{"avatar_id": 999}`, 400, "image_not_found"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
before := e.authors.authors[2]
rec := e.owner(t, http.MethodPatch, "/v1/author/2", c.body)
if rec.Code != c.status || (c.token != "" && bodyText(rec) != c.token) || bodyText(rec) == "" {
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
}
e.unchanged(t, 2, before)
})
}
})
t.Run("Keeping the own slug is not a collision", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
if rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"slug": "camille-roche", "bio": "x"}`); rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("Taking another author's slug is refused", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
e.authors.seed(3, "Zoe", "zoe-lambert")
before := e.authors.authors[2]
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", `{"slug": "zoe-lambert"}`), http.StatusConflict, "author_slug_taken")
e.unchanged(t, 2, before)
})
t.Run("An empty or unknown update is refused", func(t *testing.T) {
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"name": null}`} {
t.Run(body, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
before := e.authors.authors[2]
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", body), http.StatusBadRequest, apperr.BodyErrorMessage)
e.unchanged(t, 2, before)
})
}
})
t.Run("A missing author is not found", func(t *testing.T) {
e := newAuthorEnv(t)
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/999", `{"name": "X"}`), http.StatusNotFound, "author_not_found")
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newAuthorEnv(t)
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/abc", `{"name": "X"}`), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
t.Run("A no-op edit records nothing", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille Roche", "slug": "camille-roche", "bio": "Writer."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A role without author rights edits only its own author", func(t *testing.T) {
cases := []struct {
id uint32
status int
body string
}{
{4, 200, ""},
{2, 403, "author_rights"},
{999, 404, "author_not_found"},
}
for _, c := range cases {
t.Run(fmt.Sprint(c.id), func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
rec := e.as(t, authorEditorID, http.MethodPatch, fmt.Sprintf("/v1/author/%d", c.id), `{"bio": "Updated."}`)
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) {
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
}
})
}
})
t.Run("Own author edits include name, slug and avatar", func(t *testing.T) {
for _, body := range []string{`{"name": "Marta A."}`, `{"slug": "marta-a"}`, `{"avatar_id": 5}`, `{"social_links": []}`} {
t.Run(body, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
if rec := e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", body); rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("A role without author rights cannot change the link", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", `{"user_id": null}`), http.StatusForbidden, "author_rights")
if e.authors.authors[4].UserID == nil {
t.Errorf("the author was unlinked")
}
})
t.Run("An API key cannot edit an author", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
expectAuthorBody(t, e.viaKey(t, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`), http.StatusForbidden, sessionRefusal)
})
t.Run("The response carries the email for owner and admin only", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
own := e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", `{"bio": "Updated."}`)
admin := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/author/4", `{"bio": "Again."}`)
if strings.Contains(own.Body.String(), "user_email") || !strings.Contains(admin.Body.String(), "edith@example.com") {
t.Errorf("own %s admin %s", own.Body.String(), admin.Body.String())
}
})
}
func TestAuthorsBackendLinking(t *testing.T) {
t.Run("The owner links a user to a guest author", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
rec := e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorEditorID))
body := decodeAuthorBody(t, rec)
if rec.Code != http.StatusOK || body["user_id"] != float64(authorEditorID) || body["is_linked"] != true || body["user_email"] != "edith@example.com" {
t.Errorf("status %d body %v", rec.Code, body)
}
})
t.Run("A user cannot have two authors", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
e.authors.seedLinked(4, "Edith", "edith", authorEditorID)
before := e.authors.authors[2]
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorEditorID)), http.StatusConflict, "user_already_linked")
e.unchanged(t, 2, before)
})
t.Run("A linked author must be unlinked before it can be relinked", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorOtherEditorID)), http.StatusConflict, "author_already_linked")
})
t.Run("Linking the user it already has is a no-op", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
rec := e.owner(t, http.MethodPatch, "/v1/author/2", fmt.Sprintf(`{"user_id": %d}`, authorEditorID))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("An unknown user cannot be linked", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
expectAuthorBody(t, e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": 999}`), http.StatusNotFound, "user_not_found")
})
t.Run("The owner unlinks a user", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
e.seedOnlineArticle(10, "Spring release", 2)
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": null}`)
body := decodeAuthorBody(t, rec)
if _, present := body["user_email"]; rec.Code != http.StatusOK || body["user_id"] != nil || body["is_linked"] != false || present {
t.Errorf("status %d body %v", rec.Code, body)
}
if !reflect.DeepEqual(e.creditsOf(10), []uint32{2}) {
t.Errorf("credits = %v", e.creditsOf(10))
}
if rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusCreated {
t.Errorf("the user cannot create an author again: %d", rec.Code)
}
})
t.Run("Unlinking a guest author is a no-op", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": null}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A new article by the unlinked user no longer starts with the author", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Camille Roche", "camille-roche", authorEditorID)
e.owner(t, http.MethodPatch, "/v1/author/2", `{"user_id": null}`)
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusCreated || len(body["authors"].([]any)) != 0 {
t.Errorf("status %d body %v", rec.Code, body)
}
})
}
func TestAuthorsBackendDeleting(t *testing.T) {
t.Run("An author without articles is deleted", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
if rec := e.owner(t, http.MethodDelete, "/v1/author/2", ""); rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if _, exists := e.authors.authors[2]; exists {
t.Errorf("the author still exists")
}
})
t.Run("An author with articles needs a destination", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
for id := uint32(10); id < 13; id++ {
e.seedOnlineArticle(id, "Article", 2)
}
expectAuthorBody(t, e.owner(t, http.MethodDelete, "/v1/author/2", ""), http.StatusConflict, "author_has_articles")
if _, exists := e.authors.authors[2]; !exists || len(e.authors.deleteCalls) != 0 {
t.Errorf("the author changed")
}
})
t.Run("The articles are reassigned to another author", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "Camille Roche", "camille-roche")
e.authors.seed(3, "Zoe", "zoe")
for id := uint32(10); id < 13; id++ {
e.seedOnlineArticle(id, "Article", 2)
}
if rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", ""); rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
for id := uint32(10); id < 13; id++ {
if !reflect.DeepEqual(e.creditsOf(id), []uint32{3}) {
t.Errorf("credits of %d = %v", id, e.creditsOf(id))
}
}
})
t.Run("The reassigned author takes the position of the deleted one", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.authors.seed(3, "C", "c")
e.authors.seed(5, "E", "e")
e.authors.seed(6, "F", "f")
e.seedOnlineArticle(10, "Article", 5, 2, 6)
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
if !reflect.DeepEqual(e.creditsOf(10), []uint32{5, 3, 6}) {
t.Errorf("credits = %v", e.creditsOf(10))
}
})
t.Run("A reassignment never duplicates an author on an article", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.authors.seed(3, "C", "c")
e.seedOnlineArticle(10, "Article", 2, 3)
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
if !reflect.DeepEqual(e.creditsOf(10), []uint32{3}) {
t.Errorf("credits = %v", e.creditsOf(10))
}
})
t.Run("Nobody removes the byline", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.authors.seed(5, "E", "e")
e.authors.seed(6, "F", "f")
e.seedOnlineArticle(10, "Alone", 2)
e.seedOnlineArticle(11, "Shared", 5, 2, 6)
if rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=none", ""); rec.Code != http.StatusNoContent {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if len(e.creditsOf(10)) != 0 || !reflect.DeepEqual(e.creditsOf(11), []uint32{5, 6}) {
t.Errorf("credits = %v %v", e.creditsOf(10), e.creditsOf(11))
}
if _, exists := e.articles.articles[10]; !exists {
t.Errorf("the article was removed")
}
})
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.authors.seed(3, "C", "c")
e.seedOnlineArticle(10, "Article", 2)
e.authors.fail.failNext("DeleteMovingCredits")
rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status %d", rec.Code)
}
if _, exists := e.authors.authors[2]; !exists || !reflect.DeepEqual(e.creditsOf(10), []uint32{2}) {
t.Errorf("state changed")
}
})
t.Run("The credits are moved by one repository operation", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.authors.seed(3, "C", "c")
e.seedOnlineArticle(10, "Article", 2)
e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "")
if len(e.authors.deleteCalls) != 1 || e.authors.deleteCalls[0].id != 2 || e.authors.deleteCalls[0].reassignTo == nil || *e.authors.deleteCalls[0].reassignTo != 3 {
t.Errorf("calls = %+v", e.authors.deleteCalls)
}
})
t.Run("An unusable destination", func(t *testing.T) {
cases := []struct {
param string
status int
body string
}{
{"999", 404, "author_not_found"},
{"2", 400, "invalid_reassign"},
{"abc", 400, "invalid_reassign"},
{"-1", 400, "invalid_reassign"},
{"", 400, "invalid_reassign"},
}
for _, c := range cases {
t.Run(c.param, func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.seedOnlineArticle(10, "Article", 2)
expectAuthorBody(t, e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to="+c.param, ""), c.status, c.body)
if _, exists := e.authors.authors[2]; !exists || !reflect.DeepEqual(e.creditsOf(10), []uint32{2}) {
t.Errorf("state changed")
}
})
}
})
t.Run("A linked author can be deleted and the account is untouched", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Edith", "edith", authorEditorID)
e.owner(t, http.MethodDelete, "/v1/author/2", "")
if _, exists := e.users.users[authorEditorID]; !exists {
t.Errorf("the user was removed")
}
if rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusCreated {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A missing author is not found", func(t *testing.T) {
e := newAuthorEnv(t)
expectAuthorBody(t, e.owner(t, http.MethodDelete, "/v1/author/999", ""), http.StatusNotFound, "author_not_found")
})
t.Run("An unused reassign_to is ignored", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
e.authors.seed(3, "C", "c")
before := e.authors.authors[3]
if rec := e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", ""); rec.Code != http.StatusNoContent {
t.Fatalf("status %d", rec.Code)
}
e.unchanged(t, 3, before)
})
t.Run("Other roles and keys cannot delete", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(2, "Edith", "edith", authorEditorID)
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodDelete, "/v1/author/2", ""), http.StatusForbidden, ownerOrAdminRefusal)
expectAuthorBody(t, e.viaKey(t, http.MethodDelete, "/v1/author/2", ""), http.StatusForbidden, sessionRefusal)
if _, exists := e.authors.authors[2]; !exists {
t.Errorf("the author was deleted")
}
})
t.Run("An admin can delete", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(2, "B", "b")
if rec := e.as(t, apiKeyDevId, http.MethodDelete, "/v1/author/2", ""); rec.Code != http.StatusNoContent {
t.Errorf("status %d", rec.Code)
}
})
t.Run("Refused writes change nothing", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedEditable()
before := e.authors.snapshot()
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/", `{"name": "X"}`), http.StatusForbidden, ownerOrAdminRefusal)
expectAuthorBody(t, e.viaKey(t, http.MethodPost, "/v1/author/", `{"name": "X"}`), http.StatusForbidden, sessionRefusal)
expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`), http.StatusForbidden, "author_rights")
if !reflect.DeepEqual(e.authors.snapshot(), before) {
t.Errorf("authors changed")
}
})
}