610 lines
22 KiB
Go
610 lines
22 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/internal/domain/author"
|
|
"fmt"
|
|
"log"
|
|
"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 || body["slug_adjusted"] != false {
|
|
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" || body["slug_adjusted"] != true {
|
|
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", "")
|
|
|
|
body := decodeAuthorBody(t, rec)
|
|
slug, _ := body["slug"].(string)
|
|
if rec.Code != http.StatusCreated || utf8.RuneCountInString(slug) > 100 || !strings.HasSuffix(slug, "-2") || body["slug_adjusted"] != true {
|
|
t.Errorf("status %d slug %q body %v", rec.Code, slug, body)
|
|
}
|
|
})
|
|
|
|
t.Run("Only the own-author creation carries slug_adjusted", func(t *testing.T) {
|
|
e := newAuthorEnv(t)
|
|
|
|
rec := e.as(t, apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`)
|
|
|
|
if _, present := decodeAuthorBody(t, rec)["slug_adjusted"]; rec.Code != http.StatusCreated || present {
|
|
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthorsTotalHeader(t *testing.T) {
|
|
seed := func(e *authorEnv, count int) {
|
|
for id := uint32(1); id <= uint32(count); id++ {
|
|
name := fmt.Sprintf("Writer %02d", id)
|
|
if id <= 7 {
|
|
name = fmt.Sprintf("Mara %02d", id)
|
|
}
|
|
e.authors.seed(id, name, fmt.Sprintf("writer-%02d", id))
|
|
}
|
|
}
|
|
|
|
t.Run("The total is also sent as a header", func(t *testing.T) {
|
|
cases := []struct {
|
|
query string
|
|
header string
|
|
total int
|
|
}{{"?size=5", "30", 30}, {"?q=mar", "7", 7}, {"?page=3&size=100", "30", 30}, {"?q=nobody", "0", 0}}
|
|
for _, c := range cases {
|
|
e := newAuthorEnv(t)
|
|
seed(e, 30)
|
|
rec := e.owner(t, http.MethodGet, "/v1/author/"+c.query, "")
|
|
list := decodeAuthorList(t, rec)
|
|
if rec.Header().Get("X-Total-Count") != c.header || list.Total != c.total {
|
|
t.Errorf("%s: header %q total %d", c.query, rec.Header().Get("X-Total-Count"), list.Total)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Every reader gets the header", func(t *testing.T) {
|
|
readers := map[string]func(e *authorEnv) *httptest.ResponseRecorder{
|
|
"owner": func(e *authorEnv) *httptest.ResponseRecorder { return e.owner(t, http.MethodGet, "/v1/author/", "") },
|
|
"admin": func(e *authorEnv) *httptest.ResponseRecorder {
|
|
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "")
|
|
},
|
|
"editor": func(e *authorEnv) *httptest.ResponseRecorder {
|
|
return e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
|
|
},
|
|
"key": func(e *authorEnv) *httptest.ResponseRecorder { return e.viaKey(t, http.MethodGet, "/v1/author/", "") },
|
|
}
|
|
for name, send := range readers {
|
|
e := newAuthorEnv(t)
|
|
seed(e, 4)
|
|
rec := send(e)
|
|
if rec.Code != http.StatusOK || rec.Header().Get("X-Total-Count") != "4" {
|
|
t.Errorf("%s: status %d header %q", name, rec.Code, rec.Header().Get("X-Total-Count"))
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Refused requests send no header", func(t *testing.T) {
|
|
cases := []struct {
|
|
query string
|
|
send func(e *authorEnv, path string) *httptest.ResponseRecorder
|
|
status int
|
|
}{
|
|
{"?size=30", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
|
return e.owner(t, http.MethodGet, path, "")
|
|
}, 400},
|
|
{"?page=0", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
|
return e.owner(t, http.MethodGet, path, "")
|
|
}, 400},
|
|
{"?page=abc", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
|
return e.owner(t, http.MethodGet, path, "")
|
|
}, 400},
|
|
{"", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
|
return e.as(t, apiKeyQuinnId, http.MethodGet, path, "")
|
|
}, 403},
|
|
{"", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
|
return e.anonymous(t, http.MethodGet, path, "")
|
|
}, 401},
|
|
}
|
|
for _, c := range cases {
|
|
e := newAuthorEnv(t)
|
|
seed(e, 4)
|
|
rec := c.send(e, "/v1/author/"+c.query)
|
|
if _, present := rec.Header()["X-Total-Count"]; rec.Code != c.status || present {
|
|
t.Errorf("%q: status %d header present %v", c.query, rec.Code, present)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthorsResponseWriteFailureIsLogged(t *testing.T) {
|
|
e := newAuthorEnv(t)
|
|
e.authors.seed(1, "Writer 01", "writer-01")
|
|
var logOutput strings.Builder
|
|
defaultLogOutput := log.Writer()
|
|
log.SetOutput(&logOutput)
|
|
defer log.SetOutput(defaultLogOutput)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/author/", nil)
|
|
req.AddCookie(e.cookie(t, apiKeyOwnerId, 0))
|
|
e.router.ServeHTTP(&erroringResponseWriter{}, req)
|
|
|
|
if !strings.Contains(logOutput.String(), "Failed to write response") {
|
|
t.Errorf("expected the write failure to be logged, got %q", logOutput.String())
|
|
}
|
|
}
|