package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/author" "fmt" "net/http" "net/http/httptest" "reflect" "strings" "testing" "unicode/utf8" ) const ( ownerOrAdminRefusal = "Only owners and admins can do this." sessionRefusal = "This action needs a real session." ) func (e *authorEnv) viaKey(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() raw, _ := e.issueKey(apiKeyDevId, "Deploy bot") return e.asKey(t, raw, method, path, body) } func expectAuthorBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status || bodyText(rec) != body { t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, bodyText(rec)) } } func TestAuthorsBackendReading(t *testing.T) { t.Run("The list is newest first with article counts", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(1, "Léa Fontaine", "lea-fontaine") e.authors.seed(2, "Inès Duarte", "ines-duarte") e.authors.seed(3, "Camille Roche", "camille-roche") for id := uint32(1); id <= 9; id++ { e.seedOnlineArticle(id, fmt.Sprintf("Article %d", id), 2) } rec := e.owner(t, http.MethodGet, "/v1/author/", "") if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } list := decodeAuthorList(t, rec) if list.Total != 3 || list.Page != 1 || list.Size != 25 { t.Errorf("envelope = %+v", list) } if joinNames(listedNames(list)) != "Camille Roche|Inès Duarte|Léa Fontaine" { t.Errorf("names = %v", listedNames(list)) } counts := []float64{} for _, item := range list.Items { counts = append(counts, item["article_count"].(float64)) } if !reflect.DeepEqual(counts, []float64{0, 9, 0}) { t.Errorf("article_count = %v", counts) } }) t.Run("Who can read the list", func(t *testing.T) { cases := []struct { credentials string send func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder status int }{ {"the owner's jwt cookie", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/author/", "") }, http.StatusOK}, {"a verified admin's jwt cookie", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "") }, http.StatusOK}, {"a verified account with another role", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "") }, http.StatusOK}, {"a valid API key", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder { return e.viaKey(t, http.MethodGet, "/v1/author/", "") }, http.StatusOK}, {"an unverified account's jwt cookie", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/author/", "") }, http.StatusForbidden}, {"no credentials", func(e *authorEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, 0, http.MethodGet, "/v1/author/", "") }, http.StatusUnauthorized}, } for _, c := range cases { t.Run(c.credentials, func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(1, "Camille Roche", "camille-roche") if rec := c.send(e, t); rec.Code != c.status { t.Errorf("status %d: %s", rec.Code, rec.Body.String()) } }) } }) t.Run("Search matches the name or the slug", func(t *testing.T) { cases := []struct { q string names string total int }{ {"camille", "Camille Roche", 1}, {"ROCHE", "Camille Roche", 1}, {"zl", "Zoe Lambert", 1}, {"zoe", "Zoe Lambert", 1}, {"nobody", "", 0}, } for _, c := range cases { t.Run(c.q, func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(1, "Camille Roche", "camille-roche") e.authors.seed(2, "Zoe Lambert", "zl") list := decodeAuthorList(t, e.owner(t, http.MethodGet, "/v1/author/?q="+c.q, "")) if joinNames(listedNames(list)) != c.names || list.Total != c.total { t.Errorf("names = %v total = %d", listedNames(list), list.Total) } }) } }) t.Run("Pagination follows the article list", func(t *testing.T) { cases := []struct { params string status int items int total int page int size int }{ {"size=25&page=2", 200, 5, 30, 2, 25}, {"size=5", 200, 5, 30, 1, 5}, {"page=3&size=100", 200, 0, 30, 3, 100}, {"page=9223372036854775807&size=100", 200, 0, 30, 9223372036854775807, 100}, {"size=30", 400, 0, 0, 0, 0}, {"page=0", 400, 0, 0, 0, 0}, {"page=abc", 400, 0, 0, 0, 0}, } for _, c := range cases { t.Run(c.params, func(t *testing.T) { e := newAuthorEnv(t) for id := uint32(1); id <= 30; id++ { e.authors.seed(id, fmt.Sprintf("Author %02d", id), fmt.Sprintf("author-%02d", id)) } rec := e.owner(t, http.MethodGet, "/v1/author/?"+c.params, "") if rec.Code != c.status { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } if c.status == http.StatusBadRequest { expectAuthorBody(t, rec, c.status, apperr.BodyErrorMessage) return } list := decodeAuthorList(t, rec) if len(list.Items) != c.items || list.Total != c.total || list.Page != c.page || list.Size != c.size { t.Errorf("items %d, %+v", len(list.Items), list) } }) } }) t.Run("The account email is shown to owner and admin only", func(t *testing.T) { e := newAuthorEnv(t) e.users.users[7] = userWithEmail(7, "ines", "ines.duarte@renew.example") e.authors.seedLinked(1, "Inès Duarte", "ines-duarte", 7) owner := decodeAuthorList(t, e.owner(t, http.MethodGet, "/v1/author/", "")) item := owner.Items[0] if item["is_linked"] != true || item["user_id"] != float64(7) || item["user_email"] != "ines.duarte@renew.example" { t.Errorf("owner item = %v", item) } rec := e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "") if !strings.Contains(rec.Body.String(), `"is_linked":true`) || strings.Contains(rec.Body.String(), "user_email") || strings.Contains(rec.Body.String(), "ines.duarte@renew.example") { t.Errorf("editor body = %s", rec.Body.String()) } }) t.Run("A guest author is not linked", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(1, "Camille Roche", "camille-roche") item := decodeAuthorList(t, e.owner(t, http.MethodGet, "/v1/author/", "")).Items[0] if item["is_linked"] != false || item["user_id"] != nil { t.Errorf("item = %v", item) } if _, present := item["user_email"]; present { t.Errorf("user_email present: %v", item) } }) t.Run("An author without avatar or website returns nulls", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(1, "Camille Roche", "camille-roche") e.authors.update(1, func(a *author.Author) { a.SocialLinks = nil }) item := decodeAuthorList(t, e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")).Items[0] links, _ := item["social_links"].([]any) if item["avatar_id"] != nil || item["website"] != nil || item["bio"] != "" || links == nil || len(links) != 0 { t.Errorf("item = %v", item) } }) } func TestAuthorsBackendCreating(t *testing.T) { t.Run("An owner or admin creates an author", func(t *testing.T) { for _, actor := range []uint32{apiKeyOwnerId, apiKeyDevId} { t.Run(fmt.Sprintf("user %d", actor), func(t *testing.T) { e := newAuthorEnv(t) rec := e.as(t, actor, http.MethodPost, "/v1/author/", `{"name": "Camille Roche", "slug": "camille-roche", "bio": "Writer.", "website": "https://example.com", "social_links": [{"type": "x", "url": "https://x.com/camille"}]}`) if rec.Code != http.StatusCreated { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } body := decodeAuthorBody(t, rec) if body["id"] != float64(1) || body["article_count"] != float64(0) || body["is_linked"] != false || body["name"] != "Camille Roche" || body["slug"] != "camille-roche" || body["bio"] != "Writer." || body["website"] != "https://example.com" { t.Errorf("body = %v", body) } if stored := e.authors.authors[1]; stored.Name != "Camille Roche" || len(stored.SocialLinks) != 1 || stored.SocialLinks[0].Type != "x" { t.Errorf("stored = %+v", stored) } }) } }) t.Run("Only the name is required", func(t *testing.T) { e := newAuthorEnv(t) rec := e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Zoe Lambert"}`) body := decodeAuthorBody(t, rec) links, _ := body["social_links"].([]any) if rec.Code != http.StatusCreated || body["slug"] != "zoe-lambert" || body["bio"] != "" || body["avatar_id"] != nil || body["website"] != nil || body["user_id"] != nil || links == nil || len(links) != 0 { t.Errorf("status %d body %v", rec.Code, body) } }) t.Run("The slug is generated from the name", func(t *testing.T) { cases := [][2]string{ {"Camille Roche", "camille-roche"}, {"Inès Duarte", "ines-duarte"}, {"Dr. O'Neil Smith", "dr-o-neil-smith"}, {"!!!", "author"}, } for _, c := range cases { t.Run(c[0], func(t *testing.T) { e := newAuthorEnv(t) body := decodeAuthorBody(t, e.owner(t, http.MethodPost, "/v1/author/", `{"name": "`+c[0]+`"}`)) if body["slug"] != c[1] { t.Errorf("slug = %v", body["slug"]) } }) } }) t.Run("A taken slug is refused", func(t *testing.T) { cases := []struct{ name, body string }{ {"generated", `{"name": "Camille Roche"}`}, {"explicit", `{"name": "Other", "slug": "camille-roche"}`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(1, "Camille Roche", "camille-roche") rec := e.owner(t, http.MethodPost, "/v1/author/", c.body) expectAuthorBody(t, rec, http.StatusConflict, "author_slug_taken") if len(e.authors.authors) != 1 { t.Errorf("an author was added") } }) } }) t.Run("The slug is unique even if the repository reports a race", func(t *testing.T) { e := newAuthorEnv(t) e.authors.fail.failNextWith("Create", author.ErrSlugTaken) rec := e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Fresh Name", "slug": "fresh-name"}`) expectAuthorBody(t, rec, http.StatusConflict, "author_slug_taken") }) t.Run("Fields are validated", func(t *testing.T) { longLinks := strings.Repeat(`{"type": "x", "url": "https://x.com/a"},`, 5) + `{"type": "x", "url": "https://x.com/a"}` cases := []struct { name string body string status int token string }{ {"empty name", `{"name": ""}`, 400, ""}, {"blank name", `{"name": " "}`, 400, ""}, {"long name", `{"name": "` + strings.Repeat("a", 101) + `"}`, 400, ""}, {"capital slug", `{"name": "A", "slug": "Camille"}`, 400, "invalid_slug"}, {"underscore slug", `{"name": "A", "slug": "camille_roche"}`, 400, "invalid_slug"}, {"leading dash slug", `{"name": "A", "slug": "-camille"}`, 400, "invalid_slug"}, {"long slug", `{"name": "A", "slug": "` + strings.Repeat("a", 101) + `"}`, 400, "invalid_slug"}, {"long bio", `{"name": "A", "bio": "` + strings.Repeat("a", 281) + `"}`, 422, "bio_too_long"}, {"http website", `{"name": "A", "website": "http://example.com"}`, 422, "invalid_url"}, {"bare website", `{"name": "A", "website": "example.com"}`, 422, "invalid_url"}, {"hostless website", `{"name": "A", "website": "https://"}`, 422, "invalid_url"}, {"six links", `{"name": "A", "social_links": [` + longLinks + `]}`, 422, "too_many_links"}, {"http link", `{"name": "A", "social_links": [{"type": "x", "url": "http://x.com/a"}]}`, 422, "invalid_url"}, {"unknown link type", `{"name": "A", "social_links": [{"type": "facebook", "url": "https://f.com/a"}]}`, 400, ""}, {"unknown avatar", `{"name": "A", "avatar_id": 999}`, 400, "image_not_found"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newAuthorEnv(t) rec := e.owner(t, http.MethodPost, "/v1/author/", c.body) if rec.Code != c.status || (c.token != "" && bodyText(rec) != c.token) || (c.token == "" && bodyText(rec) == "") { t.Errorf("status %d body %q", rec.Code, bodyText(rec)) } if len(e.authors.authors) != 0 { t.Errorf("an author was added") } }) } }) t.Run("Boundaries are accepted", func(t *testing.T) { fiveLinks := strings.TrimSuffix(strings.Repeat(`{"type": "x", "url": "https://x.com/a"},`, 5), ",") cases := []struct{ name, body string }{ {"100 character name", `{"name": "` + strings.Repeat("a", 100) + `", "slug": "` + strings.Repeat("b", 100) + `"}`}, {"280 characters bio", `{"name": "A", "bio": "` + strings.Repeat("a", 280) + `"}`}, {"280 multi-byte characters bio", `{"name": "A", "bio": "` + strings.Repeat("é", 280) + `"}`}, {"five links", `{"name": "A", "social_links": [` + fiveLinks + `]}`}, {"two custom links", `{"name": "A", "social_links": [{"type": "custom", "url": "https://a.example"}, {"type": "custom", "url": "https://b.example"}]}`}, {"empty website", `{"name": "A", "website": ""}`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newAuthorEnv(t) if rec := e.owner(t, http.MethodPost, "/v1/author/", c.body); rec.Code != http.StatusCreated { t.Errorf("status %d: %s", rec.Code, rec.Body.String()) } }) } }) t.Run("A malformed body is refused", func(t *testing.T) { e := newAuthorEnv(t) expectAuthorBody(t, e.owner(t, http.MethodPost, "/v1/author/", "{not json"), http.StatusBadRequest, apperr.BodyErrorMessage) if len(e.authors.authors) != 0 { t.Errorf("an author was added") } }) t.Run("The role is checked before the body", func(t *testing.T) { e := newAuthorEnv(t) expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/", "{not json"), http.StatusForbidden, ownerOrAdminRefusal) expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/", `{"name": "Valid Name"}`), http.StatusForbidden, ownerOrAdminRefusal) if len(e.authors.authors) != 0 { t.Errorf("an author was added") } }) t.Run("An author can be created linked to a user", func(t *testing.T) { e := newAuthorEnv(t) rec := e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Marta Alves", "user_id": 1}`) body := decodeAuthorBody(t, rec) if rec.Code != http.StatusCreated || body["user_id"] != float64(1) || body["is_linked"] != true || body["user_email"] != "marta@example.com" { t.Errorf("status %d body %v", rec.Code, body) } }) t.Run("An author cannot be created with an unusable user", func(t *testing.T) { cases := []struct { name string userID int status int body string }{ {"unknown user", 999, 404, "user_not_found"}, {"user with an author", 5, 409, "user_already_linked"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newAuthorEnv(t) e.authors.seedLinked(1, "Edith", "edith", authorEditorID) rec := e.owner(t, http.MethodPost, "/v1/author/", fmt.Sprintf(`{"name": "X", "user_id": %d}`, c.userID)) expectAuthorBody(t, rec, c.status, c.body) if len(e.authors.authors) != 1 { t.Errorf("an author was added") } }) } }) } func TestAuthorsBackendOwnAuthor(t *testing.T) { t.Run("The author is named from the display name or the username", func(t *testing.T) { cases := []struct { display, username, name, slug string }{ {"Marta Alves", "marta", "Marta Alves", "marta-alves"}, {"", "marta", "marta", "marta"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newAuthorEnv(t) e.users.users[authorEditorID] = userNamed(authorEditorID, c.username, c.display) rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "") body := decodeAuthorBody(t, rec) if rec.Code != http.StatusCreated || body["name"] != c.name || body["slug"] != c.slug || body["user_id"] != float64(authorEditorID) || body["is_linked"] != true { t.Errorf("status %d body %v", rec.Code, body) } }) } }) t.Run("Any verified role may do it", func(t *testing.T) { for _, actor := range []uint32{apiKeyOwnerId, apiKeyDevId, authorEditorID} { t.Run(fmt.Sprintf("user %d", actor), func(t *testing.T) { e := newAuthorEnv(t) if rec := e.as(t, actor, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusCreated { t.Errorf("status %d: %s", rec.Code, rec.Body.String()) } }) } }) t.Run("A taken slug is de-duplicated silently", func(t *testing.T) { e := newAuthorEnv(t) e.users.users[authorEditorID] = userNamed(authorEditorID, "marta", "Marta Alves") e.authors.seed(1, "Marta Alves", "marta-alves") e.authors.seed(2, "Marta Alves 2", "marta-alves-2") rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "") if body := decodeAuthorBody(t, rec); rec.Code != http.StatusCreated || body["slug"] != "marta-alves-3" { t.Errorf("status %d body %v", rec.Code, body) } }) t.Run("The suffix still fits in the slug limit", func(t *testing.T) { e := newAuthorEnv(t) longName := strings.Repeat("a", 100) e.users.users[authorEditorID] = userNamed(authorEditorID, "long", longName) e.authors.seed(1, longName, longName) rec := e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", "") slug, _ := decodeAuthorBody(t, rec)["slug"].(string) if rec.Code != http.StatusCreated || utf8.RuneCountInString(slug) > 100 || !strings.HasSuffix(slug, "-2") { t.Errorf("status %d slug %q", rec.Code, slug) } }) t.Run("A caller who already has an author is refused", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seedLinked(1, "Edith", "edith", authorEditorID) expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""), http.StatusConflict, "user_already_linked") if len(e.authors.authors) != 1 { t.Errorf("an author was added") } }) t.Run("Two concurrent calls create one author", func(t *testing.T) { e := newAuthorEnv(t) e.authors.fail.failNextWith("Create", author.ErrUserAlreadyLinked) expectAuthorBody(t, e.as(t, authorEditorID, http.MethodPost, "/v1/author/me", ""), http.StatusConflict, "user_already_linked") }) t.Run("The endpoint is session-only and verified-only", func(t *testing.T) { e := newAuthorEnv(t) expectAuthorBody(t, e.viaKey(t, http.MethodPost, "/v1/author/me", ""), http.StatusForbidden, sessionRefusal) if rec := e.as(t, apiKeyQuinnId, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusForbidden || bodyText(rec) != apperr.VerificationRequiredErrorMessage { t.Errorf("unverified: %d %q", rec.Code, bodyText(rec)) } if rec := e.as(t, 0, http.MethodPost, "/v1/author/me", ""); rec.Code != http.StatusUnauthorized { t.Errorf("anonymous: %d %q", rec.Code, bodyText(rec)) } if len(e.authors.authors) != 0 || e.authors.createCalls != 0 { t.Errorf("an author was added") } }) }