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
@@ -70,6 +70,9 @@ type UseCases struct {
|
||||
GetContentTypeUseCase *useCases.GetContentTypeUseCase
|
||||
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
|
||||
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
|
||||
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
|
||||
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
|
||||
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
|
||||
CreateEntryUseCase *useCases.CreateEntryUseCase
|
||||
GetEntryUseCase *useCases.GetEntryUseCase
|
||||
ListEntriesUseCase *useCases.ListEntriesUseCase
|
||||
@@ -366,6 +369,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
|
||||
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package builtintype
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/response"
|
||||
domain "RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ListUseCase *useCases.ListBuiltinTypesUseCase
|
||||
GetUseCase *useCases.GetBuiltinTypeUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListBuiltinTypesUseCase, get *useCases.GetBuiltinTypeUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, GetUseCase: get}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, http.StatusOK, h.ListUseCase.List())
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
http.Error(w, apperr.BuiltinTypeNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, found)
|
||||
}
|
||||
@@ -26,15 +26,35 @@ func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListCo
|
||||
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type iconInput struct {
|
||||
value *string
|
||||
invalid bool
|
||||
}
|
||||
|
||||
func (i *iconInput) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
i.invalid = true
|
||||
return nil
|
||||
}
|
||||
i.value = &text
|
||||
return nil
|
||||
}
|
||||
|
||||
type PostContentType struct {
|
||||
Key string `json:"key" validate:"required"`
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Icon iconInput `json:"icon"`
|
||||
Fields []domain.Field `json:"fields"`
|
||||
}
|
||||
|
||||
type PutContentType struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Icon iconInput `json:"icon"`
|
||||
Fields []domain.Field `json:"fields"`
|
||||
}
|
||||
|
||||
@@ -42,23 +62,22 @@ type contentTypeResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
Fields []domain.Field `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toResponse(contentType domain.ContentType) contentTypeResponse {
|
||||
fields := make([]domain.Field, 0, len(contentType.Fields))
|
||||
for _, field := range contentType.Fields {
|
||||
if field.Options == nil {
|
||||
field.Options = []string{}
|
||||
}
|
||||
fields = append(fields, field)
|
||||
fields := contentType.Fields
|
||||
if fields == nil {
|
||||
fields = []domain.Field{}
|
||||
}
|
||||
return contentTypeResponse{
|
||||
ID: contentType.ID,
|
||||
Key: contentType.Key,
|
||||
Name: contentType.Name,
|
||||
Icon: contentType.Icon,
|
||||
Fields: fields,
|
||||
CreatedAt: contentType.CreatedAt,
|
||||
UpdatedAt: contentType.UpdatedAt,
|
||||
@@ -77,6 +96,8 @@ func writeContentTypeError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrInvalidName):
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidIcon):
|
||||
http.Error(w, apperr.InvalidIconErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidKey):
|
||||
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidField):
|
||||
@@ -118,7 +139,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Fields: request.Fields})
|
||||
if request.Icon.invalid {
|
||||
writeContentTypeError(w, domain.ErrInvalidIcon)
|
||||
return
|
||||
}
|
||||
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
|
||||
if err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
@@ -136,7 +161,11 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Fields: request.Fields})
|
||||
if request.Icon.invalid {
|
||||
writeContentTypeError(w, domain.ErrInvalidIcon)
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
|
||||
if err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/response"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LookupHandler struct {
|
||||
LookupUseCase *useCases.LookupUserNamesUseCase
|
||||
}
|
||||
|
||||
type userNameResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func NewLookupHandler(lookup *useCases.LookupUserNamesUseCase) *LookupHandler {
|
||||
return &LookupHandler{LookupUseCase: lookup}
|
||||
}
|
||||
|
||||
func parseLookupIDs(raw string) ([]uint32, bool) {
|
||||
if raw == "" {
|
||||
return nil, false
|
||||
}
|
||||
if strings.Count(raw, ",")+1 > domainUser.MaxLookupIDs {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]uint32, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id, err := strconv.ParseUint(part, 10, 32)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
ids = append(ids, uint32(id))
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
|
||||
func (h *LookupHandler) Lookup(w http.ResponseWriter, r *http.Request) {
|
||||
ids, ok := parseLookupIDs(r.URL.Query().Get("ids"))
|
||||
if !ok {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
names, err := h.LookupUseCase.Lookup(ids)
|
||||
if errors.Is(err, domainUser.ErrInvalidLookup) {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
items := make([]userNameResponse, 0, len(names))
|
||||
for _, name := range names {
|
||||
items = append(items, userNameResponse{ID: name.ID, DisplayName: name.DisplayName})
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, items)
|
||||
}
|
||||
@@ -91,6 +91,8 @@ const ContentTypeNotFoundErrorMessage = "content_type_not_found"
|
||||
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
|
||||
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
|
||||
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
|
||||
const InvalidIconErrorMessage = "invalid_icon"
|
||||
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
|
||||
const InvalidFieldErrorMessage = "invalid_field"
|
||||
const SchemaIncompatibleErrorMessage = "schema_incompatible"
|
||||
const EntryNotFoundErrorMessage = "entry_not_found"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/handlers/author"
|
||||
"RenewCMS/api/handlers/builtintype"
|
||||
"RenewCMS/api/handlers/contenttype"
|
||||
"RenewCMS/api/handlers/entry"
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
@@ -133,6 +134,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r := chi.NewRouter()
|
||||
|
||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
@@ -143,6 +145,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
r.Get("/", inviteHandler.ListUsers)
|
||||
r.Get("/lookup", lookupHandler.Lookup)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
@@ -276,6 +279,24 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
|
||||
return r
|
||||
}
|
||||
|
||||
func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/", builtinTypeHandler.List)
|
||||
r.Get("/{key}", builtinTypeHandler.Get)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -427,6 +448,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
A content type is a named schema (a key, a name and 1 to 50 fields) stored as one row with the fields as a JSON text column. Entries of every type share one generic table whose `data` column holds a JSON object. No DDL runs at runtime, so creating or changing a type never alters the database schema.
|
||||
|
||||
## Icon, built-in types and user lookup
|
||||
|
||||
- A content type has an optional `icon`: `null` or a string matching `^[a-z][a-z0-9-]{0,39}$`, validated in the domain (`ErrInvalidIcon`, `400 invalid_icon`) and never checked against a list of known icons. `""` is stored as `null`. `PUT` replaces the icon like the name and the fields, so an absent icon clears it. The column is nullable, so rows stored before it existed read as `null`.
|
||||
- `GET /v1/builtin-type` and `GET /v1/builtin-type/{key}` expose a static, read-only catalogue (`internal/domain/builtintype`) of the built-in types `article`, `author`, `site`, `media` and `user` as `key`, `name` and `fields` shaped like content type fields. Owners and admins only. It uses no repository; an unknown key is `404 builtin_type_not_found`. A test checks that every catalogue field key exists in the JSON of the matching entity.
|
||||
- `GET /v1/user/lookup?ids=1,2,3` returns `[{id, display_name}]` for any verified user, ordered by id, with the username when the display name is empty. 1 to 100 distinct uint32 ids; anything else is `400`. Unknown ids are omitted.
|
||||
|
||||
## Layering
|
||||
|
||||
The domain packages `internal/domain/contenttype` and `internal/domain/entry` own the rules: key, name and field validation, entry validation against a schema, the sort whitelist, the page limits and the value equality used by uniqueness. The use cases orchestrate them through the two repositories. The repositories only store and load rows. Entry counting for the "type still has entries" rule lives on the entry repository.
|
||||
|
||||
@@ -35,7 +35,7 @@ Feature: Owners and admins define custom content types, and users manage entries
|
||||
AuditContext, RequireSignedIn, jwtauth.Authenticator, RequireCurrentToken and
|
||||
RequireVerifiedUser, and POST /v1/content-type, PUT /v1/content-type/{key} and
|
||||
DELETE /v1/content-type/{key} additionally sit behind RequireOwnerOrAdmin
|
||||
And a content type response has exactly the keys id, key, name, fields, created_at, updated_at
|
||||
And a content type response has exactly the keys id, key, name, icon, fields, created_at, updated_at
|
||||
And a field response has exactly the keys key, label, type, required, unique, default, min, max,
|
||||
options, with null for an unset default, min and max and [] for no options
|
||||
And an entry response has exactly the keys id, content_type, data, created_by, updated_by,
|
||||
@@ -58,7 +58,7 @@ Feature: Owners and admins define custom content types, and users manage entries
|
||||
When the client sends POST /v1/content-type with body
|
||||
{"key": "recipe", "name": "Recipe", "fields": [<the sample fields>]}
|
||||
Then the response status is 201
|
||||
And the body has exactly the keys id, key, name, fields, created_at, updated_at
|
||||
And the body has exactly the keys id, key, name, icon, fields, created_at, updated_at
|
||||
And key is "recipe" and the fields come back in the order sent
|
||||
And each field has exactly the keys key, label, type, required, unique, default, min, max, options
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
6. Helper `sampleRecipe()` builds the "recipe" request body from the feature's table.
|
||||
|
||||
## Content type create and validation
|
||||
7. Admin 2 `POST /v1/content-type` with the recipe -> `201`; key set is exactly `id`, `key`, `name`,
|
||||
7. Admin 2 `POST /v1/content-type` with the recipe -> `201`; key set is exactly `id`, `key`, `name`, `icon`,
|
||||
`fields`, `created_at`, `updated_at`; each field has exactly `key`, `label`, `type`, `required`,
|
||||
`unique`, `default`, `min`, `max`, `options`; order preserved; `default`/`min`/`max` are JSON `null`
|
||||
and `options` is `[]` when unset.
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names
|
||||
|
||||
As an owner, admin or verified user working in the SPA
|
||||
I want an icon per content type, a read-only view of the built-in types and the display names of entry authors
|
||||
So that the sidebar, the Schema screen and the entry screens can be drawn without leaking account data
|
||||
|
||||
Background:
|
||||
Given the existing content type contract of custom_content_types.feature is unchanged except
|
||||
that a content type response now has exactly the keys id, key, name, icon, fields, created_at,
|
||||
updated_at
|
||||
And a content type icon is null or a string matching ^[a-z][a-z0-9-]{0,39}$ and it is never
|
||||
checked against a list of known icons
|
||||
And internal/domain/contenttype defines the sentinel ErrInvalidIcon and the apperr string is
|
||||
invalid_icon
|
||||
And the icon is stored in a nullable column added by AutoMigrate, so existing rows read as null,
|
||||
and the fields column stays a JSON text column
|
||||
And an empty icon "" is stored as null
|
||||
And a PUT replaces the icon like it replaces the name and the fields, so an absent icon clears it
|
||||
And internal/domain/builtintype defines the static catalogue of the built-in types article,
|
||||
author, site, media and user, in this order, each with a name and a list of fields shaped like
|
||||
a content type field (key, label, type, required, unique, default, min, max, options)
|
||||
And the catalogue uses no repository and no database, and the use cases ListBuiltinTypes and
|
||||
GetBuiltinType depend only on that package
|
||||
And the routes GET /v1/builtin-type and GET /v1/builtin-type/{key} are mounted in a router with
|
||||
jwtauth.Verifier, apiKeyAuth, AuditContext, RequireSignedIn, jwtauth.Authenticator,
|
||||
RequireCurrentToken, RequireVerifiedUser and RequireOwnerOrAdmin
|
||||
And a built-in type response has exactly the keys key, name, fields
|
||||
And the new apperr string is builtin_type_not_found
|
||||
And the route GET /v1/user/lookup is mounted on the existing user router behind the verified
|
||||
user chain with no role requirement
|
||||
And a lookup item has exactly the keys id and display_name
|
||||
And users: owner id 1 "marta" with display name "Marta Alves", admin id 2 "dev" with no
|
||||
display name, verified id 3 "sam" with display name "Sam Roy", unverified id 4 "quinn"
|
||||
And only the roles owner and admin exist, so every verified user passes RequireOwnerOrAdmin
|
||||
And errors are plain text from http.Error
|
||||
|
||||
Scenario: Creating a content type with an icon
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/content-type with the recipe body and "icon": "chef-hat"
|
||||
Then the response status is 201
|
||||
And the body has exactly the keys id, key, name, icon, fields, created_at, updated_at
|
||||
And icon is "chef-hat"
|
||||
And GET /v1/content-type/recipe and GET /v1/content-type both show icon "chef-hat"
|
||||
|
||||
Scenario Outline: A content type without an icon has a null icon
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client creates "recipe" with <icon>
|
||||
Then the response status is 201 and icon is null in the response, in GET /v1/content-type/recipe and in the list
|
||||
|
||||
Examples:
|
||||
| icon |
|
||||
| no icon key |
|
||||
| "icon": null |
|
||||
| "icon": "" |
|
||||
|
||||
Scenario: A content type stored before this slice still works
|
||||
Given the content type "recipe" was stored with no icon value
|
||||
When a verified user sends GET /v1/content-type and GET /v1/content-type/recipe
|
||||
Then the response status is 200 and icon is null and all other keys are unchanged
|
||||
|
||||
Scenario Outline: An invalid icon is refused and stores nothing
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/content-type with a valid body and "icon": <icon>
|
||||
Then the response status is 400
|
||||
And the response body is invalid_icon
|
||||
And no content type exists in the repository
|
||||
|
||||
Examples:
|
||||
| icon |
|
||||
| "Chef-Hat" |
|
||||
| "1star" |
|
||||
| "-star" |
|
||||
| "chef hat" |
|
||||
| "chef_hat" |
|
||||
| "<41 characters>" |
|
||||
| 5 |
|
||||
|
||||
Scenario: Boundary icons are accepted
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client creates types with the icons "a" and a 40 character icon "a" followed by 39 "b"
|
||||
Then each response status is 201
|
||||
|
||||
Scenario Outline: Updating the icon of a content type
|
||||
Given the content type "recipe" exists with the icon "chef-hat" and has an entry
|
||||
When admin 2 sends PUT /v1/content-type/recipe with the same fields and <icon>
|
||||
Then the response status is <status>
|
||||
And <outcome>
|
||||
|
||||
Examples:
|
||||
| icon | status | outcome |
|
||||
| "icon": "utensils" | 200 | icon is "utensils" |
|
||||
| no icon key | 200 | icon is null |
|
||||
| "icon": null | 200 | icon is null |
|
||||
| "icon": "Bad Icon" | 400 | the body is invalid_icon and the icon is still "chef-hat" |
|
||||
|
||||
Scenario: The icon does not affect entries
|
||||
Given the content type "recipe" has an entry and admin 2 changes only the icon
|
||||
Then the response status is 200 and the entry data and its updated_at are unchanged
|
||||
|
||||
Scenario: Listing the built-in types
|
||||
Given a valid jwt cookie for owner 1
|
||||
When the client sends GET /v1/builtin-type
|
||||
Then the response status is 200
|
||||
And the body is a JSON array of 5 items with the keys article, author, site, media, user in this order
|
||||
And each item has exactly the keys key, name, fields
|
||||
And the names are "Articles", "Authors", "Sites", "Media" and "Users"
|
||||
And every field has exactly the keys key, label, type, required, unique, default, min, max, options
|
||||
And every field type is one of text, richtext, number, boolean, date, select
|
||||
|
||||
Scenario Outline: The built-in field lists match the real API
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends GET /v1/builtin-type/<key>
|
||||
Then the response status is 200
|
||||
And the field keys, in order, are <fields>
|
||||
And no field key is one of id, created_at, updated_at, password, password_reset_code, verification_code, token_version
|
||||
|
||||
Examples:
|
||||
| key | fields |
|
||||
| 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 |
|
||||
|
||||
Scenario Outline: Selected built-in field rules
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends GET /v1/builtin-type/<key>
|
||||
Then the field <field> has type <type>, required <required>, min <min>, max <max> and options <options>
|
||||
|
||||
Examples:
|
||||
| key | field | type | required | min | max | options |
|
||||
| article | title | text | true | 3 | 200 | [] |
|
||||
| article | body | richtext | false | null | null | [] |
|
||||
| article | excerpt | text | false | null | 300 | [] |
|
||||
| article | is_online | boolean | false | null | null | [] |
|
||||
| site | default_robots | select | false | null | null | index, noindex |
|
||||
| site | site_name | text | false | null | 100 | [] |
|
||||
| site | title_template | text | false | null | 200 | [] |
|
||||
| site | default_description | text | false | null | 320 | [] |
|
||||
| user | role | select | true | null | null | owner, admin |
|
||||
| user | email | text | true | null | null | [] |
|
||||
|
||||
Scenario: Reading one built-in type does not collide with a custom type of the same key
|
||||
Given the custom content type "builtin" exists, and a custom content type "article" exists
|
||||
And a valid jwt cookie for admin 2
|
||||
When the client sends GET /v1/content-type/builtin and GET /v1/content-type/article
|
||||
Then each response is the custom content type
|
||||
When the client sends GET /v1/builtin-type/article
|
||||
Then the response is the built-in article type
|
||||
And GET /v1/content-type never lists a built-in type
|
||||
|
||||
Scenario Outline: The built-in routes are read-only and restricted
|
||||
Given <caller>
|
||||
When the client sends <method> <path>
|
||||
Then the response status is <status>
|
||||
And the response body is <message>
|
||||
|
||||
Examples:
|
||||
| caller | method | path | status | message |
|
||||
| owner 1 cookie | GET | /v1/builtin-type | 200 | |
|
||||
| admin 2 cookie | GET | /v1/builtin-type/user | 200 | |
|
||||
| an active API key of admin 2 | GET | /v1/builtin-type | 200 | |
|
||||
| a cookie for unverified 4 | GET | /v1/builtin-type | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| no credentials | GET | /v1/builtin-type | 401 | the existing Authenticator response |
|
||||
| a stale token_version cookie | GET | /v1/builtin-type | 401 | the existing Authenticator response |
|
||||
| admin 2 cookie | GET | /v1/builtin-type/nope | 404 | builtin_type_not_found |
|
||||
| admin 2 cookie | GET | /v1/builtin-type/Article | 404 | builtin_type_not_found |
|
||||
| admin 2 cookie | POST | /v1/builtin-type | 405 | empty |
|
||||
| admin 2 cookie | PUT | /v1/builtin-type/article | 405 | empty |
|
||||
| admin 2 cookie | DELETE | /v1/builtin-type/article | 405 | empty |
|
||||
|
||||
Scenario: Reading the built-in types never touches data
|
||||
Given fake repositories for content types, entries, articles, authors, sites, media and users
|
||||
When the client sends GET /v1/builtin-type and GET /v1/builtin-type/site
|
||||
Then no repository method is called
|
||||
|
||||
Scenario: Any verified user resolves display names
|
||||
Given a valid jwt cookie for verified user 3
|
||||
When the client sends GET /v1/user/lookup?ids=1,2,3
|
||||
Then the response status is 200
|
||||
And the body is a JSON array of 3 items ordered by id
|
||||
And each item has exactly the keys id and display_name
|
||||
And the display names are "Marta Alves", "dev" (the username, because the display name is empty) and "Sam Roy"
|
||||
And the body contains no email, role, username or status
|
||||
|
||||
Scenario Outline: Lookup edge cases
|
||||
Given a valid jwt cookie for verified user 3
|
||||
When the client sends GET /v1/user/lookup<query>
|
||||
Then the response status is <status>
|
||||
And the response body is <message>
|
||||
|
||||
Examples:
|
||||
| query | status | message |
|
||||
| ?ids=2,2,2 | 200 | an array with one item for id 2 |
|
||||
| ?ids=1,999 | 200 | an array with only id 1 |
|
||||
| ?ids=999 | 200 | [] |
|
||||
| ?ids=4 | 200 | an array with the item for id 4 |
|
||||
| (no query) | 400 | apperr.BodyErrorMessage |
|
||||
| ?ids= | 400 | apperr.BodyErrorMessage |
|
||||
| ?ids=abc | 400 | apperr.BodyErrorMessage |
|
||||
| ?ids=1,-2 | 400 | apperr.BodyErrorMessage |
|
||||
| ?ids=1,4294967296 | 400 | apperr.BodyErrorMessage |
|
||||
| ?ids=1,,2 | 400 | apperr.BodyErrorMessage |
|
||||
| 101 distinct ids | 400 | apperr.BodyErrorMessage |
|
||||
| exactly 100 ids | 200 | an array of the existing users |
|
||||
|
||||
Scenario Outline: Lookup needs a verified signed-in user or an API key
|
||||
Given <caller>
|
||||
When the client sends GET /v1/user/lookup?ids=1
|
||||
Then the response status is <status>
|
||||
|
||||
Examples:
|
||||
| caller | status |
|
||||
| a cookie for verified user 3 | 200 |
|
||||
| an active API key of admin 2 | 200 |
|
||||
| no credentials | 401 |
|
||||
| a cookie for unverified 4 | 403 |
|
||||
| a stale token_version cookie | 401 |
|
||||
|
||||
Scenario: Existing user and entry responses are unchanged
|
||||
Given a valid jwt cookie for verified user 3
|
||||
When the client sends GET /v1/user/ and GET /v1/content-type/recipe/entry
|
||||
Then the responses keep their existing shapes, and created_by and updated_by stay numeric ids
|
||||
|
||||
Scenario: Use case: the icon rule returns a domain sentinel
|
||||
Given a fake contenttype.Repository
|
||||
When CreateContentType and UpdateContentType are called with each invalid icon of the table above
|
||||
Then errors.Is(err, contenttype.ErrInvalidIcon) is true and the repository received no Create or Update
|
||||
|
||||
Scenario: Use case: the catalogue and the lookup
|
||||
Given the builtintype package and a fake user.Repository
|
||||
When ListBuiltinTypes and GetBuiltinType("nope") are called
|
||||
Then the list has 5 types and the unknown key returns builtintype.ErrNotFound
|
||||
When LookupUserNames is called with ids 1, 2, 999
|
||||
Then it returns the display name for 1, the username for 2, and nothing for 999
|
||||
@@ -0,0 +1,91 @@
|
||||
# QA procedure: custom content types support (icon, built-in read model, user lookup)
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once per test.
|
||||
2. Reuse the existing fakes `mockContentTypeRepo`, `mockEntryRepo`, `mockUserRepo`, `stubHasher` and the API key
|
||||
fakes from the custom content types tests. Extend `mockUserRepo` with the batch lookup by ids (unknown ids
|
||||
skipped). Add a counting wrapper (`countingRepos`) that records any call on the article, author, site,
|
||||
media, user, content type and entry fakes, with a unique type name across the package.
|
||||
3. Build the `*api.UseCases` container with fakes and mount `api.InitBackendRoutes(container, settings)` on a
|
||||
test server. If a nil use case breaks a constructor, fall back to a fresh `chi.NewRouter()` mirroring
|
||||
`NewContentTypeRouter`, a new `NewBuiltinTypeRouter` (same chain plus `RequireOwnerOrAdmin`) and the user
|
||||
router, with `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||
4. Inject a fake clock into the use cases.
|
||||
5. Seed users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" (empty display name), verified
|
||||
id 3 "sam" ("Sam Roy"), unverified 4 "quinn". Only the roles owner and admin exist, so no verified user is
|
||||
rejected by `RequireOwnerOrAdmin`. Helpers: `cookieFor(userId, tokenVersion)`, `bearer(raw)`; the API key
|
||||
through `POST /v1/api-key`.
|
||||
6. Reuse `sampleRecipe()`; add `withIcon(body, value)` that sets, omits or nulls the icon key.
|
||||
|
||||
## Icon
|
||||
7. Create with `"icon": "chef-hat"` -> 201; key set is exactly `id`, `key`, `name`, `icon`, `fields`, `created_at`,
|
||||
`updated_at`; the icon is also present in `GET /{key}` and in the list. Update the existing pair's
|
||||
"exactly the keys" wording and its QA step 7 accordingly.
|
||||
8. Create with the key absent, `null` and `""` -> 201 and `icon` is JSON `null` in all three reads (assert the
|
||||
raw JSON `null`, not a missing key and not `""`).
|
||||
9. Legacy row: seed `mockContentTypeRepo` with a type that has no icon set; GET list and GET one -> 200, `icon`
|
||||
null, other keys unchanged.
|
||||
10. Invalid icons (`Chef-Hat`, `1star`, `-star`, `chef hat`, `chef_hat`, 41 characters, JSON number `5`) ->
|
||||
400 `invalid_icon`, repository empty. 1 and 40 character icons -> 201.
|
||||
11. PUT on a type that has an entry: new icon -> 200; absent -> 200 and null; `null` -> 200 and null; invalid ->
|
||||
400 `invalid_icon` and the stored icon is unchanged. The stored entry and its `updated_at` are unchanged in
|
||||
every case.
|
||||
12. Use case level: `CreateContentType` / `UpdateContentType` with each invalid icon ->
|
||||
`errors.Is(err, contenttype.ErrInvalidIcon)`, no `Create`/`Update` call on the fake. Domain table test of the
|
||||
icon rule on the function directly.
|
||||
13. Persistence mapper round trip (unit, no GORM): icon nil and non-nil survive the model/domain conversion in
|
||||
both directions. The repository `Update` select list must include the icon (verified in the manual section
|
||||
against the real DB).
|
||||
|
||||
## Built-in types
|
||||
14. Owner `GET /v1/builtin-type` -> 200, array of 5, keys `article`, `author`, `site`, `media`, `user` in order,
|
||||
names `Articles`, `Authors`, `Sites`, `Media`, `Users`; each item has exactly `key`, `name`, `fields`; each
|
||||
field has exactly the nine field keys; every type is one of the six engine types.
|
||||
15. Field-key table (feature table): per type, the ordered field keys; assert none of the forbidden keys
|
||||
(`id`, `created_at`, `updated_at`, `password`, `password_reset_code`, `verification_code`, `token_version`)
|
||||
appears in any type.
|
||||
16. Rules table (feature table): spot-check type, required, min, max and options for the listed fields, with
|
||||
`default`/`min`/`max` as JSON `null` and `options` as `[]` when unset. Rule values come from the request
|
||||
structs; where a rule is not stated there (author bio max, media filename), leave min/max null.
|
||||
17. Cross-check against the real contracts so the catalogue can't drift: for each built-in type, the JSON object
|
||||
keys of the domain struct (via `json.Marshal` of the entity: `article.Article`, `author.Author`, `site.Site`,
|
||||
`image.Image`, `user.User`) must contain every catalogue field key, using the json tags. Fail the test if a
|
||||
catalogue key does not exist in the entity JSON.
|
||||
18. `GET /v1/builtin-type/{key}` for each key -> 200 with the same item as in the list; `nope` and `Article` ->
|
||||
404 `builtin_type_not_found`.
|
||||
19. Collision: seed custom types `builtin` and `article`; `GET /v1/content-type/builtin` and `/article` return the
|
||||
custom types; `GET /v1/content-type` lists only the custom ones; `GET /v1/builtin-type/article` returns the
|
||||
built-in one.
|
||||
20. Access table: owner 200, admin 200, API key of admin 200, user 4 403 `apperr.VerificationRequiredErrorMessage`,
|
||||
none 401, stale 401. POST/PUT/DELETE on `/v1/builtin-type` and `/v1/builtin-type/article` -> 405 for admin.
|
||||
(A verified non-owner non-admin 403 cannot be tested: no such role exists.)
|
||||
21. Isolation: with the counting wrapper, calling the list and `GET /site` leaves every repository call count at
|
||||
zero.
|
||||
22. Use case level: `ListBuiltinTypes` returns 5 types; `GetBuiltinType("nope")` returns `builtintype.ErrNotFound`.
|
||||
|
||||
## User lookup
|
||||
23. As verified user 3, `GET /v1/user/lookup?ids=1,2,3` -> 200; array ordered by id; each item has exactly `id`
|
||||
and `display_name`; names `Marta Alves`, `dev`, `Sam Roy`; raw body contains none of `email`, `role`,
|
||||
`username`, `status`, `password`.
|
||||
24. Edge table (feature table): duplicates collapse, unknown ids are omitted, all-unknown -> `[]` (assert JSON
|
||||
`[]` not `null`), unverified id 4 still resolves, missing/empty/non-numeric/negative/overflow/`1,,2`/101 ids
|
||||
-> 400 `apperr.BodyErrorMessage`, exactly 100 ids -> 200.
|
||||
25. Access table: verified user 3 200, API key of admin 2 200, none 401, user 4 403, stale token_version 401.
|
||||
26. Regression: `GET /v1/user/` and entry responses are unchanged (same keys as before, `created_by`/`updated_by`
|
||||
numeric).
|
||||
27. Use case level: `LookupUserNames` with a fake `user.Repository` returns display name, falls back to the
|
||||
username when it is empty, omits unknown ids, and makes a single batch call on the fake (no per-id lookups).
|
||||
|
||||
## Manual verification (not exercised by the Go tests)
|
||||
- Start `task dev:api` on an existing database that already has content types: confirm AutoMigrate adds the
|
||||
nullable icon column, old types read `icon: null`, and nothing else changes. Repeat on MySQL/MariaDB and
|
||||
PostgreSQL via the existing driver tasks.
|
||||
- Change an icon through PUT against the real SQLite file and reload: confirms the repository `Update` select
|
||||
list includes the icon (the mapper test cannot).
|
||||
- Compare `GET /v1/builtin-type/<key>` field rules with the real `POST`/`PATCH` validation of each resource once
|
||||
(for example create a site with a 101 character `site_name`, expect a 400) to confirm that the stated max
|
||||
values are honest.
|
||||
- Request `GET /v1/user/lookup` in a browser session as a non-admin and confirm no email or role appears in the
|
||||
network response.
|
||||
@@ -0,0 +1,141 @@
|
||||
package builtintype
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("built-in type not found")
|
||||
|
||||
const (
|
||||
KeyArticle = "article"
|
||||
KeyAuthor = "author"
|
||||
KeySite = "site"
|
||||
KeyMedia = "media"
|
||||
KeyUser = "user"
|
||||
)
|
||||
|
||||
type Type struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Fields []contenttype.Field `json:"fields"`
|
||||
}
|
||||
|
||||
func Catalogue() []Type {
|
||||
return []Type{
|
||||
{Key: KeyArticle, Name: "Articles", Fields: articleFields()},
|
||||
{Key: KeyAuthor, Name: "Authors", Fields: authorFields()},
|
||||
{Key: KeySite, Name: "Sites", Fields: siteFields()},
|
||||
{Key: KeyMedia, Name: "Media", Fields: mediaFields()},
|
||||
{Key: KeyUser, Name: "Users", Fields: userFields()},
|
||||
}
|
||||
}
|
||||
|
||||
func Find(key string) (Type, error) {
|
||||
for _, candidate := range Catalogue() {
|
||||
if candidate.Key == key {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return Type{}, ErrNotFound
|
||||
}
|
||||
|
||||
func field(key string, label string, fieldType contenttype.FieldType) contenttype.Field {
|
||||
return contenttype.Field{Key: key, Label: label, Type: fieldType, Options: []string{}}
|
||||
}
|
||||
|
||||
func (b builder) required() builder {
|
||||
b.Required = true
|
||||
return b
|
||||
}
|
||||
|
||||
func (b builder) min(bound int) builder {
|
||||
b.Min = bound
|
||||
return b
|
||||
}
|
||||
|
||||
func (b builder) max(bound int) builder {
|
||||
b.Max = bound
|
||||
return b
|
||||
}
|
||||
|
||||
func (b builder) options(options ...string) builder {
|
||||
b.Options = options
|
||||
return b
|
||||
}
|
||||
|
||||
type builder contenttype.Field
|
||||
|
||||
func text(key string, label string) builder {
|
||||
return builder(field(key, label, contenttype.TypeText))
|
||||
}
|
||||
|
||||
func of(key string, label string, fieldType contenttype.FieldType) builder {
|
||||
return builder(field(key, label, fieldType))
|
||||
}
|
||||
|
||||
func build(builders ...builder) []contenttype.Field {
|
||||
fields := make([]contenttype.Field, 0, len(builders))
|
||||
for _, b := range builders {
|
||||
fields = append(fields, contenttype.Field(b))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func articleFields() []contenttype.Field {
|
||||
return build(
|
||||
text("title", "Title").required().min(article.MinTitleRunes).max(article.MaxTitleRunes),
|
||||
of("body", "Body", contenttype.TypeRichText),
|
||||
text("slug", "Slug"),
|
||||
text("excerpt", "Excerpt").max(300),
|
||||
text("seo_title", "SEO title").max(120),
|
||||
text("seo_description", "SEO description").max(320),
|
||||
of("is_online", "Online", contenttype.TypeBoolean),
|
||||
text("canonical_url", "Canonical URL").max(article.MaxCanonicalURLRunes),
|
||||
of("noindex", "No index", contenttype.TypeBoolean),
|
||||
)
|
||||
}
|
||||
|
||||
func authorFields() []contenttype.Field {
|
||||
return build(
|
||||
text("name", "Name").required(),
|
||||
text("slug", "Slug"),
|
||||
text("bio", "Bio"),
|
||||
text("website", "Website"),
|
||||
)
|
||||
}
|
||||
|
||||
func siteFields() []contenttype.Field {
|
||||
return build(
|
||||
text("name", "Name").required(),
|
||||
text("key", "Key").required(),
|
||||
text("base_url", "Base URL").required(),
|
||||
of("default_robots", "Default robots", contenttype.TypeSelect).options("index", "noindex"),
|
||||
text("locale", "Locale"),
|
||||
of("is_default", "Default site", contenttype.TypeBoolean),
|
||||
text("site_name", "Site name").max(100),
|
||||
text("title_template", "Title template").max(200),
|
||||
text("default_description", "Default description").max(320),
|
||||
)
|
||||
}
|
||||
|
||||
func mediaFields() []contenttype.Field {
|
||||
return build(
|
||||
text("filename", "Filename"),
|
||||
of("size", "Size", contenttype.TypeNumber),
|
||||
of("width", "Width", contenttype.TypeNumber),
|
||||
of("height", "Height", contenttype.TypeNumber),
|
||||
text("content_type", "Content type"),
|
||||
)
|
||||
}
|
||||
|
||||
func userFields() []contenttype.Field {
|
||||
return build(
|
||||
text("username", "Username").required(),
|
||||
text("display_name", "Display name"),
|
||||
text("email", "Email").required(),
|
||||
of("role", "Role", contenttype.TypeSelect).required().options(string(user.RoleOwner), string(user.RoleAdmin)),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ var (
|
||||
ErrKeyTaken = errors.New("a content type with that key already exists")
|
||||
ErrInvalidName = errors.New("invalid content type name")
|
||||
ErrInvalidKey = errors.New("invalid content type key")
|
||||
ErrInvalidIcon = errors.New("invalid content type icon")
|
||||
ErrInvalidField = errors.New("invalid field")
|
||||
ErrHasEntries = errors.New("the content type still has entries")
|
||||
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
|
||||
@@ -58,6 +59,7 @@ type ContentType struct {
|
||||
ID uint32 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
Fields []Field `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var iconPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
|
||||
|
||||
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
|
||||
|
||||
func ValidateKey(key string) error {
|
||||
@@ -24,6 +26,16 @@ func ValidateName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateIcon(icon *string) (*string, error) {
|
||||
if icon == nil || *icon == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !iconPattern.MatchString(*icon) {
|
||||
return nil, ErrInvalidIcon
|
||||
}
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
func ValidateFields(fields []Field) ([]Field, error) {
|
||||
if len(fields) < MinFields || len(fields) > MaxFields {
|
||||
return nil, ErrInvalidField
|
||||
|
||||
@@ -2,6 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,8 +16,11 @@ var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
|
||||
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
|
||||
var ErrInvalidDisplayName = errors.New("invalid display name")
|
||||
var ErrUsernameTaken = errors.New("username taken")
|
||||
var ErrInvalidLookup = errors.New("invalid user lookup")
|
||||
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
|
||||
|
||||
const MaxLookupIDs = 100
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
@@ -40,3 +44,10 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (u User) NameOrUsername() string {
|
||||
if trimmed := strings.TrimSpace(u.DisplayName); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type Repository interface {
|
||||
GetByUsername(username string) (User, error)
|
||||
GetByEmail(email string) (User, error)
|
||||
GetAll() []User
|
||||
FindByIDs(ids []uint32) ([]User, error)
|
||||
Create(user User) (User, error)
|
||||
Delete(id uint32) error
|
||||
UpdateVerificationStatus(userId uint32, isVerified bool) (User, error)
|
||||
|
||||
@@ -15,6 +15,7 @@ func ContentTypeToDomain(m models.ContentType) (contenttype.ContentType, error)
|
||||
ID: m.ID,
|
||||
Key: m.Key,
|
||||
Name: m.Name,
|
||||
Icon: m.Icon,
|
||||
Fields: fields,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
@@ -30,6 +31,7 @@ func ContentTypeToModel(c contenttype.ContentType) (models.ContentType, error) {
|
||||
ID: c.ID,
|
||||
Key: c.Key,
|
||||
Name: c.Name,
|
||||
Icon: c.Icon,
|
||||
Fields: string(fields),
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
|
||||
@@ -6,6 +6,7 @@ type ContentType struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Key string `gorm:"column:type_key;size:40;not null;uniqueIndex"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
Icon *string `gorm:"size:40"`
|
||||
Fields string `gorm:"type:text;not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -60,7 +60,7 @@ func (c *ContentTypeRepository) Update(contentType domain.ContentType) (domain.C
|
||||
if err != nil {
|
||||
return domain.ContentType{}, err
|
||||
}
|
||||
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Fields", "UpdatedAt").Updates(&model)
|
||||
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Icon", "Fields", "UpdatedAt").Updates(&model)
|
||||
if err := deleteOrNotFound(result, domain.ErrContentTypeNotFound); err != nil {
|
||||
return domain.ContentType{}, mapKeyTakenErr(err)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ func (u *UserRepository) GetAll() []domainUser.User {
|
||||
return domainUsers
|
||||
}
|
||||
|
||||
func (u *UserRepository) FindByIDs(ids []uint32) ([]domainUser.User, error) {
|
||||
var users []entity.User
|
||||
if err := u.db.Select("id", "username", "display_name").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := make([]domainUser.User, 0, len(users))
|
||||
for _, localUser := range users {
|
||||
found = append(found, mappers.UserToDomain(localUser))
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (u *UserRepository) GetByUsername(username string) (domainUser.User, error) {
|
||||
var localUser entity.User
|
||||
err := u.db.Model(&entity.User{}).Where("username = ?", username).First(&localUser).Error
|
||||
|
||||
@@ -14,6 +14,7 @@ type CreateContentTypeUseCase struct {
|
||||
type CreateContentTypeCommand struct {
|
||||
Key string
|
||||
Name string
|
||||
Icon *string
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
@@ -28,6 +29,10 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
if err := contenttype.ValidateName(command.Name); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
icon, err := contenttype.ValidateIcon(command.Icon)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
fields, err := contenttype.ValidateFields(command.Fields)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
@@ -39,6 +44,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
return u.contentTypes.Create(contenttype.ContentType{
|
||||
Key: command.Key,
|
||||
Name: command.Name,
|
||||
Icon: icon,
|
||||
Fields: fields,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/builtintype"
|
||||
|
||||
type GetBuiltinTypeUseCase struct{}
|
||||
|
||||
func NewGetBuiltinTypeUseCase() *GetBuiltinTypeUseCase {
|
||||
return &GetBuiltinTypeUseCase{}
|
||||
}
|
||||
|
||||
func (u *GetBuiltinTypeUseCase) Get(key string) (builtintype.Type, error) {
|
||||
return builtintype.Find(key)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -62,7 +61,7 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
|
||||
if requester.Role != user.RoleOwner {
|
||||
return names, nil
|
||||
}
|
||||
names[requester.ID] = displayNameOrUsername(requester)
|
||||
names[requester.ID] = requester.NameOrUsername()
|
||||
for _, key := range keys {
|
||||
if _, resolved := names[key.UserID]; resolved {
|
||||
continue
|
||||
@@ -75,18 +74,11 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names[key.UserID] = displayNameOrUsername(creator)
|
||||
names[key.UserID] = creator.NameOrUsername()
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func displayNameOrUsername(account user.User) string {
|
||||
if trimmed := strings.TrimSpace(account.DisplayName); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return account.Username
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
|
||||
if requester.Role == user.RoleOwner {
|
||||
return l.apiKeyRepository.ListAll()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/builtintype"
|
||||
|
||||
type ListBuiltinTypesUseCase struct{}
|
||||
|
||||
func NewListBuiltinTypesUseCase() *ListBuiltinTypesUseCase {
|
||||
return &ListBuiltinTypesUseCase{}
|
||||
}
|
||||
|
||||
func (u *ListBuiltinTypesUseCase) List() []builtintype.Type {
|
||||
return builtintype.Catalogue()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type LookupUserNamesUseCase struct {
|
||||
users user.Repository
|
||||
}
|
||||
|
||||
type UserName struct {
|
||||
ID uint32
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
func NewLookupUserNamesUseCase(users user.Repository) *LookupUserNamesUseCase {
|
||||
return &LookupUserNamesUseCase{users}
|
||||
}
|
||||
|
||||
func (u *LookupUserNamesUseCase) Lookup(ids []uint32) ([]UserName, error) {
|
||||
distinct := distinctIDs(ids)
|
||||
if len(distinct) == 0 || len(distinct) > user.MaxLookupIDs {
|
||||
return nil, user.ErrInvalidLookup
|
||||
}
|
||||
found, err := u.users.FindByIDs(distinct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool { return found[i].ID < found[j].ID })
|
||||
names := make([]UserName, 0, len(found))
|
||||
for _, record := range found {
|
||||
names = append(names, UserName{ID: record.ID, DisplayName: record.NameOrUsername()})
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func distinctIDs(ids []uint32) []uint32 {
|
||||
seen := make(map[uint32]bool, len(ids))
|
||||
distinct := make([]uint32, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
distinct = append(distinct, id)
|
||||
}
|
||||
}
|
||||
return distinct
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type UpdateContentTypeUseCase struct {
|
||||
type UpdateContentTypeCommand struct {
|
||||
Key string
|
||||
Name string
|
||||
Icon *string
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
@@ -30,6 +31,10 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
|
||||
if err := contenttype.ValidateName(command.Name); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
icon, err := contenttype.ValidateIcon(command.Icon)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
fields, err := contenttype.ValidateFields(command.Fields)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
@@ -44,6 +49,7 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
|
||||
}
|
||||
}
|
||||
current.Name = command.Name
|
||||
current.Icon = icon
|
||||
current.Fields = fields
|
||||
current.UpdatedAt = u.now()
|
||||
return u.contentTypes.Update(current)
|
||||
|
||||
@@ -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