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") } }) }