Files
RenewCMS/test/authors_backend_http_test.go
T

499 lines
18 KiB
Go

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