feat: custom content type icons, built-in types, user lookup

This commit is contained in:
Florian Sylvain committed 2026-10-19 18:22:55 +02:00
1 parent e6e088c963
commit 73a75d5cb9
31 files changed
+1611 -36

No files matched your search

+117
View File
@@ -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)
}
})
}
}
+17 -13
View File
@@ -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{}))
+1 -1
View File
@@ -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"}
)
+15
View File
@@ -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