feat: custom content type icons, built-in types, user lookup
This commit is contained in:
1 parent
e6e088c963
commit
73a75d5cb9
31 files changed
+1611
-36
No files matched your search
@@ -0,0 +1,117 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
articleHandler "RenewCMS/api/handlers/article"
|
||||
siteHandler "RenewCMS/api/handlers/site"
|
||||
userHandler "RenewCMS/api/handlers/user"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type driftTagRules struct {
|
||||
required bool
|
||||
min any
|
||||
max any
|
||||
options []string
|
||||
}
|
||||
|
||||
func driftParseTag(tag string) driftTagRules {
|
||||
rules := driftTagRules{}
|
||||
for _, part := range strings.Split(tag, ",") {
|
||||
switch {
|
||||
case part == "required":
|
||||
rules.required = true
|
||||
case strings.HasPrefix(part, "min="):
|
||||
value, _ := strconv.Atoi(strings.TrimPrefix(part, "min="))
|
||||
rules.min = value
|
||||
case strings.HasPrefix(part, "max="):
|
||||
value, _ := strconv.Atoi(strings.TrimPrefix(part, "max="))
|
||||
rules.max = value
|
||||
case strings.HasPrefix(part, "oneof="):
|
||||
rules.options = strings.Fields(strings.TrimPrefix(part, "oneof="))
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func driftTagFor(t *testing.T, structType reflect.Type, jsonKey string) (string, bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < structType.NumField(); i++ {
|
||||
field := structType.Field(i)
|
||||
if strings.Split(field.Tag.Get("json"), ",")[0] == jsonKey {
|
||||
return field.Tag.Get("validate"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func driftSorted(values []string) []string {
|
||||
out := append([]string{}, values...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
type driftBinding struct {
|
||||
typeKey string
|
||||
request any
|
||||
fields []string
|
||||
checkRequired bool
|
||||
}
|
||||
|
||||
func TestBuiltinCatalogueMatchesRequestTags(t *testing.T) {
|
||||
bindings := []driftBinding{
|
||||
{builtintype.KeyArticle, articleHandler.PostArticle{}, []string{"title"}, true},
|
||||
{builtintype.KeyArticle, articleHandler.UpdateArticleBody{}, []string{"excerpt", "seo_title", "seo_description", "canonical_url"}, false},
|
||||
{builtintype.KeyArticle, articleHandler.CheckArticleSEOBody{}, []string{"excerpt", "seo_title", "seo_description", "canonical_url"}, false},
|
||||
{builtintype.KeySite, siteHandler.PostSite{}, []string{"name", "key", "base_url", "default_robots", "site_name", "title_template", "default_description"}, true},
|
||||
{builtintype.KeySite, siteHandler.PatchSite{}, []string{"default_robots", "site_name", "title_template", "default_description"}, false},
|
||||
{builtintype.KeyUser, userHandler.CreateInviteRequest{}, []string{"email"}, true},
|
||||
}
|
||||
covered := map[string]bool{}
|
||||
for _, binding := range bindings {
|
||||
builtin, err := builtintype.Find(binding.typeKey)
|
||||
if err != nil {
|
||||
t.Fatalf("catalogue type %s: %v", binding.typeKey, err)
|
||||
}
|
||||
byKey := map[string]contenttype.Field{}
|
||||
for _, f := range builtin.Fields {
|
||||
byKey[f.Key] = f
|
||||
}
|
||||
structType := reflect.TypeOf(binding.request)
|
||||
for _, key := range binding.fields {
|
||||
catalogueField, ok := byKey[key]
|
||||
if !ok {
|
||||
t.Errorf("%s.%s missing from catalogue", binding.typeKey, key)
|
||||
continue
|
||||
}
|
||||
tag, found := driftTagFor(t, structType, key)
|
||||
if !found {
|
||||
t.Errorf("%s has no json field %s", structType.Name(), key)
|
||||
continue
|
||||
}
|
||||
rules := driftParseTag(tag)
|
||||
covered[binding.typeKey+"."+key] = true
|
||||
label := binding.typeKey + "." + key + " vs " + structType.Name()
|
||||
if binding.checkRequired && rules.required != catalogueField.Required {
|
||||
t.Errorf("%s: required catalogue=%v tag=%v", label, catalogueField.Required, rules.required)
|
||||
}
|
||||
if !reflect.DeepEqual(rules.min, catalogueField.Min) {
|
||||
t.Errorf("%s: min catalogue=%v tag=%v", label, catalogueField.Min, rules.min)
|
||||
}
|
||||
if !reflect.DeepEqual(rules.max, catalogueField.Max) {
|
||||
t.Errorf("%s: max catalogue=%v tag=%v", label, catalogueField.Max, rules.max)
|
||||
}
|
||||
if !reflect.DeepEqual(driftSorted(rules.options), driftSorted(catalogueField.Options)) {
|
||||
t.Errorf("%s: options catalogue=%v tag=%v", label, catalogueField.Options, rules.options)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(covered) != 13 {
|
||||
t.Errorf("covered catalogue fields = %d, want 13", len(covered))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/hashing"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const supportRecipeKey = "recipe"
|
||||
|
||||
var supportContentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
|
||||
|
||||
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
|
||||
|
||||
func newSupportEnv(t *testing.T) *contentTypeEnv {
|
||||
t.Helper()
|
||||
env := newContentTypeEnv(t)
|
||||
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, Role: user.RoleAdmin}
|
||||
return env
|
||||
}
|
||||
|
||||
func recipeWithIcon(icon any, present bool) string {
|
||||
body := map[string]any{"key": supportRecipeKey, "name": "Recipe", "fields": recipeFields()}
|
||||
if present {
|
||||
body["icon"] = icon
|
||||
}
|
||||
encoded, _ := json.Marshal(body)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func recipeUpdate(icon any, present bool) string {
|
||||
body := map[string]any{"name": "Recipe", "fields": recipeFields()}
|
||||
if present {
|
||||
body["icon"] = icon
|
||||
}
|
||||
encoded, _ := json.Marshal(body)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func rawJSONValue(t *testing.T, rec *httptest.ResponseRecorder, key string) string {
|
||||
t.Helper()
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("failed to decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
raw, present := decoded[key]
|
||||
if !present {
|
||||
t.Fatalf("key %q missing in %q", key, rec.Body.String())
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func decodeContentArray(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any {
|
||||
t.Helper()
|
||||
var decoded []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("failed to decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func expectIconEverywhere(t *testing.T, env *contentTypeEnv, want string) {
|
||||
t.Helper()
|
||||
one := env.admin(t, http.MethodGet, "/v1/content-type/"+supportRecipeKey, "")
|
||||
if got := rawJSONValue(t, one, "icon"); got != want {
|
||||
t.Errorf("GET one: expected icon %s, got %s", want, got)
|
||||
}
|
||||
list := env.admin(t, http.MethodGet, "/v1/content-type", "")
|
||||
var items []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(list.Body.Bytes(), &items); err != nil || len(items) != 1 {
|
||||
t.Fatalf("unexpected list %q: %v", list.Body.String(), err)
|
||||
}
|
||||
if got := string(items[0]["icon"]); got != want {
|
||||
t.Errorf("GET list: expected icon %s, got %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypeIconHTTP(t *testing.T) {
|
||||
t.Run("Creating a content type with an icon", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
rec := env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon("chef-hat", true))
|
||||
expectStatusAndBody(t, rec, http.StatusCreated, "")
|
||||
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
|
||||
t.Errorf("expected keys %v, got %v", supportContentTypeKeys, got)
|
||||
}
|
||||
if got := rawJSONValue(t, rec, "icon"); got != `"chef-hat"` {
|
||||
t.Errorf("expected chef-hat, got %s", got)
|
||||
}
|
||||
expectIconEverywhere(t, env, `"chef-hat"`)
|
||||
})
|
||||
|
||||
t.Run("A content type without an icon has a null icon", func(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"no icon key": recipeWithIcon(nil, false),
|
||||
`"icon": null`: recipeWithIcon(nil, true),
|
||||
`"icon": ""`: recipeWithIcon("", true),
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
rec := env.admin(t, http.MethodPost, "/v1/content-type", body)
|
||||
expectStatusAndBody(t, rec, http.StatusCreated, "")
|
||||
if got := rawJSONValue(t, rec, "icon"); got != "null" {
|
||||
t.Errorf("expected null, got %s", got)
|
||||
}
|
||||
expectIconEverywhere(t, env, "null")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A content type stored before this slice still works", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
env.types.types[1] = contenttype.ContentType{ID: 1, Key: supportRecipeKey, Name: "Recipe", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}}
|
||||
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/"+supportRecipeKey, "")
|
||||
expectStatusAndBody(t, rec, http.StatusOK, "")
|
||||
if got := rawJSONValue(t, rec, "icon"); got != "null" {
|
||||
t.Errorf("expected null, got %s", got)
|
||||
}
|
||||
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
|
||||
t.Errorf("unexpected keys %v", got)
|
||||
}
|
||||
expectIconEverywhere(t, env, "null")
|
||||
})
|
||||
|
||||
t.Run("An invalid icon is refused and stores nothing", func(t *testing.T) {
|
||||
invalid := []any{"Chef-Hat", "1star", "-star", "chef hat", "chef_hat", strings.Repeat("a", 41), 5}
|
||||
for _, icon := range invalid {
|
||||
t.Run(fmt.Sprint(icon), func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
rec := env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon(icon, true))
|
||||
expectStatusAndBody(t, rec, http.StatusBadRequest, apperr.InvalidIconErrorMessage)
|
||||
if len(env.types.types) != 0 {
|
||||
t.Errorf("expected nothing stored, got %d", len(env.types.types))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Boundary icons are accepted", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
for index, icon := range []string{"a", "a" + strings.Repeat("b", 39)} {
|
||||
body := map[string]any{"key": "recipe" + strconv.Itoa(index), "name": "Recipe", "icon": icon, "fields": recipeFields()}
|
||||
encoded, _ := json.Marshal(body)
|
||||
expectStatusAndBody(t, env.admin(t, http.MethodPost, "/v1/content-type", string(encoded)), http.StatusCreated, "")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Updating the icon of a content type", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
message string
|
||||
icon string
|
||||
}{
|
||||
{"utensils", recipeUpdate("utensils", true), http.StatusOK, "", `"utensils"`},
|
||||
{"no icon key", recipeUpdate(nil, false), http.StatusOK, "", "null"},
|
||||
{"null", recipeUpdate(nil, true), http.StatusOK, "", "null"},
|
||||
{"bad icon", recipeUpdate("Bad Icon", true), http.StatusBadRequest, apperr.InvalidIconErrorMessage, `"chef-hat"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
expectStatusAndBody(t, env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon("chef-hat", true)), http.StatusCreated, "")
|
||||
rec := env.admin(t, http.MethodPut, "/v1/content-type/"+supportRecipeKey, tc.body)
|
||||
expectStatusAndBody(t, rec, tc.status, tc.message)
|
||||
expectIconEverywhere(t, env, tc.icon)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The icon does not affect entries", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
expectStatusAndBody(t, env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon("chef-hat", true)), http.StatusCreated, "")
|
||||
created := env.createEntry(t, supportRecipeKey, ctAdminId, `{"title": "Soup", "difficulty": "easy"}`)
|
||||
expectStatusAndBody(t, created, http.StatusCreated, "")
|
||||
before := env.entries.entries[1]
|
||||
expectStatusAndBody(t, env.admin(t, http.MethodPut, "/v1/content-type/"+supportRecipeKey, recipeUpdate("utensils", true)), http.StatusOK, "")
|
||||
if after := env.entries.entries[1]; !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("entry changed: %v -> %v", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContentTypeIconUseCase(t *testing.T) {
|
||||
invalid := []string{"Chef-Hat", "1star", "-star", "chef hat", "chef_hat", strings.Repeat("a", 41)}
|
||||
now := func() time.Time { return time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) }
|
||||
fields := []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}
|
||||
for _, icon := range invalid {
|
||||
t.Run("use case rejects "+icon, func(t *testing.T) {
|
||||
repo := newMockContentTypeRepo()
|
||||
repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields}
|
||||
bad := icon
|
||||
_, err := useCases.NewCreateContentTypeUseCase(repo, now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
|
||||
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.createCalls != 0 {
|
||||
t.Errorf("create: err %v, calls %d", err, repo.createCalls)
|
||||
}
|
||||
_, err = useCases.NewUpdateContentTypeUseCase(repo, newMockEntryRepo(), now).Update(useCases.UpdateContentTypeCommand{Key: "dish", Name: "Dish", Icon: &bad, Fields: fields})
|
||||
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.updateCalls != 0 {
|
||||
t.Errorf("update: err %v, calls %d", err, repo.updateCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("domain rule", func(t *testing.T) {
|
||||
empty, valid := "", "chef-hat"
|
||||
if got, err := contenttype.ValidateIcon(&empty); got != nil || err != nil {
|
||||
t.Errorf("empty icon should become nil, got %v %v", got, err)
|
||||
}
|
||||
if got, err := contenttype.ValidateIcon(&valid); err != nil || *got != valid {
|
||||
t.Errorf("valid icon refused: %v", err)
|
||||
}
|
||||
if got, err := contenttype.ValidateIcon(nil); got != nil || err != nil {
|
||||
t.Errorf("nil icon should stay nil, got %v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func checkBuiltinItem(t *testing.T, item map[string]any) {
|
||||
t.Helper()
|
||||
if got := objectKeys(item); !reflect.DeepEqual(got, []string{"fields", "key", "name"}) {
|
||||
t.Fatalf("unexpected item keys %v", got)
|
||||
}
|
||||
engineTypes := map[string]bool{"text": true, "richtext": true, "number": true, "boolean": true, "date": true, "select": true}
|
||||
for _, raw := range item["fields"].([]any) {
|
||||
field := raw.(map[string]any)
|
||||
if got := objectKeys(field); !reflect.DeepEqual(got, supportFieldKeys) {
|
||||
t.Errorf("unexpected field keys %v", got)
|
||||
}
|
||||
if !engineTypes[field["type"].(string)] {
|
||||
t.Errorf("unknown field type %v", field["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func builtinFieldKeys(item map[string]any) []string {
|
||||
keys := []string{}
|
||||
for _, raw := range item["fields"].([]any) {
|
||||
keys = append(keys, raw.(map[string]any)["key"].(string))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestBuiltinTypeHTTP(t *testing.T) {
|
||||
t.Run("Listing the built-in types", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
rec := env.as(t, ctOwnerId, http.MethodGet, "/v1/builtin-type", "")
|
||||
expectStatusAndBody(t, rec, http.StatusOK, "")
|
||||
items := decodeContentArray(t, rec)
|
||||
keys, names := []string{}, []string{}
|
||||
for _, item := range items {
|
||||
checkBuiltinItem(t, item)
|
||||
keys = append(keys, item["key"].(string))
|
||||
names = append(names, item["name"].(string))
|
||||
}
|
||||
if !reflect.DeepEqual(keys, []string{"article", "author", "site", "media", "user"}) {
|
||||
t.Errorf("unexpected keys %v", keys)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"Articles", "Authors", "Sites", "Media", "Users"}) {
|
||||
t.Errorf("unexpected names %v", names)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The built-in field lists match the real API", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
want := map[string]string{
|
||||
"article": "title, body, slug, excerpt, seo_title, seo_description, is_online, canonical_url, noindex",
|
||||
"author": "name, slug, bio, website",
|
||||
"site": "name, key, base_url, default_robots, locale, is_default, site_name, title_template, default_description",
|
||||
"media": "filename, size, width, height, content_type",
|
||||
"user": "username, display_name, email, role",
|
||||
}
|
||||
forbidden := []string{"id", "created_at", "updated_at", "password", "password_reset_code", "verification_code", "token_version"}
|
||||
for key, fields := range want {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
rec := env.admin(t, http.MethodGet, "/v1/builtin-type/"+key, "")
|
||||
expectStatusAndBody(t, rec, http.StatusOK, "")
|
||||
got := builtinFieldKeys(decodeContentObject(t, rec))
|
||||
if strings.Join(got, ", ") != fields {
|
||||
t.Errorf("expected %s, got %v", fields, got)
|
||||
}
|
||||
for _, name := range got {
|
||||
for _, bad := range forbidden {
|
||||
if name == bad {
|
||||
t.Errorf("forbidden field %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Selected built-in field rules", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
cases := []struct {
|
||||
key, field, fieldType string
|
||||
required bool
|
||||
min, max any
|
||||
options []any
|
||||
}{
|
||||
{"article", "title", "text", true, float64(3), float64(200), []any{}},
|
||||
{"article", "body", "richtext", false, nil, nil, []any{}},
|
||||
{"article", "excerpt", "text", false, nil, float64(300), []any{}},
|
||||
{"article", "is_online", "boolean", false, nil, nil, []any{}},
|
||||
{"site", "default_robots", "select", false, nil, nil, []any{"index", "noindex"}},
|
||||
{"site", "site_name", "text", false, nil, float64(100), []any{}},
|
||||
{"site", "title_template", "text", false, nil, float64(200), []any{}},
|
||||
{"site", "default_description", "text", false, nil, float64(320), []any{}},
|
||||
{"user", "role", "select", true, nil, nil, []any{"owner", "admin"}},
|
||||
{"user", "email", "text", true, nil, nil, []any{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.key+"."+tc.field, func(t *testing.T) {
|
||||
item := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/builtin-type/"+tc.key, ""))
|
||||
for _, raw := range item["fields"].([]any) {
|
||||
field := raw.(map[string]any)
|
||||
if field["key"] != tc.field {
|
||||
continue
|
||||
}
|
||||
if field["type"] != tc.fieldType || field["required"] != tc.required || field["min"] != tc.min || field["max"] != tc.max || !reflect.DeepEqual(field["options"], tc.options) {
|
||||
t.Errorf("unexpected field %v", field)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Errorf("field %s missing", tc.field)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reading one built-in type does not collide with a custom type of the same key", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
env.createType(t, "builtin", dishFields())
|
||||
env.createType(t, "article", dishFields())
|
||||
for _, key := range []string{"builtin", "article"} {
|
||||
rec := env.admin(t, http.MethodGet, "/v1/content-type/"+key, "")
|
||||
expectStatusAndBody(t, rec, http.StatusOK, "")
|
||||
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
|
||||
t.Errorf("%s is not the custom type: %v", key, got)
|
||||
}
|
||||
}
|
||||
item := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/builtin-type/article", ""))
|
||||
if item["name"] != "Articles" {
|
||||
t.Errorf("expected the built-in article, got %v", item["name"])
|
||||
}
|
||||
listed := decodeContentArray(t, env.admin(t, http.MethodGet, "/v1/content-type", ""))
|
||||
if len(listed) != 2 {
|
||||
t.Errorf("expected 2 custom types, got %d", len(listed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The built-in routes are read-only and restricted", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
stale := func(req *http.Request) {
|
||||
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
apiKey := env.issueKey(ctAdminId)
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
mutate func(*http.Request)
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"owner 1 cookie", http.MethodGet, "/v1/builtin-type", env.asUser(t, ctOwnerId), 200, ""},
|
||||
{"admin 2 cookie", http.MethodGet, "/v1/builtin-type/user", env.asUser(t, ctAdminId), 200, ""},
|
||||
{"an active API key of admin 2", http.MethodGet, "/v1/builtin-type", func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+apiKey) }, 200, ""},
|
||||
{"a cookie for unverified 4", http.MethodGet, "/v1/builtin-type", env.asUser(t, ctUnverifiedId), 403, apperr.VerificationRequiredErrorMessage},
|
||||
{"no credentials", http.MethodGet, "/v1/builtin-type", nil, 401, ""},
|
||||
{"a stale token_version cookie", http.MethodGet, "/v1/builtin-type", stale, 401, ""},
|
||||
{"unknown key", http.MethodGet, "/v1/builtin-type/nope", env.asUser(t, ctAdminId), 404, apperr.BuiltinTypeNotFoundErrorMessage},
|
||||
{"uppercase key", http.MethodGet, "/v1/builtin-type/Article", env.asUser(t, ctAdminId), 404, apperr.BuiltinTypeNotFoundErrorMessage},
|
||||
{"POST", http.MethodPost, "/v1/builtin-type", env.asUser(t, ctAdminId), 405, ""},
|
||||
{"PUT", http.MethodPut, "/v1/builtin-type/article", env.asUser(t, ctAdminId), 405, ""},
|
||||
{"DELETE", http.MethodDelete, "/v1/builtin-type/article", env.asUser(t, ctAdminId), 405, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := env.request(t, tc.method, tc.path, "", tc.mutate)
|
||||
expectStatusAndBody(t, rec, tc.status, tc.message)
|
||||
if tc.status == http.StatusMethodNotAllowed && rec.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body, got %q", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reading the built-in types never touches data", func(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
users := newMockUserRepo()
|
||||
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
now := func() time.Time { return time.Now() }
|
||||
generator := hashing.NewSHA256KeyGenerator()
|
||||
container := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now),
|
||||
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, now),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
||||
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
|
||||
for _, path := range []string{"/v1/builtin-type", "/v1/builtin-type/site"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type panickingContentTypeRepo struct{ contenttype.Repository }
|
||||
|
||||
type panickingEntryRepo struct{ entry.Repository }
|
||||
|
||||
func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) {
|
||||
entities := map[string]any{
|
||||
builtintype.KeyArticle: article.Article{},
|
||||
builtintype.KeyAuthor: author.Author{},
|
||||
builtintype.KeySite: site.Site{},
|
||||
builtintype.KeyMedia: image.Image{},
|
||||
builtintype.KeyUser: user.User{},
|
||||
}
|
||||
for key, entity := range entities {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
encoded, err := json.Marshal(entity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var object map[string]any
|
||||
_ = json.Unmarshal(encoded, &object)
|
||||
found, err := builtintype.Find(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range found.Fields {
|
||||
if _, exists := object[field.Key]; !exists {
|
||||
t.Errorf("catalogue field %s.%s does not exist in the entity JSON", key, field.Key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinTypeUseCases(t *testing.T) {
|
||||
if got := useCases.NewListBuiltinTypesUseCase().List(); len(got) != 5 {
|
||||
t.Errorf("expected 5 types, got %d", len(got))
|
||||
}
|
||||
if _, err := useCases.NewGetBuiltinTypeUseCase().Get("nope"); !errors.Is(err, builtintype.ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
found, err := useCases.NewGetBuiltinTypeUseCase().Get("site")
|
||||
if err != nil || found.Name != "Sites" {
|
||||
t.Errorf("unexpected %v %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupQuery(ids ...int) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, strconv.Itoa(id))
|
||||
}
|
||||
return "?ids=" + strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func TestUserLookupHTTP(t *testing.T) {
|
||||
t.Run("Any verified user resolves display names", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup?ids=1,2,3", "")
|
||||
expectStatusAndBody(t, rec, http.StatusOK, "")
|
||||
items := decodeContentArray(t, rec)
|
||||
wantNames := []string{"Marta Alves", "dev", "Sam Roy"}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("expected 3 items, got %v", items)
|
||||
}
|
||||
for index, item := range items {
|
||||
if got := objectKeys(item); !reflect.DeepEqual(got, []string{"display_name", "id"}) {
|
||||
t.Errorf("unexpected keys %v", got)
|
||||
}
|
||||
if item["id"] != float64(index+1) || item["display_name"] != wantNames[index] {
|
||||
t.Errorf("unexpected item %v", item)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{"email", "role", "username", "status", "password"} {
|
||||
if strings.Contains(rec.Body.String(), leaked) {
|
||||
t.Errorf("body leaks %s", leaked)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Lookup edge cases", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
var hundred, overflow []int
|
||||
for id := 1; id <= 100; id++ {
|
||||
hundred = append(hundred, id)
|
||||
}
|
||||
overflow = append(hundred, 101)
|
||||
cases := []struct {
|
||||
name, query string
|
||||
status int
|
||||
body string
|
||||
count int
|
||||
}{
|
||||
{"duplicates", "?ids=2,2,2", 200, "", 1},
|
||||
{"one unknown", "?ids=1,999", 200, "", 1},
|
||||
{"all unknown", "?ids=999", 200, "[]", 0},
|
||||
{"unverified user", "?ids=4", 200, "", 1},
|
||||
{"no query", "", 400, apperr.BodyErrorMessage, 0},
|
||||
{"empty", "?ids=", 400, apperr.BodyErrorMessage, 0},
|
||||
{"letters", "?ids=abc", 400, apperr.BodyErrorMessage, 0},
|
||||
{"negative", "?ids=1,-2", 400, apperr.BodyErrorMessage, 0},
|
||||
{"overflow", "?ids=1,4294967296", 400, apperr.BodyErrorMessage, 0},
|
||||
{"empty element", "?ids=1,,2", 400, apperr.BodyErrorMessage, 0},
|
||||
{"101 distinct ids", lookupQuery(overflow...), 400, apperr.BodyErrorMessage, 0},
|
||||
{"exactly 100 ids", lookupQuery(hundred...), 200, "", 5},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup"+tc.query, "")
|
||||
expectStatusAndBody(t, rec, tc.status, tc.body)
|
||||
if tc.status == http.StatusOK {
|
||||
if got := len(decodeContentArray(t, rec)); got != tc.count {
|
||||
t.Errorf("expected %d items, got %d", tc.count, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Repository failure is a 500, not a 400", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
env.users.fail.failNext("FindByIDs")
|
||||
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup?ids=1", "")
|
||||
expectStatusAndBody(t, rec, http.StatusInternalServerError, "Internal server error")
|
||||
})
|
||||
|
||||
t.Run("More than the maximum raw parts is rejected", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup?ids="+strings.Repeat("1,", 100)+"1", "")
|
||||
expectStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("Lookup needs a verified signed-in user or an API key", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
apiKey := env.issueKey(ctAdminId)
|
||||
stale := func(req *http.Request) {
|
||||
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*http.Request)
|
||||
status int
|
||||
}{
|
||||
{"verified user 3", env.asUser(t, ctMemberId), 200},
|
||||
{"API key", func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+apiKey) }, 200},
|
||||
{"no credentials", nil, 401},
|
||||
{"unverified 4", env.asUser(t, ctUnverifiedId), 403},
|
||||
{"stale token", stale, 401},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := env.request(t, http.MethodGet, "/v1/user/lookup?ids=1", "", tc.mutate)
|
||||
if rec.Code != tc.status {
|
||||
t.Errorf("expected %d, got %d (%s)", tc.status, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Existing user and entry responses are unchanged", func(t *testing.T) {
|
||||
env := newSupportEnv(t)
|
||||
env.createType(t, supportRecipeKey, recipeFields())
|
||||
expectStatusAndBody(t, env.createEntry(t, supportRecipeKey, ctMemberId, `{"title": "Soup", "difficulty": "easy"}`), http.StatusCreated, "")
|
||||
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/", "")
|
||||
expectStatusAndBody(t, rec, http.StatusOK, "")
|
||||
list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/"+supportRecipeKey+"/entry", "")
|
||||
expectStatusAndBody(t, list, http.StatusOK, "")
|
||||
var page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(list.Body.Bytes(), &page); err != nil || len(page.Items) != 1 {
|
||||
t.Fatalf("unexpected page %q: %v", list.Body.String(), err)
|
||||
}
|
||||
if page.Items[0]["created_by"] != float64(ctMemberId) || page.Items[0]["updated_by"] != float64(ctMemberId) {
|
||||
t.Errorf("created_by and updated_by should stay numeric ids: %v", page.Items[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLookupUserNamesUseCase(t *testing.T) {
|
||||
users := newMockUserRepo()
|
||||
users.users[1] = user.User{ID: 1, Username: "marta", DisplayName: "Marta Alves"}
|
||||
users.users[2] = user.User{ID: 2, Username: "dev"}
|
||||
lookup := useCases.NewLookupUserNamesUseCase(users)
|
||||
|
||||
t.Run("display name, username fallback, unknown omitted, one batch call", func(t *testing.T) {
|
||||
names, err := lookup.Lookup([]uint32{2, 999, 1, 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []useCases.UserName{{ID: 1, DisplayName: "Marta Alves"}, {ID: 2, DisplayName: "dev"}}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Errorf("expected %v, got %v", want, names)
|
||||
}
|
||||
if users.findByIDsCalls != 1 {
|
||||
t.Errorf("expected a single batch call, got %d", users.findByIDsCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly the maximum number of ids is accepted", func(t *testing.T) {
|
||||
atLimit := make([]uint32, 0, user.MaxLookupIDs)
|
||||
for id := uint32(1); id <= user.MaxLookupIDs; id++ {
|
||||
atLimit = append(atLimit, id)
|
||||
}
|
||||
if _, err := lookup.Lookup(atLimit); err != nil {
|
||||
t.Errorf("expected no error at the limit, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no ids or too many ids", func(t *testing.T) {
|
||||
if _, err := lookup.Lookup(nil); !errors.Is(err, user.ErrInvalidLookup) {
|
||||
t.Errorf("expected ErrInvalidLookup, got %v", err)
|
||||
}
|
||||
tooMany := make([]uint32, 0, 101)
|
||||
for id := uint32(1); id <= 101; id++ {
|
||||
tooMany = append(tooMany, id)
|
||||
}
|
||||
if _, err := lookup.Lookup(tooMany); !errors.Is(err, user.ErrInvalidLookup) {
|
||||
t.Errorf("expected ErrInvalidLookup, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContentTypeIconMapperRoundTrip(t *testing.T) {
|
||||
icon := "chef-hat"
|
||||
for name, stored := range map[string]*string{"nil": nil, "set": &icon} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
model, err := mappers.ContentTypeToModel(contenttype.ContentType{Key: "recipe", Icon: stored, Fields: []contenttype.Field{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := mappers.ContentTypeToDomain(model)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(back.Icon, stored) {
|
||||
t.Errorf("expected %v, got %v", stored, back.Icon)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -241,19 +241,23 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
|
||||
env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1}
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
|
||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
|
||||
CreateEntryUseCase: useCases.NewCreateEntryUseCase(env.types, env.entries, now),
|
||||
GetEntryUseCase: useCases.NewGetEntryUseCase(env.types, env.entries),
|
||||
ListEntriesUseCase: useCases.NewListEntriesUseCase(env.types, env.entries),
|
||||
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(env.types, env.entries, now),
|
||||
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(env.types, env.entries),
|
||||
Token: env.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
|
||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users),
|
||||
CreateEntryUseCase: useCases.NewCreateEntryUseCase(env.types, env.entries, now),
|
||||
GetEntryUseCase: useCases.NewGetEntryUseCase(env.types, env.entries),
|
||||
ListEntriesUseCase: useCases.NewListEntriesUseCase(env.types, env.entries),
|
||||
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(env.types, env.entries, now),
|
||||
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(env.types, env.entries),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
contentTypeKeys = []string{"created_at", "fields", "id", "key", "name", "updated_at"}
|
||||
contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
|
||||
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
|
||||
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "updated_at", "updated_by"}
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ func (s *stubHasher) Compare(hashed string, value string) error {
|
||||
type mockUserRepo struct {
|
||||
users map[uint32]user.User
|
||||
nextID uint32
|
||||
findByIDsCalls int
|
||||
fail failSwitch
|
||||
clearVerificationCodeCalls int
|
||||
clearVerificationCodeUserIds []uint32
|
||||
@@ -112,6 +113,20 @@ func (m *mockUserRepo) GetAll() []user.User {
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindByIDs(ids []uint32) ([]user.User, error) {
|
||||
if err := m.fail.trigger("FindByIDs"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.findByIDsCalls++
|
||||
found := []user.User{}
|
||||
for _, id := range ids {
|
||||
if stored, exists := m.users[id]; exists {
|
||||
found = append(found, stored)
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(u user.User) (user.User, error) {
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
return user.User{}, err
|
||||
|
||||
Reference in new issue
Block a user