Files
RenewCMS/test/authors_backend_articles_http_test.go
T

540 lines
18 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/author"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func (e *authorEnv) seedBylines() {
e.authors.seed(5, "Inès Duarte", "ines-duarte")
e.authors.seed(6, "Marta Alves", "marta-alves")
e.authors.seed(7, "Zoe Lambert", "zoe-lambert")
}
func authorIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []uint32 {
t.Helper()
var body struct {
Authors []struct {
ID uint32 `json:"id"`
} `json:"authors"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
ids := []uint32{}
for _, entry := range body.Authors {
ids = append(ids, entry.ID)
}
return ids
}
func TestAuthorsBackendArticles(t *testing.T) {
t.Run("An article is credited with ordered authors", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release")
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [6, 5, 7]}`)
if rec.Code != http.StatusOK || !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{6, 5, 7}) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("The author list is replaced and can be emptied", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 5, 6)
if rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [6]}`); !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{6}) {
t.Errorf("body %s", rec.Body.String())
}
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": []}`)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authors":[]`) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("Reordering changes the positions", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 5, 6, 7)
e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [7, 5, 6]}`)
if got := authorIDsOf(t, e.owner(t, http.MethodGet, "/v1/article/8", "")); !reflect.DeepEqual(got, []uint32{7, 5, 6}) {
t.Errorf("authors = %v", got)
}
})
t.Run("Any account that can edit the article sets its authors", func(t *testing.T) {
senders := map[string]func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder{
"owner": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
},
"admin": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
},
"other role": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, authorEditorID, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
},
"API key": func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder {
return e.viaKey(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [5]}`)
},
}
for name, send := range senders {
t.Run(name, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release")
if rec := send(e, t); rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("The authors and the other fields change together", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release")
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New", "author_ids": [5]}`)
if rec.Code != http.StatusOK || e.articles.articles[8].Body != "New" || !reflect.DeepEqual(e.creditsOf(8), []uint32{5}) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("An update without author_ids keeps the authors", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 5, 6)
e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New"}`)
if !reflect.DeepEqual(e.creditsOf(8), []uint32{5, 6}) {
t.Errorf("credits = %v", e.creditsOf(8))
}
})
t.Run("An unusable author_ids is refused", func(t *testing.T) {
cases := []struct {
value string
status int
body string
}{
{"[999]", 404, "author_not_found"},
{"[5, 999]", 404, "author_not_found"},
{"[5, 5]", 400, apperr.BodyErrorMessage},
{"null", 400, apperr.BodyErrorMessage},
{`"a"`, 400, apperr.BodyErrorMessage},
{"[-1]", 400, apperr.BodyErrorMessage},
{"[0]", 400, ""},
}
for _, c := range cases {
t.Run(c.value, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 6)
before := e.articles.articles[8]
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": `+c.value+`}`)
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) || bodyText(rec) == "" {
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
}
if !reflect.DeepEqual(e.articles.articles[8], before) || !reflect.DeepEqual(e.creditsOf(8), []uint32{6}) {
t.Errorf("the article changed")
}
})
}
})
t.Run("A refused update changes no authors", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 5)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New", "author_ids": [999]}`)
if rec.Code != http.StatusNotFound || e.articles.articles[8].Body != "Body" || !reflect.DeepEqual(e.creditsOf(8), []uint32{5}) {
t.Errorf("status %d", rec.Code)
}
})
t.Run("An unchanged list records nothing", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 5, 6)
if rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [5, 6]}`); rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("An article without authors can be published", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedOfflineArticle(8, "Spring release")
rec := e.owner(t, http.MethodPatch, "/v1/article/8/publish", "")
if rec.Code != http.StatusOK || !e.articles.articles[8].IsOnline {
t.Errorf("status %d", rec.Code)
}
})
t.Run("Article reads carry the ordered authors", func(t *testing.T) {
requests := []struct{ method, path, body string }{
{http.MethodGet, "/v1/article/8", ""},
{http.MethodPatch, "/v1/article/8", `{"body": "New"}`},
{http.MethodPatch, "/v1/article/8/publish", ""},
{http.MethodPatch, "/v1/article/8/unpublish", ""},
}
for _, c := range requests {
t.Run(c.method+" "+c.path, func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 6, 5)
rec := e.owner(t, c.method, c.path, c.body)
if rec.Code != http.StatusOK || !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{6, 5}) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
var parsed struct {
Authors []map[string]any `json:"authors"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
keys := []string{}
for key := range parsed.Authors[0] {
keys = append(keys, key)
}
if len(keys) != 7 {
t.Errorf("author keys = %v", keys)
}
})
}
t.Run("GET /v1/article/ items", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOfflineArticle(8, "Spring release", 6, 5)
rec := e.owner(t, http.MethodGet, "/v1/article/", "")
var page struct {
Items []struct {
Authors []struct {
ID uint32 `json:"id"`
} `json:"authors"`
} `json:"items"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || len(page.Items[0].Authors) != 2 || page.Items[0].Authors[0].ID != 6 {
t.Errorf("body %s", rec.Body.String())
}
})
})
t.Run("An article without authors returns an empty list", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedOfflineArticle(8, "Spring release")
if rec := e.owner(t, http.MethodGet, "/v1/article/8", ""); !strings.Contains(rec.Body.String(), `"authors":[]`) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("Anonymous readers see the authors of online articles", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(5, "Inès Duarte", "ines-duarte", authorEditorID)
e.seedOnlineArticle(9, "Spring release", 5)
rec := e.as(t, 0, http.MethodGet, "/v1/article/9", "")
body := rec.Body.String()
if rec.Code != http.StatusOK || !strings.Contains(body, "Inès Duarte") || strings.Contains(body, "user_id") || strings.Contains(body, "user_email") || strings.Contains(body, "article_count") {
t.Errorf("status %d body %s", rec.Code, body)
}
})
t.Run("An offline article is still not found publicly", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedOfflineArticle(8, "Spring release")
expectAuthorBody(t, e.as(t, 0, http.MethodGet, "/v1/article/8", ""), http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
})
t.Run("A new article defaults to the creator's author", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(5, "Edith", "edith", authorEditorID)
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated || !reflect.DeepEqual(authorIDsOf(t, rec), []uint32{5}) || e.articles.articles[1].IsOnline {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("A creator without an author creates an article without authors", func(t *testing.T) {
e := newAuthorEnv(t)
rec := e.as(t, authorEditorID, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"authors":[]`) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("Deleting an article removes its credits and keeps the author", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(5, "Inès Duarte", "ines-duarte")
e.seedOnlineArticle(8, "One", 5)
e.seedOnlineArticle(9, "Two", 5)
if rec := e.owner(t, http.MethodDelete, "/v1/article/8", ""); rec.Code != http.StatusNoContent {
t.Fatalf("status %d", rec.Code)
}
got, err := e.authors.Get(5)
if err != nil || got.ArticleCount != 1 {
t.Errorf("author %+v %v", got, err)
}
})
t.Run("article_count counts offline articles too", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seed(5, "Inès Duarte", "ines-duarte")
e.seedOnlineArticle(8, "One", 5)
e.seedOfflineArticle(9, "Two", 5)
if got, _ := e.authors.Get(5); got.ArticleCount != 2 {
t.Errorf("article_count = %d", got.ArticleCount)
}
})
t.Run("The list filter and counts are unaffected", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBylines()
e.seedOnlineArticle(8, "Spring release", 5)
e.seedOfflineArticle(9, "Spring draft", 6)
e.seedOnlineArticle(10, "Autumn", 7)
rec := e.owner(t, http.MethodGet, "/v1/article/?status=online&q=spring", "")
var page struct {
Total int `json:"total"`
Counts struct {
All int `json:"all"`
Online int `json:"online"`
Draft int `json:"draft"`
} `json:"counts"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 1 || page.Counts.All != 3 || page.Counts.Online != 2 || page.Counts.Draft != 1 {
t.Errorf("body %s", rec.Body.String())
}
})
}
func TestAuthorsBackendCurrentUser(t *testing.T) {
t.Run("GET /v1/auth/me includes the linked author", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(5, "Edith", "edith", authorEditorID)
e.seedOnlineArticle(8, "One", 5)
rec := e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")
body := decodeAuthorBody(t, rec)
linked, _ := body["author"].(map[string]any)
if rec.Code != http.StatusOK || linked == nil || linked["id"] != float64(5) || linked["article_count"] != float64(1) {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
for _, key := range []string{"id", "name", "slug", "bio", "avatar_id", "website", "social_links", "article_count"} {
if _, present := linked[key]; !present {
t.Errorf("missing %s", key)
}
}
if _, present := linked["user_email"]; present || strings.Contains(rec.Body.String(), "user_id") {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("A user without an author has author null", func(t *testing.T) {
e := newAuthorEnv(t)
rec := e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")
if body := decodeAuthorBody(t, rec); body["author"] != nil {
t.Errorf("author = %v", body["author"])
}
if _, present := decodeAuthorBody(t, rec)["author"]; !present {
t.Errorf("author key missing")
}
})
t.Run("The field follows link changes", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(5, "Edith", "edith", authorEditorID)
e.owner(t, http.MethodPatch, "/v1/author/5", `{"user_id": null}`)
if body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", "")); body["author"] != nil {
t.Errorf("author = %v", body["author"])
}
})
t.Run("The existing fields are unchanged", func(t *testing.T) {
e := newAuthorEnv(t)
body := decodeAuthorBody(t, e.as(t, authorEditorID, http.MethodGet, "/v1/auth/me", ""))
for _, key := range []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} {
if _, present := body[key]; !present {
t.Errorf("missing %s", key)
}
}
raw, _ := json.Marshal(body)
for _, secret := range []string{"password", "token", "code"} {
if strings.Contains(string(raw), secret) {
t.Errorf("body holds %s", secret)
}
}
})
t.Run("An API key reads the same body", func(t *testing.T) {
e := newAuthorEnv(t)
e.authors.seedLinked(5, "Dev", "dev", apiKeyDevId)
rec := e.viaKey(t, http.MethodGet, "/v1/auth/me", "")
if body := decodeAuthorBody(t, rec); rec.Code != http.StatusOK || body["author"] == nil {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}
func (e *authorEnv) useImageForAuthor(authorID uint32, imageID uint32) {
e.authors.update(authorID, func(a *author.Author) { a.AvatarID = idPointer(imageID) })
}
func TestAuthorsBackendMediaUsage(t *testing.T) {
t.Run("An image used as an avatar is counted as used", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 5)
var list, unused mediaListBody
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/media/", "").Body.Bytes(), &list)
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/media/?filter=unused", "").Body.Bytes(), &unused)
if len(list.Items) != 1 || list.Items[0].UsedIn != 1 || list.Counts.Unused != 0 || len(unused.Items) != 0 {
t.Errorf("list %+v unused %+v", list, unused)
}
})
t.Run("The media list counts the author", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 5)
e.articles.articles[1] = articleUsingImage(5)
var list mediaListBody
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/media/", "").Body.Bytes(), &list)
if len(list.Items) != 1 || list.Items[0].UsedIn != 2 {
t.Errorf("list %+v", list)
}
})
t.Run("Deleting the image clears the author's avatar", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 5)
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusNoContent || e.authors.authors[2].AvatarID != nil {
t.Errorf("status %d avatar %v", rec.Code, e.authors.authors[2].AvatarID)
}
})
t.Run("A failed image deletion keeps the author's avatar", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 5)
e.images.failDeleteFor = map[uint32]error{5: errRepositoryFailure}
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
if rec.Code != http.StatusInternalServerError || e.authors.authors[2].AvatarID == nil {
t.Errorf("status %d avatar %v", rec.Code, e.authors.authors[2].AvatarID)
}
})
t.Run("Merging copies repoints the author", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "samehash")
e.seedBanner(6, "banner-copy.png", "samehash")
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 6)
rec := e.owner(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 5, "merge_ids": [6]}`)
var body map[string]int
_ = json.Unmarshal(rec.Body.Bytes(), &body)
avatar := e.authors.authors[2].AvatarID
if rec.Code != http.StatusOK || body["authors_updated"] != 1 || avatar == nil || *avatar != 5 {
t.Errorf("status %d body %s avatar %v", rec.Code, rec.Body.String(), avatar)
}
})
t.Run("Purge repoints authors the same way", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "samehash")
e.seedBanner(6, "banner-copy.png", "samehash")
e.articles.articles[1] = articleUsingImage(5)
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 6)
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
var body map[string]int
_ = json.Unmarshal(rec.Body.Bytes(), &body)
avatar := e.authors.authors[2].AvatarID
if rec.Code != http.StatusOK || body["authors_updated"] != 1 || avatar == nil || *avatar != 5 {
t.Errorf("status %d body %s avatar %v", rec.Code, rec.Body.String(), avatar)
}
})
t.Run("A failed purge group leaves the author pointing at an existing image", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "samehash")
e.seedBanner(6, "banner-copy.png", "samehash")
e.articles.articles[1] = articleUsingImage(5)
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 6)
e.images.failDeleteFor = map[uint32]error{6: errRepositoryFailure}
e.owner(t, http.MethodPost, "/v1/media/purge", "")
avatar := e.authors.authors[2].AvatarID
if avatar == nil {
t.Fatalf("the avatar was cleared")
}
if _, exists := e.images.images[*avatar]; !exists {
t.Errorf("the author points at the missing image %d", *avatar)
}
})
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
e := newAuthorEnv(t)
e.seedBanner(5, "banner.png", "hash")
e.authors.seed(2, "Camille", "camille")
e.useImageForAuthor(2, 5)
e.articles.articles[1] = articleUsingImage(5)
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "author") {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
}