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 stays an array and later gains additive keys", 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", "") var items []map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil || rec.Code != http.StatusOK || len(items) != 2 { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } first := items[0] if first["id"] != float64(1) || first["title"] != "Spring release" || first["published"] != true || first["type"] != "article" { t.Errorf("the article item lost a key: %v", first) } }) }