feat: custom content types backend

This commit is contained in:
Florian Sylvain committed 2026-10-19 17:01:37 +02:00
1 parent ed67b7e70d
commit 58d58f5a04
41 files changed
+4086 -2

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter | | [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log | | [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas | | [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
| [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering, phase 1 limitations and their fix path |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache | | [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one | | [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
+26
View File
@@ -5,6 +5,8 @@ import (
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit" domainAudit "RenewCMS/internal/domain/audit"
domainAuthor "RenewCMS/internal/domain/author" domainAuthor "RenewCMS/internal/domain/author"
domainContentType "RenewCMS/internal/domain/contenttype"
domainEntry "RenewCMS/internal/domain/entry"
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
@@ -63,6 +65,16 @@ type UseCases struct {
UpdateSiteUseCase *useCases.UpdateSiteUseCase UpdateSiteUseCase *useCases.UpdateSiteUseCase
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
DeleteSiteUseCase *useCases.DeleteSiteUseCase DeleteSiteUseCase *useCases.DeleteSiteUseCase
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
ListContentTypesUseCase *useCases.ListContentTypesUseCase
GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
CreateEntryUseCase *useCases.CreateEntryUseCase
GetEntryUseCase *useCases.GetEntryUseCase
ListEntriesUseCase *useCases.ListEntriesUseCase
UpdateEntryUseCase *useCases.UpdateEntryUseCase
DeleteEntryUseCase *useCases.DeleteEntryUseCase
GetUserUseCase *useCases.GetUserUseCase GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
@@ -140,6 +152,8 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
type Repositories struct { type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository SiteRepo domainSite.Repository
ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
@@ -280,6 +294,8 @@ func getRepositories(connection *database.Connection, settings config.Settings)
return &Repositories{ return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db), SiteRepo: persistence.NewSiteRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db), AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db), ImageRepo: persistence.NewImageRepository(db),
@@ -345,6 +361,16 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder), SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder), DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
@@ -0,0 +1,153 @@
package contenttype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateContentTypeUseCase
ListUseCase *useCases.ListContentTypesUseCase
GetUseCase *useCases.GetContentTypeUseCase
UpdateUseCase *useCases.UpdateContentTypeUseCase
DeleteUseCase *useCases.DeleteContentTypeUseCase
}
func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListContentTypesUseCase, get *useCases.GetContentTypeUseCase, update *useCases.UpdateContentTypeUseCase, deleteUseCase *useCases.DeleteContentTypeUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type PostContentType struct {
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required,max=100"`
Fields []domain.Field `json:"fields"`
}
type PutContentType struct {
Key string `json:"key"`
Name string `json:"name" validate:"required,max=100"`
Fields []domain.Field `json:"fields"`
}
type contentTypeResponse struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
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)
}
return contentTypeResponse{
ID: contentType.ID,
Key: contentType.Key,
Name: contentType.Name,
Fields: fields,
CreatedAt: contentType.CreatedAt,
UpdatedAt: contentType.UpdatedAt,
}
}
func writeContentTypeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrKeyTaken):
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSchemaIncompatible):
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.ErrInvalidKey):
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidField):
http.Error(w, apperr.InvalidFieldErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
found, err := h.ListUseCase.List()
if err != nil {
writeContentTypeError(w, err)
return
}
responses := make([]contentTypeResponse, 0, len(found))
for _, contentType := range found {
responses = append(responses, toResponse(contentType))
}
response.WriteJSON(w, http.StatusOK, responses)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
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 err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request PutContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
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 err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key")); err != nil {
writeContentTypeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+232
View File
@@ -0,0 +1,232 @@
package entry
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/contenttype"
domain "RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateEntryUseCase
GetUseCase *useCases.GetEntryUseCase
ListUseCase *useCases.ListEntriesUseCase
UpdateUseCase *useCases.UpdateEntryUseCase
DeleteUseCase *useCases.DeleteEntryUseCase
}
func NewHandler(create *useCases.CreateEntryUseCase, get *useCases.GetEntryUseCase, list *useCases.ListEntriesUseCase, update *useCases.UpdateEntryUseCase, deleteUseCase *useCases.DeleteEntryUseCase) *Handler {
return &Handler{CreateUseCase: create, GetUseCase: get, ListUseCase: list, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type EntryBody struct {
Data map[string]any `json:"data" validate:"required"`
}
type entryResponse struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedBy uint32 `json:"created_by"`
UpdatedBy uint32 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toResponse(typed useCases.TypedEntry) entryResponse {
return entryResponse{
ID: typed.ID,
ContentType: typed.ContentTypeKey,
Data: typed.Data,
CreatedBy: typed.CreatedBy,
UpdatedBy: typed.UpdatedBy,
CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt,
}
}
type pageResponse struct {
Items []entryResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Sort domain.Sort
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
order := domain.DefaultSort()
if values.Has("sort") {
order, err = domain.ParseSort(values.Get("sort"))
if err != nil {
return listQuery{}, err
}
}
return listQuery{Page: page, Size: size, Sort: order}, nil
}
func parseEntryId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeEntryError(w http.ResponseWriter, err error) {
var fieldError domain.FieldError
switch {
case errors.Is(err, contenttype.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrEntryNotFound):
http.Error(w, apperr.EntryNotFoundErrorMessage, http.StatusNotFound)
case errors.As(err, &fieldError):
http.Error(w, apperr.InvalidEntryErrorMessage+": "+fieldError.Error(), fieldErrorStatus(err))
case errors.Is(err, domain.ErrInvalidPage), errors.Is(err, domain.ErrInvalidSort):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidEntry):
http.Error(w, apperr.InvalidEntryErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func fieldErrorStatus(err error) int {
if errors.Is(err, domain.ErrUniqueViolation) {
return http.StatusConflict
}
return http.StatusUnprocessableEntity
}
func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
var body EntryBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || validator.Validate.Struct(body) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return EntryBody{}, false
}
return body, true
}
func currentUserId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
}
return userId, ok
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort})
if err != nil {
writeEntryError(w, err)
return
}
items := make([]entryResponse, 0, len(page.Items))
for _, typed := range page.Items {
items = append(items, toResponse(typed))
}
response.WriteJSON(w, http.StatusOK, pageResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id)
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userId, ok := currentUserId(w, r)
if !ok {
return
}
body, ok := readBody(w, r)
if !ok {
return
}
created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, UserID: userId})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
userId, ok := currentUserId(w, r)
if !ok {
return
}
body, ok := readBody(w, r)
if !ok {
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, UserID: userId})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id); err != nil {
writeEntryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+9
View File
@@ -86,3 +86,12 @@ const ArticleSlugTakenErrorMessage = "slug_taken"
const ArticleImageNotFoundErrorMessage = "image_not_found" const ArticleImageNotFoundErrorMessage = "image_not_found"
const InvalidTitleTemplateErrorMessage = "invalid_title_template" const InvalidTitleTemplateErrorMessage = "invalid_title_template"
const SlugSuggestionHeader = "X-Slug-Suggestion" const SlugSuggestionHeader = "X-Slug-Suggestion"
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 InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found"
const InvalidEntryErrorMessage = "invalid_entry"
+21
View File
@@ -0,0 +1,21 @@
package response
import (
"encoding/json"
"log"
"net/http"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+33
View File
@@ -6,6 +6,8 @@ import (
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/author" "RenewCMS/api/handlers/author"
"RenewCMS/api/handlers/contenttype"
"RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/media" "RenewCMS/api/handlers/media"
@@ -244,6 +246,36 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
return r return r
} }
func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
contentTypeHandler := contenttype.NewHandler(container.CreateContentTypeUseCase, container.ListContentTypesUseCase, container.GetContentTypeUseCase, container.UpdateContentTypeUseCase, container.DeleteContentTypeUseCase)
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
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.Get("/", contentTypeHandler.List)
r.Get("/{key}", contentTypeHandler.Get)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Post("/", contentTypeHandler.Create)
r.Put("/{key}", contentTypeHandler.Update)
r.Delete("/{key}", contentTypeHandler.Delete)
})
r.Get("/{key}/entry", entryHandler.List)
r.Post("/{key}/entry", entryHandler.Create)
r.Get("/{key}/entry/{id}", entryHandler.Get)
r.Put("/{key}/entry/{id}", entryHandler.Update)
r.Delete("/{key}/entry/{id}", entryHandler.Delete)
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler { func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
@@ -394,6 +426,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies)) r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies)) r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies)) r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies)) r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies)) r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
+108
View File
@@ -0,0 +1,108 @@
# Claude Design prompt: custom content types
Paste everything below the line into Claude Design, with the existing RenewCMS design system attached.
---
You are extending the RenewCMS back-office design. RenewCMS is a small self-hosted CMS (Go API plus a Vue 3 SPA built with Nuxt UI v4 and Tailwind 4). The existing screens, the AppFrame back-office shell, the AuthCard auth frames, the design tokens and the component inventory are already in this design system: reuse them, never invent new colors, spacing, radius or type values, and keep the same copy tone (short, plain, sentence case). Deliver the same artifacts the system already uses: one board per screen with every state, the exact copy per state, the routes with their auth requirement, the new components with their Nuxt UI mapping, and light and dark variants at desktop (1440x900) and mobile (390x844).
## The feature
Until now RenewCMS only managed fixed content: articles, authors, sites, media and users. We are adding custom content types: an owner or admin defines a content type (for example "Recipe" or "Event") made of ordered fields, and then any verified user creates entries of that type. Think of the clear part of Directus: a type is a collection, a field is a typed column. Do not copy Strapi's split between collections, components and fields into separate screens: everything about a type happens on one screen. Do not draw a table-builder feel with database jargon; speak in terms of content.
The backend for the first part already exists. Design the full experience including the parts that come later, and mark each screen or state as "available now" or "later" (see the scope table).
## What the backend does today (design against this, do not contradict it)
Routes, all behind the normal signed-in, verified-user back-office session (cookie or API key):
- GET /v1/content-type: list of types, ordered by name.
- POST /v1/content-type, PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}: owner or admin only.
- GET /v1/content-type/{key}.
- GET and POST /v1/content-type/{key}/entry; GET, PUT and DELETE /v1/content-type/{key}/entry/{id}. Any verified user.
A content type: id, key, name, fields, created_at, updated_at.
- key: lowercase slug, 2 to 40 characters, unique, chosen at creation, never editable afterwards. It appears in URLs and the API.
- name: free text, up to 100 characters, editable.
- fields: ordered list, 1 to 50 fields.
A field: key, label, type, required, unique, default, min, max, options.
- key: lowercase letters, digits and underscores, starts with a letter, up to 40 characters, unique within the type. It becomes the JSON key in the API.
- label: the text shown in the editor.
- type: one of text, richtext (markdown), number, boolean, date (a calendar date, no time), select (a single choice from a list of 1 to 50 distinct options).
- required, unique (text, number, date and select only), default (applied only when an entry is created and the field is omitted), min and max.
- min and max mean: length in characters for text and richtext, value for number, earliest and latest date for date. They do not exist for boolean and select. min must not exceed max. A default must satisfy the field's own rules. A unique field cannot have a default.
- A field's type can never change after the type is saved, and the type key never changes.
An entry: id, content_type (the type's key), data (an object keyed by field key), created_by, updated_by, created_at, updated_at.
- Saving an entry replaces its whole data. Defaults apply only on create. An explicit empty value stays empty, and a required field cannot be empty.
- Unknown keys are rejected. Values of a removed field disappear.
Schema changes once entries exist:
- Removing a field is allowed; its stored values stop being returned.
- Every existing entry is re-validated against the new schema. If any entry would become invalid (a new required field without a default, a field made unique while duplicates exist, a tighter min or max below a stored value, a removed option still in use), the whole change is refused as incompatible and nothing changes.
- Adding a required field is fine if it has a default.
- A type that has entries cannot be deleted.
Entry lists are paginated: page size 5, 25, 50 or 100 (default 25), sort by id, created_at or updated_at in either direction (default most recently updated first). There is no filtering or search on custom fields yet.
Errors come back as plain text. Map each to friendly copy:
- content_type_not_found, entry_not_found
- content_type_key_taken
- content_type_has_entries
- invalid_content_type_key
- invalid_field
- schema_incompatible
- "invalid_entry: <field key>: <rule>", rule being one of required, type, min, max, options, unique, unknown_field. Unique violations arrive as HTTP 409, the other entry rules as 422.
- The usual 401, 403 (unverified account or insufficient role), 400 and 500 already covered in the system's HTTP-status-to-copy table.
## Screens to design
For each screen: every state (loading, empty, populated, error, forbidden, offline or failed save where relevant), the exact copy, the routes and their auth requirement, and a mobile layout.
### 1. Schema screen: list of types (available now)
Sits in the back-office navigation as "Schema", visible to owners and admins; other verified users do not see it. Shows the types as a list or table: name, key, number of fields, last updated. Shows the built-in types (Articles, Authors, Sites, Media, Users) in the same list with a "Built-in" badge and a lock icon. Built-ins are read-only entries here: they open a read-only view listing their fields, with no edit, no delete and no ability to add fields. Do not suggest that built-ins are custom collections; the badge and a one-line explanation make clear they are managed by RenewCMS. Primary action "New content type". States: loading, empty (no custom type yet: invite the user to create one, built-ins still listed), populated, error, forbidden (non-admin who types the URL).
### 2. Schema builder (available now, the main screen)
One screen with three regions, not three separate areas:
- Left: the ordered field list. Reorder (drag with a keyboard-accessible alternative), add field, remove field. Each row shows label, key, type icon and small badges for required and unique.
- Right: the field inspector for the selected field: label, key (generated from the label, editable until the type is first saved, then locked), type (a gallery of the six types with icon and one-line description), required, unique (disabled with an explanation where not allowed), default, min and max (with the right unit wording per type), and for select an options editor (add, remove, reorder, no duplicates, no blanks). Show the rules inline as the user types, not only after save.
- Top: type name, type key (editable only at creation; afterwards locked with a tooltip explaining why), Save and Discard.
- Live API preview: a collapsible panel showing the exact JSON an entry of this type looks like (a sample entry using each field's type and default) and the route it lives at, updating as fields change. Include a copy button.
States: new type, editing an existing type, unsaved changes, validation errors per field and at type level (inline, with the field highlighted in the list), saving, saved, save failed, key already taken, schema incompatible with existing entries.
### 3. Schema change review (available now for the review step, later for the diff)
Before saving changes to a type that already has entries, show a review step: "Add 2 fields, change 1, remove 1", listing each change, with destructive ones (removing a field, removing an option, tightening a limit, making a field unique or required) clearly flagged. Because the server decides compatibility, the review is informational plus an explicit Apply button; if the server answers schema incompatible, show a refusal state that says nothing was changed and which kind of change likely caused it (we cannot tell which entry, so write copy that does not pretend to). Also design the "delete type" confirmation: blocked when entries exist (explain that entries must be deleted first) and a plain confirmation when empty. Mark per-change entry counts and a full diff as "later" (the API does not return them yet).
### 4. Field-type gallery (available now)
A reference board for the six field types showing, for each: its icon, its inspector options, its list cell (how a value renders in the entries table, including the empty state), its form control in the entry editor (markdown editor for richtext with preview, number input with min and max hints, toggle for boolean using the system's single toggle component, date picker, select, text with a character counter when a limit exists), and its error states per rule (required, type, min, max, options, unique). Show the "filter" widget for each type as "later" since filtering on custom fields is not available yet.
### 5. Content navigation and entry list (available now)
The back-office sidebar gets a "Content" group generated from the custom types, one entry per type (name, with the type's icon if any; truncated gracefully with many types; collapsed on mobile). The list screen for a type shows a paginated table with a column per field (cap the visible columns and let the user choose which, defaulting to the first few), plus created and updated dates and the author of the last change. Sort control (id, created, updated), page size selector (5, 25, 50, 100), "New entry" button. Row click opens the editor. States: loading, empty for this type (with a hint pointing to "New entry"), populated, error, forbidden, type not found.
Use a sample "Events" type for all boards: title (text, required, unique, max 80), starts_on (date, required), ends_on (date), online (boolean, default false), capacity (number, min 1, max 5000), category (select: Talk, Workshop, Meetup), description (richtext).
### 6. Entry editor (available now)
Create and edit form generated from the schema, one control per field in schema order, with labels, required markers, helper text from min, max and options, a character counter where a limit exists, and inline errors per field. Show defaults pre-filled on create. Save, Cancel, Delete (with confirmation). Provenance footer: created by, updated by, dates. States: new, editing, unsaved changes, saving, saved, failed save, field errors for each rule, duplicate unique value (409), entry not found, concurrent schema change (a field disappeared or a new required field appeared since the form loaded: explain that the form must be reloaded).
### 7. Relation field and sentence-style relation picker (later, design now)
Relations are not built yet. Design the picker so that the model reads as sentences, not jargon: "Each Event has one Venue" and, beside it, "Each Venue has many Events", with a live preview of both sides. Offer the three relationship shapes in plain words. Show how a relation field renders in the inspector, the entry form (search and pick, with chips for many) and the list cell. Mark everything "later".
### 8. Permissions grid (later, design now)
A collection by action grid on the roles area: rows are types (custom and built-in), columns are read, create, update and delete, per role. Built-in rows are locked where the system dictates it. Make the grid usable on mobile (collapse to a per-role list). Mark "later". Today's rule is simple: owners and admins edit schema, any verified user reads and writes entries; show that as the default state of the grid.
### 9. Schema export and import (later, design now)
Export the whole schema or a single type to a file, import with a preview of what will be created or changed and the same compatibility review as screen 3, plus an explicit confirm. Aim at the dev-to-production flow. Mark "later".
## Scope table to include in your answer
For every screen and state, label it "available now" or "later" so engineering knows what is wired. Available now: screens 1 to 6 (except the diff counts), the live API preview, the type-level and field-level validation states. Later: relation picker, permissions grid, schema export and import, filters on custom fields, per-change entry counts, publish and draft state, public (anonymous) read access, activity log entries for types and entries.
## Constraints
- Nuxt UI v4 only, mapped to existing components in the inventory. List every new component you introduce with its Nuxt UI mapping and why an existing one does not fit.
- Tokens only. No raw hex values and no arbitrary Tailwind colors.
- Accessibility: keyboard reorder for fields, focus order, visible focus, labels on every control, error text tied to its input, sufficient contrast in light and dark.
- Copy: short and plain, no database jargon in primary labels (say "field", "choice list", "entry", "content type"; keep "key" for the API identifier and explain it once).
- Do not redesign existing screens. Only add what these screens need: new sidebar group, new routes, new components.
- Provide the routes table: /schema, /schema/new, /schema/:key, /content/:key, /content/:key/new, /content/:key/:id, each with its auth requirement (schema routes owner or admin, content routes any verified user).
- Anything the API cannot do today (see above) must not appear as if it works. If a design needs a backend capability that does not exist, flag it explicitly in a short "needs backend" list at the end.
+14
View File
@@ -0,0 +1,14 @@
# Custom content types
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.
## 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.
## Known limitations of phase 1
- Uniqueness and schema re-validation are O(n). Checking a unique field loads every entry of the type and compares values in Go, and updating a type with stored entries loads all of them to re-validate. This keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL. The path to fix it is the filterable-fields phase: project the values of unique and filterable fields into an indexed table, so uniqueness becomes an indexed lookup and re-validation can be restricted to the changed fields.
- Unique values are checked without a transaction, so two concurrent writes of the same unique value can both succeed. A content type key is protected by a unique index, and a violation is reported as `content_type_key_taken` (409).
- `entry.Repository.DeleteByContentType` is intentionally unused for now. A content type that still has entries cannot be deleted; the method is kept as part of the repository contract for a later cascade.
- Listing is limited to page 1,000,000 and to page sizes of 100 so that the SQL offset cannot overflow.
+355
View File
@@ -0,0 +1,355 @@
Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API
As a site owner or admin
I want to define content types made of ordered scalar fields, and then create entries of those types
So that I can store structured content beyond articles, with the schema enforced by the server
Background:
Given internal/domain/contenttype defines the entity ContentType with ID uint32, Key, Name,
Fields []Field, CreatedAt and UpdatedAt, with snake_case json tags
And Field is a value object, ordered by its position in ContentType.Fields, with Key, Label,
Type, Required, Unique, Default, Min, Max and Options
And the field types are "text", "richtext", "number", "boolean", "date" and "select"
And a content type key matches the domain slug rule and has 2 to 40 characters
And a field key matches ^[a-z][a-z0-9_]{0,39}$, is unique within its type, and a type has 1 to 50 fields
And internal/domain/contenttype defines the sentinels ErrContentTypeNotFound, ErrKeyTaken,
ErrInvalidKey, ErrInvalidField, ErrHasEntries and ErrSchemaIncompatible
And internal/domain/contenttype defines the interface Repository with Create, GetByKey, List,
Update and Delete
And internal/domain/entry defines the entity Entry with ID uint32, ContentTypeID, Data
(a JSON object), CreatedBy, UpdatedBy, CreatedAt and UpdatedAt
And internal/domain/entry defines the sentinels ErrEntryNotFound, ErrInvalidEntry and
ErrUniqueViolation, and the interface Repository with Create, Get, ListPage, Update, Delete,
DeleteByContentType, CountByContentType and FindByFieldValue(contentTypeID, fieldKey, value, excludeID)
And schema validation of a field list and of an entry's data lives in the domain/use cases,
never in handlers, and handler request structs still use validate tags
And the use cases CreateContentType, ListContentTypes, GetContentType, UpdateContentType,
DeleteContentType, CreateEntry, GetEntry, ListEntries, UpdateEntry and DeleteEntry depend only
on the two domain repositories and an injected clock
And persistence stores content types in one table with the fields as a JSON text column, and
entries in one generic table with the data as a JSON text column, so no DDL runs at runtime
and the SQL is portable across SQLite, MySQL/MariaDB and PostgreSQL
And both GORM models are added to the AutoMigrate list and wired only in api/dependecyInjection.go
And the built-in articles, authors, sites, media and users are not part of this engine
And the routes are mounted under /v1/content-type in a router with jwtauth.Verifier, apiKeyAuth,
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 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,
created_at, updated_at, where content_type is the type's key
And errors are plain text from http.Error, and the new apperr strings are
content_type_not_found, content_type_key_taken, content_type_has_entries,
invalid_content_type_key, invalid_field, schema_incompatible, entry_not_found and invalid_entry
And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), unverified id 4 "quinn"
And the sample type "recipe" has the fields, in order:
| key | type | required | unique | default | min | max | options |
| title | text | true | true | | 3 | 80 | |
| servings | number | false | false | 4 | 1 | 50 | |
| vegetarian | boolean | false | false | false | | | |
| published_on | date | false | false | | | | |
| difficulty | select | true | false | | | | easy, medium, hard |
| notes | richtext | false | false | | | 500 | |
Scenario: Creating a content type
Given a valid jwt cookie for admin 2
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 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
Scenario Outline: Creating a content type with an invalid body is refused and stores nothing
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with <body>
Then the response status is <status>
And the response body is <message>
And no content type exists in the repository
Examples:
| body | status | message |
| body not-json | 400 | apperr.BodyErrorMessage |
| body {} | 400 | text containing 'Key' and 'required' |
| {"key": "Recipe!", "name": "R", "fields": [<one text field>]} | 400 | invalid_content_type_key |
| {"key": "r", "name": "R", "fields": [<one text field>]} | 400 | invalid_content_type_key |
| {"key": "recipe", "name": "", "fields": [<one text field>]} | 400 | text containing 'Name' and 'required' |
| {"key": "recipe", "name": "R", "fields": []} | 422 | invalid_field |
| {"key": "recipe", "name": "R", "fields": [<51 fields>]} | 422 | invalid_field |
| a field key "Title" or "1st" or "a-b" | 422 | invalid_field |
| two fields with the key "title" | 422 | invalid_field |
| a field type "integer" | 422 | invalid_field |
| a select field with no options, an empty option, or a duplicated option | 422 | invalid_field |
| options on a non-select field | 422 | invalid_field |
| min or max on a boolean or select field | 422 | invalid_field |
| min greater than max | 422 | invalid_field |
| a negative or fractional min or max on a text or richtext field | 422 | invalid_field |
| a non-date min or max on a date field | 422 | invalid_field |
| unique true on a richtext or boolean field | 422 | invalid_field |
| a default of the wrong type, outside min/max, or not in the options | 422 | invalid_field |
| a default on a unique field | 422 | invalid_field |
Scenario: A content type key can't be reused
Given the content type "recipe" exists
When admin 2 sends POST /v1/content-type with the key "recipe"
Then the response status is 409
And the response body is content_type_key_taken
And only one content type exists
Scenario Outline: Writing content types needs a verified owner or admin
Given <caller>
When the client sends POST /v1/content-type with a valid body
Then the response status is <status>
And the response body is <message>
And no content type exists in the repository
Examples:
| caller | status | message |
| no credentials | 401 | the existing Authenticator response |
| a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage |
| a stale token_version cookie | 401 | the existing Authenticator response |
Scenario: An API key acts as its creator on the content type routes
Given an active key created by admin 2
When the client sends POST /v1/content-type with that key and no cookie and a valid body
Then the response status is 201
Scenario: Listing and reading content types
Given the content types "recipe" and "event" exist
And a valid jwt cookie for any verified user
When the client sends GET /v1/content-type
Then the response status is 200
And the body is a JSON array of both content types, ordered by name
When the client sends GET /v1/content-type/recipe
Then the response status is 200 and the body is the "recipe" content type
Scenario Outline: Reading a content type that doesn't exist or with a bad key
Given a valid jwt cookie for admin 2
When the client sends <method> /v1/content-type/<key> <body>
Then the response status is <status>
And the response body is <message>
Examples:
| method | key | body | status | message |
| GET | nope | | 404 | content_type_not_found |
| PUT | nope | and a valid schema body | 404 | content_type_not_found |
| DELETE | nope | | 404 | content_type_not_found |
| GET | Bad_Key | | 404 | content_type_not_found |
Scenario: Updating a content type before it has entries
Given the content type "recipe" exists with no entries
When admin 2 sends PUT /v1/content-type/recipe with a new name, a removed field, a reordered
list and a changed field type
Then the response status is 200
And the body shows the new name, the new field order and the new field type
And the key is still "recipe"
Scenario: The key of a content type can't be changed
Given the content type "recipe" exists
When admin 2 sends PUT /v1/content-type/recipe with body key "dish"
Then the response status is 200
And the key is still "recipe"
Scenario Outline: Updating a content type that has entries
Given the content type "recipe" has an entry {"title": "Pad thai", "difficulty": "easy"}
When admin 2 sends PUT /v1/content-type/recipe with <change>
Then the response status is <status>
And the response body is <message>
And the stored schema is <outcome>
Examples:
| change | status | message | outcome |
| a field's type changed from text to number | 422 | invalid_field | unchanged |
| a new required field without a default | 409 | schema_incompatible | unchanged |
| a new required field with a default | 200 | | updated |
| a new optional field | 200 | | updated |
| the field "notes" removed | 200 | | updated |
| the option "easy" removed from difficulty | 409 | schema_incompatible | unchanged |
| the option "hard" removed from difficulty | 200 | | updated |
| title max lowered below the stored title's length | 409 | schema_incompatible | unchanged |
| label, order or name changed | 200 | | updated |
Scenario: Making a field unique is refused when stored values collide
Given the type "recipe" has two entries with the title "Pad thai" and the title is not unique
When admin 2 sends PUT /v1/content-type/recipe making title unique
Then the response status is 409 with schema_incompatible and the schema is unchanged
Scenario: Values of a removed field are no longer returned
Given an entry with notes "Soak overnight" and a schema update that removes "notes"
When a user sends GET on that entry
Then data has no "notes" key
When the entry is saved again through PUT
Then the stored document has no "notes" key
Scenario: Deleting a content type
Given the content type "event" exists with no entries
When admin 2 sends DELETE /v1/content-type/event
Then the response status is 204 and the body is empty
And GET /v1/content-type/event returns 404 with content_type_not_found
Scenario: A content type with entries can't be deleted
Given the content type "recipe" has one entry
When admin 2 sends DELETE /v1/content-type/recipe
Then the response status is 409
And the response body is content_type_has_entries
And the type and its entry still exist
Scenario: Creating an entry applies defaults
Given the content type "recipe" exists and a valid jwt cookie for admin 2
When the client sends POST /v1/content-type/recipe/entry with body
{"data": {"title": "Pad thai", "difficulty": "easy"}}
Then the response status is 201
And the body has exactly the keys id, content_type, data, created_by, updated_by, created_at, updated_at
And content_type is "recipe", created_by and updated_by are 2
And data is {"title": "Pad thai", "difficulty": "easy", "servings": 4, "vegetarian": false}
And data has no "published_on" and no "notes" key
Scenario: An explicit value overrides the default and an explicit null is kept
Given the content type "recipe" exists
When the client creates an entry with {"title": "Soup", "difficulty": "easy", "servings": 2, "published_on": null}
Then data servings is 2 and published_on is null and no default is applied to published_on
Scenario Outline: Creating an entry that breaks the schema is refused and stores nothing
Given the content type "recipe" exists and a valid jwt cookie
When the client sends POST /v1/content-type/recipe/entry with <body>
Then the response status is <status>
And the response body is <message>
And no entry exists
Examples:
| body | status | message |
| body not-json | 400 | apperr.BodyErrorMessage |
| {} | 400 | apperr.BodyErrorMessage |
| {"data": "x"} | 400 | apperr.BodyErrorMessage |
| {"data": {"difficulty": "easy"}} | 422 | invalid_entry: title: required |
| {"data": {"title": null, "difficulty": "easy"}} | 422 | invalid_entry: title: required |
| {"data": {"title": 5, "difficulty": "easy"}} | 422 | invalid_entry: title: type |
| {"data": {"title": "ab", "difficulty": "easy"}} | 422 | invalid_entry: title: min |
| {"data": {"title": "<81 runes>", "difficulty": "easy"}} | 422 | invalid_entry: title: max |
| {"data": {"title": "Soup", "difficulty": "extreme"}} | 422 | invalid_entry: difficulty: options |
| {"data": {"title": "Soup", "difficulty": "easy", "servings": 0}} | 422 | invalid_entry: servings: min |
| {"data": {"title": "Soup", "difficulty": "easy", "servings": 51}} | 422 | invalid_entry: servings: max |
| {"data": {"title": "Soup", "difficulty": "easy", "servings": "4"}} | 422 | invalid_entry: servings: type |
| {"data": {"title": "Soup", "difficulty": "easy", "vegetarian": "yes"}} | 422 | invalid_entry: vegetarian: type |
| {"data": {"title": "Soup", "difficulty": "easy", "published_on": "2026-02-30"}} | 422 | invalid_entry: published_on: type |
| {"data": {"title": "Soup", "difficulty": "easy", "published_on": "2026-02-01T10:00:00Z"}} | 422 | invalid_entry: published_on: type |
| {"data": {"title": "Soup", "difficulty": "easy", "notes": "<501 runes>"}} | 422 | invalid_entry: notes: max |
| {"data": {"title": "Soup", "difficulty": "easy", "colour": "red"}} | 422 | invalid_entry: colour: unknown_field |
Scenario: Boundary values are accepted
Given the content type "recipe" exists
When entries are created with a title of exactly 3 runes, exactly 80 runes, servings 1 and servings 50
Then each response status is 201
Scenario: Unique fields reject duplicates and ignore the entry itself
Given the content type "recipe" has an entry 7 with the title "Pad thai"
When the client sends POST /v1/content-type/recipe/entry with the title "Pad thai"
Then the response status is 409
And the response body is "invalid_entry: title: unique"
When the client sends PUT /v1/content-type/recipe/entry/7 with the same title "Pad thai" and a new servings
Then the response status is 200
Scenario: Unique values are scoped to their content type and ignore nulls
Given a content type "dish" with a unique text field "title" and an entry titled "Pad thai"
And the content type "recipe" has an entry titled "Pad thai"
Then both entries exist
And two entries of a type with an optional unique field both leaving it absent are accepted
Scenario: Getting an entry
Given the entry 7 of type "recipe"
When the client sends GET /v1/content-type/recipe/entry/7
Then the response status is 200 and the body is the entry
When the client sends GET /v1/content-type/dish/entry/7
Then the response status is 404 and the body is entry_not_found
Scenario Outline: Entry routes with a bad id or a missing resource
Given a valid jwt cookie
When the client sends <method> <path> <body>
Then the response status is <status>
And the response body is <message>
Examples:
| method | path | body | status | message |
| GET | /v1/content-type/recipe/entry/abc | | 400 | apperr.IdUint32ErrorMessage |
| GET | /v1/content-type/recipe/entry/-1 | | 400 | apperr.IdUint32ErrorMessage |
| GET | /v1/content-type/recipe/entry/4294967296 | | 400 | apperr.IdUint32ErrorMessage |
| GET | /v1/content-type/recipe/entry/999 | | 404 | entry_not_found |
| PUT | /v1/content-type/recipe/entry/999 | and a valid data body | 404 | entry_not_found |
| DELETE | /v1/content-type/recipe/entry/999 | | 404 | entry_not_found |
| GET | /v1/content-type/nope/entry/1 | | 404 | content_type_not_found |
| POST | /v1/content-type/nope/entry | and a valid data body | 404 | content_type_not_found |
| GET | /v1/content-type/nope/entry | | 404 | content_type_not_found |
Scenario: Replacing an entry validates the whole document and keeps provenance
Given the entry 7 of type "recipe" created by admin 2, and a valid jwt cookie for owner 1
When owner 1 sends PUT /v1/content-type/recipe/entry/7 with {"data": {"title": "Green curry", "difficulty": "medium"}}
Then the response status is 200
And data is exactly {"title": "Green curry", "difficulty": "medium"} with no default applied
And created_by is 2 and updated_by is 1 and updated_at is later than created_at
When owner 1 sends PUT with {"data": {"difficulty": "medium"}}
Then the response status is 422 with invalid_entry: title: required and the entry is unchanged
Scenario: Deleting an entry
Given the entry 7 of type "recipe"
When the client sends DELETE /v1/content-type/recipe/entry/7
Then the response status is 204 and the body is empty
And GET on entry 7 returns 404 with entry_not_found
Scenario Outline: Listing entries is paginated and sortable
Given 30 entries of type "recipe", and entries of type "dish" that must not appear
When the client sends GET /v1/content-type/recipe/entry<query>
Then the response status is <status>
And <result>
Examples:
| query | status | result |
| | 200 | items has 25 entries, total 30, page 1, size 25, ordered updated_at desc |
| ?page=2 | 200 | items has 5 entries, total 30, page 2, size 25 |
| ?size=5&page=2 | 200 | items has 5 entries, page 2, size 5 |
| ?sort=id:asc | 200 | items ordered by id ascending |
| ?sort=created_at:desc | 200 | items ordered by created_at descending |
| ?page=99 | 200 | items is [] and total is 30 |
| ?page=0 | 400 | apperr.BodyErrorMessage |
| ?size=7 | 400 | apperr.BodyErrorMessage |
| ?sort=data:asc | 400 | apperr.BodyErrorMessage |
| ?page=abc | 400 | apperr.BodyErrorMessage |
Scenario: An empty content type lists no entries
Given the content type "event" with no entries
When the client sends GET /v1/content-type/event/entry
Then the body is {"items": [], "total": 0, "page": 1, "size": 25} and items is a JSON array, not null
Scenario Outline: Entry routes accept any verified user and API keys, and refuse everyone else
Given the content type "recipe" exists
When <caller> sends POST /v1/content-type/recipe/entry with a valid body
Then the response status is <status>
Examples:
| caller | status |
| an admin cookie | 201 |
| an owner cookie | 201 |
| an active API key of admin 2 | 201 |
| no credentials | 401 |
| a cookie for unverified 4 | 403 |
| a stale token_version cookie | 401 |
Scenario: Built-in resources are not reachable through the generic engine
Given a valid jwt cookie for admin 2
When the client creates a content type with the key "article" and sends GET /v1/content-type/article/entry
Then the routes behave as for any custom type and GET /v1/article is unchanged
And no article, author, site, media or user is read or written by the engine
Scenario: Use case: validating an entry against a schema returns domain sentinels
Given a fake contenttype.Repository holding "recipe" and a fake entry.Repository
When CreateEntry is called with data breaking each rule in the table above
Then errors.Is(err, entry.ErrInvalidEntry) is true for each, and the repository received no Create
And a duplicate unique value returns an error satisfying errors.Is(err, entry.ErrUniqueViolation)
Scenario: Use case: schema updates re-validate stored entries
Given a fake entry.Repository holding entries and a fake contenttype.Repository
When UpdateContentType is called with a schema that an existing entry violates
Then it returns contenttype.ErrSchemaIncompatible and the repository Update is not called
And a type change returns contenttype.ErrInvalidField
And DeleteContentType with entries returns contenttype.ErrHasEntries and nothing is deleted
+100
View File
@@ -0,0 +1,100 @@
# QA procedure: custom content types (backend core)
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. Add in-memory fakes with unique type names across the package: `mockContentTypeRepo`
(implements `contenttype.Repository`, `failNext("Update")` style switch) and `mockEntryRepo` (implements `entry.Repository`, with
`CountByContentType`; `FindByFieldValue` compares decoded
JSON values in Go; ordering, paging and per-type scoping implemented like the real contract; an
`createCalls` counter). Reuse `mockUserRepo`, `stubHasher` and the API key fakes.
3. Build the handler stack from a `*api.UseCases` container holding fakes and mount
`api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). If a nil
use case breaks a constructor, fall back to a fresh `chi.NewRouter()` mirroring the new
content type router: `jwtauth.Verifier`, key middleware, `RequireSignedIn`, `jwtauth.Authenticator`,
`RequireCurrentToken`, `RequireVerifiedUser`, with `RequireOwnerOrAdmin` on POST/PUT/DELETE
`/content-type`. Token: `jwtauth.New("HS256", []byte("test-secret"), nil)`.
4. Inject a fake clock (`now func() time.Time`) into the use cases so `updated_at` > `created_at`.
5. Seed users: owner 1 "marta", admin 2 "dev" (verified), unverified 4 "quinn". Helpers: `cookieFor(userId,
tokenVersion)`, `bearer(raw)`; create the API key through `POST /v1/api-key`.
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`,
`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.
8. Invalid-body table (feature table): each case asserts status, exact body (or contains for
validator text), and that `mockContentTypeRepo` is empty. Cover not-json, `{}`, bad key (`Recipe!`,
1 char, 41 chars), empty name, 0 fields, 51 fields, bad/duplicate/overlong field keys, unknown type,
every min/max/options/unique/default rule violation.
9. Duplicate key -> `409` `content_type_key_taken`, one type stored.
10. Access table: no credentials `401`, user 4 `403` `apperr.VerificationRequiredErrorMessage`, stale
token_version `401`, API key of admin 2 `201`. Failures leave the repository empty.
## Content type read, update, delete
11. List returns an array ordered by name; `GET /{key}` returns the type; unknown or malformed key -> `404`
`content_type_not_found`; same for PUT and DELETE.
12. Update with no entries: name, order, removal and type change accepted (`200`); body key in the PUT is
ignored and the key stays.
13. Update with entries (table): type change `422 invalid_field`; new required without default `409
schema_incompatible`; new required with default `200`; new optional `200`; remove field `200`; remove
an option in use `409`; remove an unused option `200`; lower max below a stored value `409`; label,
order, name `200`. On every `409`/`422`, assert the stored schema is byte-identical to before
(`failNext` not used, compare the fake's stored type).
14. Make a field unique while two stored entries collide -> `409 schema_incompatible`, unchanged.
15. Removed field values are not returned on GET, and are absent from the stored document after the next
PUT of that entry.
16. Delete without entries -> `204` empty body, then `404`. Delete with entries -> `409
content_type_has_entries`, type and entries intact.
17. Repository failure on Update/Delete -> `500` "Internal server error", state unchanged.
## Entries
18. Create with defaults: response key set exactly `id`, `content_type`, `data`, `created_by`,
`updated_by`, `created_at`, `updated_at`; `data` equals `{"title","difficulty","servings":4,
"vegetarian":false}`; no `published_on`/`notes` key; `created_by` = `updated_by` = 2.
19. Explicit value beats default; explicit `null` is kept and no default is applied to it.
20. Invalid-data table (feature table, include 400s: not-json, `{}`, non-object `data`): assert status,
exact body, and `createCalls == 0`. Include the date cases `2026-02-30` and a datetime string, a
string number `"4"`, and an unknown field `colour`.
21. Boundaries: title of exactly 3 and 80 runes (use a multibyte string to pin rune counting), servings 1
and 50, notes 500 runes -> `201`; one beyond each -> `422`.
22. Unique: second entry with the same title -> `409` body `invalid_entry: title: unique`; PUT on the
same entry with its own title -> `200`; same title in another type -> `201`; two entries both
omitting an optional unique field -> `201`.
23. GET: entry of another type's key -> `404` `entry_not_found`; unknown type -> `404`
`content_type_not_found`; ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`.
24. PUT replaces data wholesale: no defaults re-applied; `created_by` unchanged, `updated_by` = caller,
`updated_at` > `created_at`; invalid replacement -> `422` and the stored entry unchanged.
25. DELETE -> `204`, empty body, then `404`; unknown id `404 entry_not_found`.
26. List: seed 30 recipes and some dishes; default page (25 items, total 30, page 1, size 25,
`updated_at:desc`), `?page=2`, `?size=5&page=2`, `?sort=id:asc`, `?sort=created_at:desc`, `?page=99`
(`items: []`, total 30). Bad `page=0`, `size=7`, `sort=data:asc`, `page=abc` -> `400`
`apperr.BodyErrorMessage`. Empty type -> `items` is `[]` (assert JSON `[]`, not `null`).
27. Access table on entry routes: admin, owner, API key -> `201`; none `401`; user 4 `403`; stale `401`.
A non-admin verified caller can read and write entries but not content types (only if a
non-owner/non-admin role exists in the fake; otherwise skip and note it).
28. Isolation: creating a type keyed `article` does not affect `GET /v1/article`; no built-in repository
fake is called by any case in this file.
## Use case level (no HTTP)
29. `CreateEntry`/`UpdateEntry` against `mockContentTypeRepo` + `mockEntryRepo`: each rule breach ->
`errors.Is(err, entry.ErrInvalidEntry)` and no write; duplicate -> `entry.ErrUniqueViolation`.
30. `UpdateContentType`: incompatible stored entry -> `contenttype.ErrSchemaIncompatible` and no `Update`
call; type change -> `ErrInvalidField`; `DeleteContentType` with entries ->
`ErrHasEntries`; unknown key -> `ErrContentTypeNotFound`; taken key -> `ErrKeyTaken`.
31. Schema validator unit table: every row of the "invalid body" table asserted on the domain function
directly with `errors.Is(err, contenttype.ErrInvalidField)`.
## Manual verification (not exercised by the Go tests)
- Real SQLite, then MySQL/MariaDB and PostgreSQL via the existing driver tasks: start `task dev:api`,
confirm `AutoMigrate` creates the content type and entry tables with the JSON documents in text
columns and no table gets created or altered when a new content type is defined.
- Create a type, restart the server and confirm types and entries persist; create 1,000 entries and
confirm list paging and the unique check stay responsive (the unique check is a portable lookup, not a
DB index).
- Fire two concurrent POSTs with the same unique value and record the outcome (known limitation: the
check is not transactional).
- With curl and an API key, create a type and an entry from a non-browser client and confirm a browser
origin in `CORS_ALLOWED_ORIGINS` can use PUT and DELETE (preflight allows them).
@@ -0,0 +1,64 @@
package contenttype
import (
"errors"
"time"
)
var (
ErrContentTypeNotFound = errors.New("content type not found")
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")
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")
)
type FieldType string
const (
TypeText FieldType = "text"
TypeRichText FieldType = "richtext"
TypeNumber FieldType = "number"
TypeBoolean FieldType = "boolean"
TypeDate FieldType = "date"
TypeSelect FieldType = "select"
)
const (
RuleType = "type"
RuleMin = "min"
RuleMax = "max"
RuleOptions = "options"
)
const (
MinKeyRunes = 2
MaxKeyRunes = 40
MaxNameRunes = 100
MinFields = 1
MaxFields = 50
DateLayout = "2006-01-02"
)
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Type FieldType `json:"type"`
Required bool `json:"required"`
Unique bool `json:"unique"`
Default any `json:"default"`
Min any `json:"min"`
Max any `json:"max"`
Options []string `json:"options"`
}
type ContentType struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Fields []Field `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,9 @@
package contenttype
type Repository interface {
Create(contentType ContentType) (ContentType, error)
GetByKey(key string) (ContentType, error)
List() ([]ContentType, error)
Update(contentType ContentType) (ContentType, error)
Delete(id uint32) error
}
+80
View File
@@ -0,0 +1,80 @@
package contenttype
import (
"math"
"time"
"unicode/utf8"
)
type kind struct {
accepts func(value any) bool
measure func(value any) (float64, bool)
bound func(bound any) (float64, bool)
unique bool
}
var kinds = map[FieldType]kind{
TypeText: {accepts: isString, measure: runeCount, bound: wholeLength, unique: true},
TypeRichText: {accepts: isString, measure: runeCount, bound: wholeLength},
TypeNumber: {accepts: isNumber, measure: asNumber, bound: asNumber, unique: true},
TypeBoolean: {accepts: isBoolean},
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
TypeSelect: {accepts: isString, unique: true},
}
func isString(value any) bool {
_, ok := value.(string)
return ok
}
func isBoolean(value any) bool {
_, ok := value.(bool)
return ok
}
func isNumber(value any) bool {
_, ok := asNumber(value)
return ok
}
func isDate(value any) bool {
_, ok := dateDays(value)
return ok
}
func runeCount(value any) (float64, bool) {
text, ok := value.(string)
return float64(utf8.RuneCountInString(text)), ok
}
func wholeLength(bound any) (float64, bool) {
number, ok := asNumber(bound)
return number, ok && number >= 0 && number == math.Trunc(number)
}
func asNumber(value any) (float64, bool) {
var number float64
switch typed := value.(type) {
case float64:
number = typed
case int:
number = float64(typed)
case int64:
number = float64(typed)
default:
return 0, false
}
return number, !math.IsNaN(number) && !math.IsInf(number, 0)
}
func dateDays(value any) (float64, bool) {
text, ok := value.(string)
if !ok {
return 0, false
}
parsed, err := time.Parse(DateLayout, text)
if err != nil || parsed.Format(DateLayout) != text {
return 0, false
}
return float64(parsed.Unix()), true
}
+101
View File
@@ -0,0 +1,101 @@
package contenttype
import (
"RenewCMS/internal/domain/slug"
"regexp"
"strings"
"unicode/utf8"
)
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
func ValidateKey(key string) error {
length := utf8.RuneCountInString(key)
if length < MinKeyRunes || length > MaxKeyRunes || !slug.IsValid(key) {
return ErrInvalidKey
}
return nil
}
func ValidateName(name string) error {
if strings.TrimSpace(name) == "" || utf8.RuneCountInString(name) > MaxNameRunes {
return ErrInvalidName
}
return nil
}
func ValidateFields(fields []Field) ([]Field, error) {
if len(fields) < MinFields || len(fields) > MaxFields {
return nil, ErrInvalidField
}
seen := make(map[string]bool, len(fields))
normalized := make([]Field, 0, len(fields))
for _, field := range fields {
if seen[field.Key] || !field.validSchema() {
return nil, ErrInvalidField
}
seen[field.Key] = true
if field.Options == nil {
field.Options = []string{}
}
normalized = append(normalized, field)
}
return normalized, nil
}
func (f Field) validSchema() bool {
fieldKind, known := kinds[f.Type]
if !known || !fieldKeyPattern.MatchString(f.Key) {
return false
}
if f.Unique && !fieldKind.unique {
return false
}
return f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
}
func (f Field) validOptions() bool {
if f.Type != TypeSelect {
return len(f.Options) == 0
}
if len(f.Options) == 0 {
return false
}
seen := make(map[string]bool, len(f.Options))
for _, option := range f.Options {
if option == "" || seen[option] {
return false
}
seen[option] = true
}
return true
}
func (f Field) validBounds(fieldKind kind) bool {
if f.Min == nil && f.Max == nil {
return true
}
if fieldKind.bound == nil {
return false
}
lower, lowerOk := f.optionalBound(fieldKind, f.Min)
upper, upperOk := f.optionalBound(fieldKind, f.Max)
if !lowerOk || !upperOk {
return false
}
return f.Min == nil || f.Max == nil || lower <= upper
}
func (f Field) optionalBound(fieldKind kind, bound any) (float64, bool) {
if bound == nil {
return 0, true
}
return fieldKind.bound(bound)
}
func (f Field) validDefault() bool {
if f.Default == nil {
return true
}
return !f.Unique && f.Violation(f.Default) == ""
}
+41
View File
@@ -0,0 +1,41 @@
package contenttype
import "slices"
func (f Field) Violation(value any) string {
fieldKind, known := kinds[f.Type]
if !known || !fieldKind.accepts(value) {
return RuleType
}
if rule := f.boundViolation(fieldKind, value); rule != "" {
return rule
}
if len(f.Options) > 0 {
text, _ := value.(string)
if !slices.Contains(f.Options, text) {
return RuleOptions
}
}
return ""
}
func (f Field) boundViolation(fieldKind kind, value any) string {
if fieldKind.measure == nil {
return ""
}
measured, _ := fieldKind.measure(value)
if lower, set := f.measuredBound(fieldKind, f.Min); set && measured < lower {
return RuleMin
}
if upper, set := f.measuredBound(fieldKind, f.Max); set && measured > upper {
return RuleMax
}
return ""
}
func (f Field) measuredBound(fieldKind kind, bound any) (float64, bool) {
if bound == nil {
return 0, false
}
return fieldKind.bound(bound)
}
+90
View File
@@ -0,0 +1,90 @@
package entry
import (
"RenewCMS/internal/domain/contenttype"
"reflect"
"sort"
)
func SameValue(left any, right any) bool {
return reflect.DeepEqual(left, right)
}
type UniqueValue struct {
Key string
Value any
}
func ApplyDefaults(fields []contenttype.Field, data map[string]any) map[string]any {
filled := make(map[string]any, len(data))
for key, value := range data {
filled[key] = value
}
for _, field := range fields {
if _, present := filled[field.Key]; !present && field.Default != nil {
filled[field.Key] = field.Default
}
}
return filled
}
func Project(fields []contenttype.Field, data map[string]any) map[string]any {
projected := make(map[string]any, len(data))
for _, field := range fields {
if value, present := data[field.Key]; present {
projected[field.Key] = value
}
}
return projected
}
func Validate(fields []contenttype.Field, data map[string]any) error {
for _, field := range fields {
if err := validateField(field, data); err != nil {
return err
}
}
return firstUnknownField(fields, data)
}
func validateField(field contenttype.Field, data map[string]any) error {
value := data[field.Key]
if value == nil {
if field.Required {
return invalidField(field.Key, RuleRequired)
}
return nil
}
if rule := field.Violation(value); rule != "" {
return invalidField(field.Key, rule)
}
return nil
}
func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
known := make(map[string]bool, len(fields))
for _, field := range fields {
known[field.Key] = true
}
var unknown []string
for key := range data {
if !known[key] {
unknown = append(unknown, key)
}
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
return invalidField(unknown[0], RuleUnknownField)
}
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
var values []UniqueValue
for _, field := range fields {
if value := data[field.Key]; field.Unique && value != nil {
values = append(values, UniqueValue{Key: field.Key, Value: value})
}
}
return values
}
+64
View File
@@ -0,0 +1,64 @@
package entry
import (
"errors"
"time"
)
var (
ErrEntryNotFound = errors.New("entry not found")
ErrInvalidEntry = errors.New("invalid entry")
ErrUniqueViolation = errors.New("a unique value is already used")
ErrInvalidSort = errors.New("invalid sort")
ErrInvalidPage = errors.New("invalid page")
)
const (
MaxPage = 1000000
MaxPageSize = 100
)
func ValidatePage(page int, size int) error {
if page > MaxPage || size > MaxPageSize {
return ErrInvalidPage
}
return nil
}
const (
RuleRequired = "required"
RuleUnknownField = "unknown_field"
RuleUnique = "unique"
)
type Entry struct {
ID uint32 `json:"id"`
ContentTypeID uint32 `json:"content_type_id"`
Data map[string]any `json:"data"`
CreatedBy uint32 `json:"created_by"`
UpdatedBy uint32 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FieldError struct {
Key string
Rule string
Cause error
}
func (e FieldError) Error() string {
return e.Key + ": " + e.Rule
}
func (e FieldError) Unwrap() error {
return e.Cause
}
func invalidField(key string, rule string) error {
return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
}
func UniqueViolation(key string) error {
return FieldError{Key: key, Rule: RuleUnique, Cause: ErrUniqueViolation}
}
@@ -0,0 +1,71 @@
package entry
import "strings"
type SortField string
type SortDirection string
const (
SortByID SortField = "id"
SortByCreatedAt SortField = "created_at"
SortByUpdatedAt SortField = "updated_at"
SortAsc SortDirection = "asc"
SortDesc SortDirection = "desc"
)
type Sort struct {
Field SortField
Direction SortDirection
}
func (s Sort) Valid() bool {
switch s.Field {
case SortByID, SortByCreatedAt, SortByUpdatedAt:
default:
return false
}
return s.Direction == SortAsc || s.Direction == SortDesc
}
func (s Sort) String() string {
return string(s.Field) + ":" + string(s.Direction)
}
func ParseSort(value string) (Sort, error) {
field, direction, found := strings.Cut(value, ":")
parsed := Sort{Field: SortField(field), Direction: SortDirection(direction)}
if !found || !parsed.Valid() {
return Sort{}, ErrInvalidSort
}
return parsed, nil
}
func DefaultSort() Sort {
return Sort{Field: SortByUpdatedAt, Direction: SortDesc}
}
type Filters struct {
ContentTypeID uint32
Page int
Size int
Sort Sort
Unpaged bool
}
type PageResult struct {
Items []Entry
Total int
}
type Repository interface {
Create(entry Entry) (Entry, error)
Get(contentTypeID uint32, id uint32) (Entry, error)
ListPage(filters Filters) (PageResult, error)
Update(entry Entry) (Entry, error)
Delete(contentTypeID uint32, id uint32) error
DeleteByContentType(contentTypeID uint32) error
CountByContentType(contentTypeID uint32) (int, error)
FindByFieldValue(contentTypeID uint32, fieldKey string, value any, excludeID uint32) ([]Entry, error)
}
@@ -7,7 +7,7 @@ import (
) )
func Migrate(db *gorm.DB, publicURL string) error { func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}) err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{})
if err != nil { if err != nil {
return err return err
} }
@@ -0,0 +1,37 @@
package mappers
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
func ContentTypeToDomain(m models.ContentType) (contenttype.ContentType, error) {
var fields []contenttype.Field
if err := json.Unmarshal([]byte(m.Fields), &fields); err != nil {
return contenttype.ContentType{}, err
}
return contenttype.ContentType{
ID: m.ID,
Key: m.Key,
Name: m.Name,
Fields: fields,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}, nil
}
func ContentTypeToModel(c contenttype.ContentType) (models.ContentType, error) {
fields, err := json.Marshal(c.Fields)
if err != nil {
return models.ContentType{}, err
}
return models.ContentType{
ID: c.ID,
Key: c.Key,
Name: c.Name,
Fields: string(fields),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}, nil
}
@@ -0,0 +1,39 @@
package mappers
import (
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
func EntryToDomain(m models.Entry) (entry.Entry, error) {
data := map[string]any{}
if err := json.Unmarshal([]byte(m.Data), &data); err != nil {
return entry.Entry{}, err
}
return entry.Entry{
ID: m.ID,
ContentTypeID: m.ContentTypeID,
Data: data,
CreatedBy: m.CreatedBy,
UpdatedBy: m.UpdatedBy,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}, nil
}
func EntryToModel(e entry.Entry) (models.Entry, error) {
data, err := json.Marshal(e.Data)
if err != nil {
return models.Entry{}, err
}
return models.Entry{
ID: e.ID,
ContentTypeID: e.ContentTypeID,
Data: string(data),
CreatedBy: e.CreatedBy,
UpdatedBy: e.UpdatedBy,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}, nil
}
@@ -0,0 +1,12 @@
package models
import "time"
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"`
Fields string `gorm:"type:text;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -0,0 +1,13 @@
package models
import "time"
type Entry struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
ContentTypeID uint32 `gorm:"not null;index"`
Data string `gorm:"type:text;not null"`
CreatedBy uint32 `gorm:"not null"`
UpdatedBy uint32 `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -0,0 +1,98 @@
package persistence
import (
domain "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"strings"
"gorm.io/gorm"
)
var _ domain.Repository = &ContentTypeRepository{}
type ContentTypeRepository struct {
db *gorm.DB
}
func NewContentTypeRepository(db *gorm.DB) *ContentTypeRepository {
return &ContentTypeRepository{db}
}
func (c *ContentTypeRepository) Create(contentType domain.ContentType) (domain.ContentType, error) {
model, err := mappers.ContentTypeToModel(contentType)
if err != nil {
return domain.ContentType{}, err
}
if err := c.db.Create(&model).Error; err != nil {
return domain.ContentType{}, mapKeyTakenErr(err)
}
return mappers.ContentTypeToDomain(model)
}
func (c *ContentTypeRepository) GetByKey(key string) (domain.ContentType, error) {
var model entity.ContentType
if err := c.db.Where("type_key = ?", key).First(&model).Error; err != nil {
return domain.ContentType{}, mapNotFoundErr(err, domain.ErrContentTypeNotFound)
}
return mappers.ContentTypeToDomain(model)
}
func (c *ContentTypeRepository) List() ([]domain.ContentType, error) {
var found []entity.ContentType
if err := c.db.Order("LOWER(name) ASC, id ASC").Find(&found).Error; err != nil {
return nil, err
}
contentTypes := make([]domain.ContentType, 0, len(found))
for _, model := range found {
contentType, err := mappers.ContentTypeToDomain(model)
if err != nil {
return nil, err
}
contentTypes = append(contentTypes, contentType)
}
return contentTypes, nil
}
func (c *ContentTypeRepository) Update(contentType domain.ContentType) (domain.ContentType, error) {
model, err := mappers.ContentTypeToModel(contentType)
if err != nil {
return domain.ContentType{}, err
}
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Fields", "UpdatedAt").Updates(&model)
if err := deleteOrNotFound(result, domain.ErrContentTypeNotFound); err != nil {
return domain.ContentType{}, mapKeyTakenErr(err)
}
return contentType, nil
}
func (c *ContentTypeRepository) Delete(id uint32) error {
return deleteOrNotFound(c.db.Delete(&entity.ContentType{}, id), domain.ErrContentTypeNotFound)
}
var uniqueViolationMarkers = []string{
"unique constraint failed",
"duplicate entry",
"duplicate key value violates unique constraint",
}
func isUniqueViolation(err error) bool {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
message := strings.ToLower(err.Error())
for _, marker := range uniqueViolationMarkers {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func mapKeyTakenErr(err error) error {
if isUniqueViolation(err) {
return domain.ErrKeyTaken
}
return err
}
@@ -0,0 +1,152 @@
package persistence
import (
domain "RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
var _ domain.Repository = &EntryRepository{}
type EntryRepository struct {
db *gorm.DB
}
func NewEntryRepository(db *gorm.DB) *EntryRepository {
return &EntryRepository{db}
}
var entrySortColumns = map[domain.SortField]string{
domain.SortByID: "id",
domain.SortByCreatedAt: "created_at",
domain.SortByUpdatedAt: "updated_at",
}
var entrySortDirections = map[domain.SortDirection]string{
domain.SortAsc: "ASC",
domain.SortDesc: "DESC",
}
func EntryOrderClause(sort domain.Sort) (string, error) {
column, columnKnown := entrySortColumns[sort.Field]
direction, directionKnown := entrySortDirections[sort.Direction]
if !columnKnown || !directionKnown || !sort.Valid() {
return "", domain.ErrInvalidSort
}
if sort.Field == domain.SortByID {
return column + " " + direction, nil
}
return column + " " + direction + ", id DESC", nil
}
func (e *EntryRepository) Create(record domain.Entry) (domain.Entry, error) {
model, err := mappers.EntryToModel(record)
if err != nil {
return domain.Entry{}, err
}
if err := e.db.Create(&model).Error; err != nil {
return domain.Entry{}, err
}
return mappers.EntryToDomain(model)
}
func (e *EntryRepository) Get(contentTypeID uint32, id uint32) (domain.Entry, error) {
var model entity.Entry
if err := e.db.Where("content_type_id = ? AND id = ?", contentTypeID, id).First(&model).Error; err != nil {
return domain.Entry{}, mapNotFoundErr(err, domain.ErrEntryNotFound)
}
return mappers.EntryToDomain(model)
}
func (e *EntryRepository) ListPage(filters domain.Filters) (domain.PageResult, error) {
order, err := EntryOrderClause(filters.Sort)
if err != nil {
return domain.PageResult{}, err
}
if err := domain.ValidatePage(filters.Page, filters.Size); err != nil {
return domain.PageResult{}, err
}
filters.Page = max(filters.Page, 1)
filters.Size = max(filters.Size, 0)
scoped := func() *gorm.DB {
return e.db.Model(&entity.Entry{}).Where("content_type_id = ?", filters.ContentTypeID)
}
var total int64
if err := scoped().Count(&total).Error; err != nil {
return domain.PageResult{}, err
}
ordered := scoped().Order(order)
if !filters.Unpaged {
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
}
var models []entity.Entry
if err := ordered.Find(&models).Error; err != nil {
return domain.PageResult{}, err
}
items, err := entriesToDomain(models)
if err != nil {
return domain.PageResult{}, err
}
return domain.PageResult{Items: items, Total: int(total)}, nil
}
func (e *EntryRepository) Update(record domain.Entry) (domain.Entry, error) {
model, err := mappers.EntryToModel(record)
if err != nil {
return domain.Entry{}, err
}
result := e.db.Model(&entity.Entry{}).Where("id = ?", model.ID).Select("Data", "UpdatedBy", "UpdatedAt").Updates(&model)
if err := deleteOrNotFound(result, domain.ErrEntryNotFound); err != nil {
return domain.Entry{}, err
}
return record, nil
}
func (e *EntryRepository) Delete(contentTypeID uint32, id uint32) error {
result := e.db.Where("content_type_id = ? AND id = ?", contentTypeID, id).Delete(&entity.Entry{})
return deleteOrNotFound(result, domain.ErrEntryNotFound)
}
func (e *EntryRepository) DeleteByContentType(contentTypeID uint32) error {
return e.db.Where("content_type_id = ?", contentTypeID).Delete(&entity.Entry{}).Error
}
func (e *EntryRepository) CountByContentType(contentTypeID uint32) (int, error) {
var total int64
if err := e.db.Model(&entity.Entry{}).Where("content_type_id = ?", contentTypeID).Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
func (e *EntryRepository) FindByFieldValue(contentTypeID uint32, fieldKey string, value any, excludeID uint32) ([]domain.Entry, error) {
var models []entity.Entry
if err := e.db.Where("content_type_id = ? AND id <> ?", contentTypeID, excludeID).Find(&models).Error; err != nil {
return nil, err
}
candidates, err := entriesToDomain(models)
if err != nil {
return nil, err
}
matching := []domain.Entry{}
for _, candidate := range candidates {
if domain.SameValue(candidate.Data[fieldKey], value) {
matching = append(matching, candidate)
}
}
return matching, nil
}
func entriesToDomain(models []entity.Entry) ([]domain.Entry, error) {
items := make([]domain.Entry, 0, len(models))
for _, model := range models {
item, err := mappers.EntryToDomain(model)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
@@ -0,0 +1,57 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"errors"
"time"
)
type CreateContentTypeUseCase struct {
contentTypes contenttype.Repository
now func() time.Time
}
type CreateContentTypeCommand struct {
Key string
Name string
Fields []contenttype.Field
}
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase {
return &CreateContentTypeUseCase{contentTypes, now}
}
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
if err := contenttype.ValidateKey(command.Key); err != nil {
return contenttype.ContentType{}, err
}
if err := contenttype.ValidateName(command.Name); err != nil {
return contenttype.ContentType{}, err
}
fields, err := contenttype.ValidateFields(command.Fields)
if err != nil {
return contenttype.ContentType{}, err
}
if err := u.ensureKeyFree(command.Key); err != nil {
return contenttype.ContentType{}, err
}
created := u.now()
return u.contentTypes.Create(contenttype.ContentType{
Key: command.Key,
Name: command.Name,
Fields: fields,
CreatedAt: created,
UpdatedAt: created,
})
}
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
_, err := u.contentTypes.GetByKey(key)
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
return nil
}
if err != nil {
return err
}
return contenttype.ErrKeyTaken
}
@@ -0,0 +1,50 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"time"
)
type CreateEntryUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
now func() time.Time
}
type CreateEntryCommand struct {
ContentTypeKey string
Data map[string]any
UserID uint32
}
func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *CreateEntryUseCase {
return &CreateEntryUseCase{contentTypes, entries, now}
}
func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, error) {
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
if err != nil {
return TypedEntry{}, err
}
data := entry.ApplyDefaults(owner.Fields, command.Data)
if err := entry.Validate(owner.Fields, data); err != nil {
return TypedEntry{}, err
}
if err := ensureUnique(u.entries, owner, data, 0); err != nil {
return TypedEntry{}, err
}
created := u.now()
stored, err := u.entries.Create(entry.Entry{
ContentTypeID: owner.ID,
Data: data,
CreatedBy: command.UserID,
UpdatedBy: command.UserID,
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
return TypedEntry{}, err
}
return typedEntry(owner, stored), nil
}
@@ -0,0 +1,30 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
)
type DeleteContentTypeUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
}
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase {
return &DeleteContentTypeUseCase{contentTypes, entries}
}
func (u *DeleteContentTypeUseCase) Delete(key string) error {
target, err := u.contentTypes.GetByKey(key)
if err != nil {
return err
}
count, err := u.entries.CountByContentType(target.ID)
if err != nil {
return err
}
if count > 0 {
return contenttype.ErrHasEntries
}
return u.contentTypes.Delete(target.ID)
}
@@ -0,0 +1,23 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
)
type DeleteEntryUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
}
func NewDeleteEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteEntryUseCase {
return &DeleteEntryUseCase{contentTypes, entries}
}
func (u *DeleteEntryUseCase) Delete(contentTypeKey string, id uint32) error {
owner, err := u.contentTypes.GetByKey(contentTypeKey)
if err != nil {
return err
}
return u.entries.Delete(owner.ID, id)
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/contenttype"
type GetContentTypeUseCase struct {
contentTypes contenttype.Repository
}
func NewGetContentTypeUseCase(contentTypes contenttype.Repository) *GetContentTypeUseCase {
return &GetContentTypeUseCase{contentTypes}
}
func (u *GetContentTypeUseCase) Get(key string) (contenttype.ContentType, error) {
return u.contentTypes.GetByKey(key)
}
@@ -0,0 +1,27 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
)
type GetEntryUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
}
func NewGetEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository) *GetEntryUseCase {
return &GetEntryUseCase{contentTypes, entries}
}
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, error) {
owner, err := u.contentTypes.GetByKey(contentTypeKey)
if err != nil {
return TypedEntry{}, err
}
found, err := u.entries.Get(owner.ID, id)
if err != nil {
return TypedEntry{}, err
}
return typedEntry(owner, found), nil
}
@@ -0,0 +1,22 @@
package useCases
import "RenewCMS/internal/domain/contenttype"
type ListContentTypesUseCase struct {
contentTypes contenttype.Repository
}
func NewListContentTypesUseCase(contentTypes contenttype.Repository) *ListContentTypesUseCase {
return &ListContentTypesUseCase{contentTypes}
}
func (u *ListContentTypesUseCase) List() ([]contenttype.ContentType, error) {
found, err := u.contentTypes.List()
if err != nil {
return nil, err
}
if found == nil {
return []contenttype.ContentType{}, nil
}
return found, nil
}
@@ -0,0 +1,51 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
)
type ListEntriesUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
}
type ListEntriesCommand struct {
ContentTypeKey string
Page int
Size int
Sort entry.Sort
}
type EntryPage struct {
Items []TypedEntry
Total int
Page int
Size int
}
func NewListEntriesUseCase(contentTypes contenttype.Repository, entries entry.Repository) *ListEntriesUseCase {
return &ListEntriesUseCase{contentTypes, entries}
}
func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, error) {
if err := entry.ValidatePage(command.Page, command.Size); err != nil {
return EntryPage{}, err
}
if !command.Sort.Valid() {
return EntryPage{}, entry.ErrInvalidSort
}
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
if err != nil {
return EntryPage{}, err
}
found, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
if err != nil {
return EntryPage{}, err
}
items := make([]TypedEntry, 0, len(found.Items))
for _, record := range found.Items {
items = append(items, typedEntry(owner, record))
}
return EntryPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
}
@@ -0,0 +1,100 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"time"
)
type UpdateContentTypeUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
now func() time.Time
}
type UpdateContentTypeCommand struct {
Key string
Name string
Fields []contenttype.Field
}
func NewUpdateContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *UpdateContentTypeUseCase {
return &UpdateContentTypeUseCase{contentTypes, entries, now}
}
func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (contenttype.ContentType, error) {
current, err := u.contentTypes.GetByKey(command.Key)
if err != nil {
return contenttype.ContentType{}, err
}
if err := contenttype.ValidateName(command.Name); err != nil {
return contenttype.ContentType{}, err
}
fields, err := contenttype.ValidateFields(command.Fields)
if err != nil {
return contenttype.ContentType{}, err
}
stored, err := u.storedEntries(current.ID)
if err != nil {
return contenttype.ContentType{}, err
}
if len(stored) > 0 {
if err := checkCompatibility(current.Fields, fields, stored); err != nil {
return contenttype.ContentType{}, err
}
}
current.Name = command.Name
current.Fields = fields
current.UpdatedAt = u.now()
return u.contentTypes.Update(current)
}
func (u *UpdateContentTypeUseCase) storedEntries(contentTypeID uint32) ([]entry.Entry, error) {
found, err := u.entries.ListPage(entry.Filters{ContentTypeID: contentTypeID, Unpaged: true, Sort: entry.DefaultSort()})
if err != nil {
return nil, err
}
return found.Items, nil
}
func checkCompatibility(previous []contenttype.Field, next []contenttype.Field, stored []entry.Entry) error {
if fieldTypeChanged(previous, next) {
return contenttype.ErrInvalidField
}
seen := make(map[string][]any)
for _, record := range stored {
data := entry.ApplyDefaults(next, entry.Project(next, record.Data))
if entry.Validate(next, data) != nil {
return contenttype.ErrSchemaIncompatible
}
if collides(seen, entry.UniqueValues(next, data)) {
return contenttype.ErrSchemaIncompatible
}
}
return nil
}
func fieldTypeChanged(previous []contenttype.Field, next []contenttype.Field) bool {
types := make(map[string]contenttype.FieldType, len(previous))
for _, field := range previous {
types[field.Key] = field.Type
}
for _, field := range next {
if before, existed := types[field.Key]; existed && before != field.Type {
return true
}
}
return false
}
func collides(seen map[string][]any, values []entry.UniqueValue) bool {
for _, candidate := range values {
for _, earlier := range seen[candidate.Key] {
if entry.SameValue(earlier, candidate.Value) {
return true
}
}
seen[candidate.Key] = append(seen[candidate.Key], candidate.Value)
}
return false
}
@@ -0,0 +1,49 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"time"
)
type UpdateEntryUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
now func() time.Time
}
type UpdateEntryCommand struct {
ContentTypeKey string
ID uint32
Data map[string]any
UserID uint32
}
func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *UpdateEntryUseCase {
return &UpdateEntryUseCase{contentTypes, entries, now}
}
func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, error) {
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
if err != nil {
return TypedEntry{}, err
}
current, err := u.entries.Get(owner.ID, command.ID)
if err != nil {
return TypedEntry{}, err
}
if err := entry.Validate(owner.Fields, command.Data); err != nil {
return TypedEntry{}, err
}
if err := ensureUnique(u.entries, owner, command.Data, current.ID); err != nil {
return TypedEntry{}, err
}
current.Data = command.Data
current.UpdatedBy = command.UserID
current.UpdatedAt = u.now()
stored, err := u.entries.Update(current)
if err != nil {
return TypedEntry{}, err
}
return typedEntry(owner, stored), nil
}
@@ -0,0 +1,29 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
)
type TypedEntry struct {
entry.Entry
ContentTypeKey string
}
func typedEntry(owner contenttype.ContentType, record entry.Entry) TypedEntry {
record.Data = entry.Project(owner.Fields, record.Data)
return TypedEntry{Entry: record, ContentTypeKey: owner.Key}
}
func ensureUnique(entries entry.Repository, owner contenttype.ContentType, data map[string]any, excludeID uint32) error {
for _, candidate := range entry.UniqueValues(owner.Fields, data) {
taken, err := entries.FindByFieldValue(owner.ID, candidate.Key, candidate.Value, excludeID)
if err != nil {
return err
}
if len(taken) > 0 {
return entry.UniqueViolation(candidate.Key)
}
}
return nil
}
+379
View File
@@ -0,0 +1,379 @@
package test
import (
"RenewCMS/api"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
ctOwnerId = 1
ctAdminId = 2
ctMemberId = 3
ctUnverifiedId = 4
ctStaleId = 5
)
type mockContentTypeRepo struct {
types map[uint32]contenttype.ContentType
nextID uint32
createCalls int
updateCalls int
deleteCalls int
fail failSwitch
}
var _ contenttype.Repository = &mockContentTypeRepo{}
func newMockContentTypeRepo() *mockContentTypeRepo {
return &mockContentTypeRepo{types: map[uint32]contenttype.ContentType{}}
}
func (m *mockContentTypeRepo) Create(contentType contenttype.ContentType) (contenttype.ContentType, error) {
if err := m.fail.trigger("Create"); err != nil {
return contenttype.ContentType{}, err
}
m.createCalls++
m.nextID++
contentType.ID = m.nextID
m.types[contentType.ID] = contentType
return contentType, nil
}
func (m *mockContentTypeRepo) GetByKey(key string) (contenttype.ContentType, error) {
if err := m.fail.trigger("GetByKey"); err != nil {
return contenttype.ContentType{}, err
}
for _, stored := range m.types {
if stored.Key == key {
return stored, nil
}
}
return contenttype.ContentType{}, contenttype.ErrContentTypeNotFound
}
func (m *mockContentTypeRepo) List() ([]contenttype.ContentType, error) {
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
listed := []contenttype.ContentType{}
for _, stored := range m.types {
listed = append(listed, stored)
}
sort.Slice(listed, func(i, j int) bool { return listed[i].Name < listed[j].Name })
return listed, nil
}
func (m *mockContentTypeRepo) Update(contentType contenttype.ContentType) (contenttype.ContentType, error) {
if err := m.fail.trigger("Update"); err != nil {
return contenttype.ContentType{}, err
}
m.updateCalls++
m.types[contentType.ID] = contentType
return contentType, nil
}
func (m *mockContentTypeRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
m.deleteCalls++
delete(m.types, id)
return nil
}
type mockEntryRepo struct {
entries map[uint32]entry.Entry
nextID uint32
createCalls int
updateCalls int
}
var _ entry.Repository = &mockEntryRepo{}
func newMockEntryRepo() *mockEntryRepo {
return &mockEntryRepo{entries: map[uint32]entry.Entry{}}
}
func (m *mockEntryRepo) Create(record entry.Entry) (entry.Entry, error) {
m.createCalls++
m.nextID++
record.ID = m.nextID
m.entries[record.ID] = record
return record, nil
}
func (m *mockEntryRepo) Get(contentTypeID uint32, id uint32) (entry.Entry, error) {
if stored, found := m.entries[id]; found && stored.ContentTypeID == contentTypeID {
return stored, nil
}
return entry.Entry{}, entry.ErrEntryNotFound
}
func (m *mockEntryRepo) ListPage(filters entry.Filters) (entry.PageResult, error) {
matching := []entry.Entry{}
for _, stored := range m.entries {
if stored.ContentTypeID == filters.ContentTypeID {
matching = append(matching, stored)
}
}
sortEntries(matching, filters.Sort)
page := entry.PageResult{Items: []entry.Entry{}, Total: len(matching)}
if filters.Unpaged {
page.Items = matching
return page, nil
}
start := min((filters.Page-1)*filters.Size, len(matching))
end := min(start+filters.Size, len(matching))
page.Items = matching[start:end]
return page, nil
}
func sortEntries(items []entry.Entry, order entry.Sort) {
if order.Field == "" {
order = entry.DefaultSort()
}
key := func(item entry.Entry) int64 {
switch order.Field {
case entry.SortByID:
return int64(item.ID)
case entry.SortByCreatedAt:
return item.CreatedAt.UnixNano()
}
return item.UpdatedAt.UnixNano()
}
sort.SliceStable(items, func(i, j int) bool {
if order.Direction == entry.SortAsc {
return key(items[i]) < key(items[j])
}
return key(items[i]) > key(items[j])
})
}
func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
m.updateCalls++
m.entries[record.ID] = record
return record, nil
}
func (m *mockEntryRepo) Delete(contentTypeID uint32, id uint32) error {
if _, err := m.Get(contentTypeID, id); err != nil {
return err
}
delete(m.entries, id)
return nil
}
func (m *mockEntryRepo) DeleteByContentType(contentTypeID uint32) error {
for id, stored := range m.entries {
if stored.ContentTypeID == contentTypeID {
delete(m.entries, id)
}
}
return nil
}
func (m *mockEntryRepo) CountByContentType(contentTypeID uint32) (int, error) {
count := 0
for _, stored := range m.entries {
if stored.ContentTypeID == contentTypeID {
count++
}
}
return count, nil
}
func (m *mockEntryRepo) FindByFieldValue(contentTypeID uint32, fieldKey string, value any, excludeID uint32) ([]entry.Entry, error) {
found := []entry.Entry{}
for _, stored := range m.entries {
if stored.ContentTypeID == contentTypeID && stored.ID != excludeID && reflect.DeepEqual(stored.Data[fieldKey], value) {
found = append(found, stored)
}
}
return found, nil
}
type contentTypeEnv struct {
types *mockContentTypeRepo
entries *mockEntryRepo
users *mockUserRepo
keys *mockAPIKeyRepo
generator *hashing.SHA256KeyGenerator
token *jwtauth.JWTAuth
clock time.Time
router http.Handler
}
func newContentTypeEnv(t *testing.T) *contentTypeEnv {
t.Helper()
validator.InitValidator()
entries := newMockEntryRepo()
env := &contentTypeEnv{
types: newMockContentTypeRepo(),
entries: entries,
users: newMockUserRepo(),
keys: newMockAPIKeyRepo(),
generator: hashing.NewSHA256KeyGenerator(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
}
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner}
env.users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true}
env.users.users[ctUnverifiedId] = user.User{ID: ctUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
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),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
env.router = router
return env
}
func (e *contentTypeEnv) request(t *testing.T, method string, path string, body string, mutate func(*http.Request)) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if mutate != nil {
mutate(req)
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func (e *contentTypeEnv) asUser(t *testing.T, userId uint32) func(*http.Request) {
t.Helper()
return func(req *http.Request) {
if userId == 0 {
return
}
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
}
func (e *contentTypeEnv) as(t *testing.T, userId uint32, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.request(t, method, path, body, e.asUser(t, userId))
}
func (e *contentTypeEnv) issueKey(userId uint32) string {
raw, prefix, hashed := e.generator.Generate()
_, _ = e.keys.Create(apikey.APIKey{UserID: userId, Name: "ci", Prefix: prefix, HashedKey: hashed, CreatedAt: e.clock})
return raw
}
func (e *contentTypeEnv) admin(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.as(t, ctAdminId, method, path, body)
}
func (e *contentTypeEnv) createType(t *testing.T, key string, fields []map[string]any) {
t.Helper()
rec := e.admin(t, http.MethodPost, "/v1/content-type", typeBody(key, strings.ToUpper(key[:1])+key[1:], fields))
if rec.Code != http.StatusCreated {
t.Fatalf("create type %s: %d %s", key, rec.Code, rec.Body.String())
}
}
func (e *contentTypeEnv) createEntry(t *testing.T, key string, userId uint32, data string) *httptest.ResponseRecorder {
t.Helper()
e.clock = e.clock.Add(time.Minute)
return e.as(t, userId, http.MethodPost, "/v1/content-type/"+key+"/entry", `{"data": `+data+`}`)
}
func typeBody(key string, name string, fields []map[string]any) string {
encoded, _ := json.Marshal(map[string]any{"key": key, "name": name, "fields": fields})
return string(encoded)
}
func recipeFields() []map[string]any {
return []map[string]any{
{"key": "title", "label": "Title", "type": "text", "required": true, "unique": true, "min": 3, "max": 80},
{"key": "servings", "label": "Servings", "type": "number", "default": 4, "min": 1, "max": 50},
{"key": "vegetarian", "label": "Vegetarian", "type": "boolean", "default": false},
{"key": "published_on", "label": "Published on", "type": "date"},
{"key": "difficulty", "label": "Difficulty", "type": "select", "required": true, "options": []string{"easy", "medium", "hard"}},
{"key": "notes", "label": "Notes", "type": "richtext", "max": 500},
}
}
func dishFields() []map[string]any {
return []map[string]any{
{"key": "title", "label": "Title", "type": "text", "unique": true},
{"key": "slug", "label": "Slug", "type": "text", "unique": true},
}
}
func textField(overrides map[string]any) map[string]any {
field := map[string]any{"key": "title", "label": "Title", "type": "text"}
for key, value := range overrides {
field[key] = value
}
return field
}
func decodeContentObject(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 objectKeys(object map[string]any) []string {
keys := make([]string, 0, len(object))
for key := range object {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func expectStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status {
t.Fatalf("expected status %d, got %d, body %q", status, rec.Code, rec.Body.String())
}
if body != "" && strings.TrimSpace(rec.Body.String()) != body {
t.Errorf("expected body %q, got %q", body, rec.Body.String())
}
}
+668
View File
@@ -0,0 +1,668 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
"time"
)
var (
contentTypeKeys = []string{"created_at", "fields", "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"}
)
func TestCreatingAContentType(t *testing.T) {
t.Run("Creating a content type", func(t *testing.T) {
env := newContentTypeEnv(t)
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("recipe", "Recipe", recipeFields()))
expectStatusAndBody(t, rec, http.StatusCreated, "")
body := decodeContentObject(t, rec)
if !reflect.DeepEqual(objectKeys(body), contentTypeKeys) || body["key"] != "recipe" {
t.Fatalf("unexpected body %v", body)
}
fields := body["fields"].([]any)
for index, sent := range recipeFields() {
field := fields[index].(map[string]any)
if field["key"] != sent["key"] || !reflect.DeepEqual(objectKeys(field), fieldKeys) {
t.Errorf("unexpected field %d: %v", index, field)
}
}
first := fields[0].(map[string]any)
if first["default"] != nil || fields[3].(map[string]any)["min"] != nil || len(fields[0].(map[string]any)["options"].([]any)) != 0 {
t.Errorf("expected null unset values and [] options: %v", first)
}
})
}
func TestCreatingAContentTypeWithAnInvalidBody(t *testing.T) {
many := make([]map[string]any, 51)
for index := range many {
many[index] = textField(map[string]any{"key": "f" + strconv.Itoa(index)})
}
one := []map[string]any{textField(nil)}
invalidField := func(overrides map[string]any) []map[string]any {
return []map[string]any{textField(overrides)}
}
examples := []struct {
name string
body string
status int
message string
}{
{"body not-json", "not-json", 400, apperr.BodyErrorMessage},
{"body {}", "{}", 400, "Key"},
{"key with a capital and a symbol", typeBody("Recipe!", "R", one), 400, apperr.InvalidContentTypeKeyErrorMessage},
{"key too short", typeBody("r", "R", one), 400, apperr.InvalidContentTypeKeyErrorMessage},
{"key too long", typeBody(strings.Repeat("a", 41), "R", one), 400, apperr.InvalidContentTypeKeyErrorMessage},
{"empty name", typeBody("recipe", "", one), 400, "Name"},
{"no fields", typeBody("recipe", "R", []map[string]any{}), 422, apperr.InvalidFieldErrorMessage},
{"51 fields", typeBody("recipe", "R", many), 422, apperr.InvalidFieldErrorMessage},
{"field key Title", typeBody("recipe", "R", invalidField(map[string]any{"key": "Title"})), 422, apperr.InvalidFieldErrorMessage},
{"field key 1st", typeBody("recipe", "R", invalidField(map[string]any{"key": "1st"})), 422, apperr.InvalidFieldErrorMessage},
{"field key a-b", typeBody("recipe", "R", invalidField(map[string]any{"key": "a-b"})), 422, apperr.InvalidFieldErrorMessage},
{"two fields with the key title", typeBody("recipe", "R", []map[string]any{textField(nil), textField(nil)}), 422, apperr.InvalidFieldErrorMessage},
{"a field type integer", typeBody("recipe", "R", invalidField(map[string]any{"type": "integer"})), 422, apperr.InvalidFieldErrorMessage},
{"a select field with no options", typeBody("recipe", "R", invalidField(map[string]any{"type": "select"})), 422, apperr.InvalidFieldErrorMessage},
{"a select field with an empty option", typeBody("recipe", "R", invalidField(map[string]any{"type": "select", "options": []string{"a", ""}})), 422, apperr.InvalidFieldErrorMessage},
{"a select field with a duplicated option", typeBody("recipe", "R", invalidField(map[string]any{"type": "select", "options": []string{"a", "a"}})), 422, apperr.InvalidFieldErrorMessage},
{"options on a non-select field", typeBody("recipe", "R", invalidField(map[string]any{"options": []string{"a"}})), 422, apperr.InvalidFieldErrorMessage},
{"min on a boolean field", typeBody("recipe", "R", invalidField(map[string]any{"type": "boolean", "min": 1})), 422, apperr.InvalidFieldErrorMessage},
{"max on a select field", typeBody("recipe", "R", invalidField(map[string]any{"type": "select", "options": []string{"a"}, "max": 1})), 422, apperr.InvalidFieldErrorMessage},
{"min greater than max", typeBody("recipe", "R", invalidField(map[string]any{"type": "number", "min": 5, "max": 3})), 422, apperr.InvalidFieldErrorMessage},
{"a negative min on a text field", typeBody("recipe", "R", invalidField(map[string]any{"min": -1})), 422, apperr.InvalidFieldErrorMessage},
{"a fractional max on a richtext field", typeBody("recipe", "R", invalidField(map[string]any{"type": "richtext", "max": 1.5})), 422, apperr.InvalidFieldErrorMessage},
{"a non-date min on a date field", typeBody("recipe", "R", invalidField(map[string]any{"type": "date", "min": "soon"})), 422, apperr.InvalidFieldErrorMessage},
{"a numeric max on a date field", typeBody("recipe", "R", invalidField(map[string]any{"type": "date", "max": 5})), 422, apperr.InvalidFieldErrorMessage},
{"unique on a richtext field", typeBody("recipe", "R", invalidField(map[string]any{"type": "richtext", "unique": true})), 422, apperr.InvalidFieldErrorMessage},
{"unique on a boolean field", typeBody("recipe", "R", invalidField(map[string]any{"type": "boolean", "unique": true})), 422, apperr.InvalidFieldErrorMessage},
{"a default of the wrong type", typeBody("recipe", "R", invalidField(map[string]any{"default": 5})), 422, apperr.InvalidFieldErrorMessage},
{"a default outside min and max", typeBody("recipe", "R", invalidField(map[string]any{"type": "number", "min": 1, "max": 5, "default": 9})), 422, apperr.InvalidFieldErrorMessage},
{"a default not in the options", typeBody("recipe", "R", invalidField(map[string]any{"type": "select", "options": []string{"a"}, "default": "b"})), 422, apperr.InvalidFieldErrorMessage},
{"a default on a unique field", typeBody("recipe", "R", invalidField(map[string]any{"unique": true, "default": "abc"})), 422, apperr.InvalidFieldErrorMessage},
}
t.Run("Creating a content type with an invalid body is refused and stores nothing", func(t *testing.T) {
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
env := newContentTypeEnv(t)
rec := env.admin(t, http.MethodPost, "/v1/content-type", example.body)
if rec.Code != example.status || !strings.Contains(rec.Body.String(), example.message) {
t.Errorf("expected %d containing %q, got %d %q", example.status, example.message, rec.Code, rec.Body.String())
}
if len(env.types.types) != 0 {
t.Errorf("expected no content type, got %v", env.types.types)
}
})
}
})
}
func TestContentTypeKeysAndAccess(t *testing.T) {
t.Run("A content type key can't be reused", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("recipe", "Other", recipeFields()))
expectStatusAndBody(t, rec, http.StatusConflict, apperr.ContentTypeKeyTakenErrorMessage)
if len(env.types.types) != 1 {
t.Errorf("expected one content type")
}
})
t.Run("Writing content types needs a verified owner or admin", func(t *testing.T) {
examples := []struct {
caller string
userId uint32
status int
message string
}{
{"no credentials", 0, 401, ""},
{"a jwt cookie for user 4", ctUnverifiedId, 403, apperr.VerificationRequiredErrorMessage},
{"a stale token_version cookie", ctStaleId, 401, apperr.UnauthorizedErrorMessage},
{"a verified member without a role", ctMemberId, 403, apperr.OwnerOrAdminRequiredErrorMessage},
}
for _, example := range examples {
t.Run(example.caller, func(t *testing.T) {
env := newContentTypeEnv(t)
rec := env.as(t, example.userId, http.MethodPost, "/v1/content-type", typeBody("recipe", "Recipe", recipeFields()))
expectStatusAndBody(t, rec, example.status, example.message)
if len(env.types.types) != 0 {
t.Errorf("expected no content type")
}
})
}
})
t.Run("An API key acts as its creator on the content type routes", func(t *testing.T) {
env := newContentTypeEnv(t)
raw := env.issueKey(ctAdminId)
rec := env.request(t, http.MethodPost, "/v1/content-type", typeBody("recipe", "Recipe", recipeFields()), func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+raw)
})
expectStatusAndBody(t, rec, http.StatusCreated, "")
})
t.Run("Listing and reading content types", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createType(t, "event", dishFields())
list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type", "")
expectStatusAndBody(t, list, http.StatusOK, "")
if !strings.HasPrefix(list.Body.String(), "[") || strings.Index(list.Body.String(), `"event"`) > strings.Index(list.Body.String(), `"recipe"`) {
t.Errorf("expected both types ordered by name, got %s", list.Body.String())
}
one := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/recipe", "")
expectStatusAndBody(t, one, http.StatusOK, "")
if decodeContentObject(t, one)["key"] != "recipe" {
t.Errorf("unexpected body %s", one.Body.String())
}
})
t.Run("Reading a content type that doesn't exist or with a bad key", func(t *testing.T) {
examples := []struct{ method, key, body string }{
{http.MethodGet, "nope", ""},
{http.MethodPut, "nope", typeBody("nope", "Nope", recipeFields())},
{http.MethodDelete, "nope", ""},
{http.MethodGet, "Bad_Key", ""},
}
for _, example := range examples {
t.Run(example.method+" "+example.key, func(t *testing.T) {
env := newContentTypeEnv(t)
rec := env.admin(t, example.method, "/v1/content-type/"+example.key, example.body)
expectStatusAndBody(t, rec, http.StatusNotFound, apperr.ContentTypeNotFoundErrorMessage)
})
}
})
}
func TestUpdatingAContentType(t *testing.T) {
t.Run("Updating a content type before it has entries", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
reordered := []map[string]any{
{"key": "difficulty", "label": "Difficulty", "type": "number"},
{"key": "title", "label": "Title", "type": "text"},
}
rec := env.admin(t, http.MethodPut, "/v1/content-type/recipe", typeBody("recipe", "Dish", reordered))
expectStatusAndBody(t, rec, http.StatusOK, "")
body := decodeContentObject(t, rec)
fields := body["fields"].([]any)
if body["name"] != "Dish" || len(fields) != 2 || fields[0].(map[string]any)["key"] != "difficulty" || fields[0].(map[string]any)["type"] != "number" || body["key"] != "recipe" {
t.Errorf("unexpected body %v", body)
}
})
t.Run("The key of a content type can't be changed", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
rec := env.admin(t, http.MethodPut, "/v1/content-type/recipe", typeBody("dish", "Recipe", recipeFields()))
expectStatusAndBody(t, rec, http.StatusOK, "")
if decodeContentObject(t, rec)["key"] != "recipe" {
t.Errorf("expected the key to stay recipe")
}
})
t.Run("Updating a content type that has entries", func(t *testing.T) {
withField := func(modify func([]map[string]any) []map[string]any) []map[string]any {
return modify(recipeFields())
}
replace := func(key string, overrides map[string]any) func([]map[string]any) []map[string]any {
return func(fields []map[string]any) []map[string]any {
for _, field := range fields {
if field["key"] == key {
for name, value := range overrides {
field[name] = value
}
}
}
return fields
}
}
examples := []struct {
change string
fields []map[string]any
name string
status int
message string
updated bool
}{
{"a field's type changed from text to number", withField(replace("title", map[string]any{"type": "number"})), "Recipe", 422, apperr.InvalidFieldErrorMessage, false},
{"a new required field without a default", append(recipeFields(), textField(map[string]any{"key": "extra", "required": true})), "Recipe", 409, apperr.SchemaIncompatibleErrorMessage, false},
{"a new required field with a default", append(recipeFields(), textField(map[string]any{"key": "extra", "required": true, "default": "none"})), "Recipe", 200, "", true},
{"a new optional field", append(recipeFields(), textField(map[string]any{"key": "extra"})), "Recipe", 200, "", true},
{"the field notes removed", recipeFields()[:5], "Recipe", 200, "", true},
{"the option easy removed from difficulty", withField(replace("difficulty", map[string]any{"options": []string{"medium", "hard"}})), "Recipe", 409, apperr.SchemaIncompatibleErrorMessage, false},
{"the option hard removed from difficulty", withField(replace("difficulty", map[string]any{"options": []string{"easy", "medium"}})), "Recipe", 200, "", true},
{"title max lowered below the stored title's length", withField(replace("title", map[string]any{"max": 5})), "Recipe", 409, apperr.SchemaIncompatibleErrorMessage, false},
{"label, order or name changed", withField(replace("title", map[string]any{"label": "Name"})), "Dish", 200, "", true},
}
for _, example := range examples {
t.Run(example.change, func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
before := env.types.types[1]
rec := env.admin(t, http.MethodPut, "/v1/content-type/recipe", typeBody("recipe", example.name, example.fields))
expectStatusAndBody(t, rec, example.status, example.message)
changed := !reflect.DeepEqual(env.types.types[1].Fields, before.Fields) || env.types.types[1].Name != before.Name
if changed != example.updated {
t.Errorf("expected updated=%v, got %v", example.updated, changed)
}
})
}
})
t.Run("Making a field unique is refused when stored values collide", func(t *testing.T) {
env := newContentTypeEnv(t)
loose := []map[string]any{textField(nil)}
env.createType(t, "recipe", loose)
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai"}`)
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai"}`)
rec := env.admin(t, http.MethodPut, "/v1/content-type/recipe", typeBody("recipe", "Recipe", []map[string]any{textField(map[string]any{"unique": true})}))
expectStatusAndBody(t, rec, http.StatusConflict, apperr.SchemaIncompatibleErrorMessage)
if env.types.types[1].Fields[0].Unique {
t.Errorf("expected the schema to stay unchanged")
}
})
t.Run("Values of a removed field are no longer returned", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
created := decodeContentObject(t, env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy", "notes": "Soak overnight"}`))
id := strconv.Itoa(int(created["id"].(float64)))
env.admin(t, http.MethodPut, "/v1/content-type/recipe", typeBody("recipe", "Recipe", recipeFields()[:5]))
got := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/content-type/recipe/entry/"+id, ""))
if _, present := got["data"].(map[string]any)["notes"]; present {
t.Errorf("expected no notes key, got %v", got["data"])
}
saved := env.admin(t, http.MethodPut, "/v1/content-type/recipe/entry/"+id, `{"data": {"title": "Pad thai", "difficulty": "easy"}}`)
expectStatusAndBody(t, saved, http.StatusOK, "")
if _, present := env.entries.entries[1].Data["notes"]; present {
t.Errorf("expected the stored document to have no notes key")
}
})
}
func TestDeletingAContentType(t *testing.T) {
t.Run("Deleting a content type", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "event", dishFields())
rec := env.admin(t, http.MethodDelete, "/v1/content-type/event", "")
expectStatusAndBody(t, rec, http.StatusNoContent, "")
if rec.Body.Len() != 0 {
t.Errorf("expected an empty body")
}
expectStatusAndBody(t, env.admin(t, http.MethodGet, "/v1/content-type/event", ""), http.StatusNotFound, apperr.ContentTypeNotFoundErrorMessage)
})
t.Run("A repository failure on update or delete returns 500 and changes nothing", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "event", dishFields())
before := env.types.types[1]
env.types.fail.failNext("Update")
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Renamed", dishFields()))
expectStatusAndBody(t, rec, http.StatusInternalServerError, "Internal server error")
if !reflect.DeepEqual(env.types.types[1], before) {
t.Errorf("expected the stored type to be unchanged after a failed update")
}
env.types.fail.failNext("Delete")
rec = env.admin(t, http.MethodDelete, "/v1/content-type/event", "")
expectStatusAndBody(t, rec, http.StatusInternalServerError, "Internal server error")
if len(env.types.types) != 1 {
t.Errorf("expected the type to remain after a failed delete")
}
})
t.Run("A content type with entries can't be deleted", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
rec := env.admin(t, http.MethodDelete, "/v1/content-type/recipe", "")
expectStatusAndBody(t, rec, http.StatusConflict, apperr.ContentTypeHasEntriesErrorMessage)
if len(env.types.types) != 1 || len(env.entries.entries) != 1 {
t.Errorf("expected the type and its entry to remain")
}
})
}
func TestCreatingEntries(t *testing.T) {
t.Run("Creating an entry applies defaults", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
rec := env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
expectStatusAndBody(t, rec, http.StatusCreated, "")
body := decodeContentObject(t, rec)
want := map[string]any{"title": "Pad thai", "difficulty": "easy", "servings": float64(4), "vegetarian": false}
if !reflect.DeepEqual(objectKeys(body), entryKeys) || body["content_type"] != "recipe" || body["created_by"] != float64(2) || body["updated_by"] != float64(2) || !reflect.DeepEqual(body["data"], want) {
t.Errorf("unexpected body %v", body)
}
})
t.Run("An explicit value overrides the default and an explicit null is kept", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
rec := env.createEntry(t, "recipe", ctAdminId, `{"title": "Soup", "difficulty": "easy", "servings": 2, "published_on": null}`)
data := decodeContentObject(t, rec)["data"].(map[string]any)
if data["servings"] != float64(2) {
t.Errorf("expected servings 2, got %v", data["servings"])
}
if value, present := data["published_on"]; !present || value != nil {
t.Errorf("expected published_on null, got %v", data)
}
})
t.Run("Creating an entry that breaks the schema is refused and stores nothing", func(t *testing.T) {
wrap := func(data string) string { return `{"data": ` + data + `}` }
examples := []struct {
body string
status int
message string
}{
{"not-json", 400, apperr.BodyErrorMessage},
{"{}", 400, apperr.BodyErrorMessage},
{`{"data": "x"}`, 400, apperr.BodyErrorMessage},
{wrap(`{"difficulty": "easy"}`), 422, "invalid_entry: title: required"},
{wrap(`{"title": null, "difficulty": "easy"}`), 422, "invalid_entry: title: required"},
{wrap(`{"title": 5, "difficulty": "easy"}`), 422, "invalid_entry: title: type"},
{wrap(`{"title": "ab", "difficulty": "easy"}`), 422, "invalid_entry: title: min"},
{wrap(`{"title": "` + strings.Repeat("é", 81) + `", "difficulty": "easy"}`), 422, "invalid_entry: title: max"},
{wrap(`{"title": "Soup", "difficulty": "extreme"}`), 422, "invalid_entry: difficulty: options"},
{wrap(`{"title": "Soup", "difficulty": "easy", "servings": 0}`), 422, "invalid_entry: servings: min"},
{wrap(`{"title": "Soup", "difficulty": "easy", "servings": 51}`), 422, "invalid_entry: servings: max"},
{wrap(`{"title": "Soup", "difficulty": "easy", "servings": "4"}`), 422, "invalid_entry: servings: type"},
{wrap(`{"title": "Soup", "difficulty": "easy", "vegetarian": "yes"}`), 422, "invalid_entry: vegetarian: type"},
{wrap(`{"title": "Soup", "difficulty": "easy", "published_on": "2026-02-30"}`), 422, "invalid_entry: published_on: type"},
{wrap(`{"title": "Soup", "difficulty": "easy", "published_on": "2026-02-01T10:00:00Z"}`), 422, "invalid_entry: published_on: type"},
{wrap(`{"title": "Soup", "difficulty": "easy", "notes": "` + strings.Repeat("n", 501) + `"}`), 422, "invalid_entry: notes: max"},
{wrap(`{"title": "Soup", "difficulty": "easy", "colour": "red"}`), 422, "invalid_entry: colour: unknown_field"},
}
for index, example := range examples {
t.Run(strconv.Itoa(index)+" "+example.message, func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
rec := env.as(t, ctAdminId, http.MethodPost, "/v1/content-type/recipe/entry", example.body)
expectStatusAndBody(t, rec, example.status, example.message)
if len(env.entries.entries) != 0 {
t.Errorf("expected no entry")
}
})
}
})
t.Run("Boundary values are accepted", func(t *testing.T) {
bodies := []string{
`{"title": "abc", "difficulty": "easy"}`,
`{"title": "` + strings.Repeat("é", 80) + `", "difficulty": "easy"}`,
`{"title": "Soup one", "difficulty": "easy", "servings": 1}`,
`{"title": "Soup two", "difficulty": "easy", "servings": 50}`,
`{"title": "Soup three", "difficulty": "easy", "published_on": "2026-02-28"}`,
}
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
for _, data := range bodies {
expectStatusAndBody(t, env.createEntry(t, "recipe", ctAdminId, data), http.StatusCreated, "")
}
})
}
func TestUniqueEntryValues(t *testing.T) {
t.Run("Unique fields reject duplicates and ignore the entry itself", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
duplicate := env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
same := env.admin(t, http.MethodPut, "/v1/content-type/recipe/entry/1", `{"data": {"title": "Pad thai", "difficulty": "easy", "servings": 6}}`)
expectStatusAndBody(t, duplicate, http.StatusConflict, "invalid_entry: title: unique")
expectStatusAndBody(t, same, http.StatusOK, "")
})
t.Run("Unique values are scoped to their content type and ignore nulls", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createType(t, "dish", dishFields())
expectStatusAndBody(t, env.createEntry(t, "dish", ctAdminId, `{"title": "Pad thai"}`), http.StatusCreated, "")
expectStatusAndBody(t, env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`), http.StatusCreated, "")
expectStatusAndBody(t, env.createEntry(t, "dish", ctAdminId, `{"title": "Soup"}`), http.StatusCreated, "")
expectStatusAndBody(t, env.createEntry(t, "dish", ctAdminId, `{"title": "Stew", "slug": null}`), http.StatusCreated, "")
})
}
func TestReadingAndChangingEntries(t *testing.T) {
t.Run("Getting an entry", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createType(t, "dish", dishFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
found := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/recipe/entry/1", "")
scoped := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/dish/entry/1", "")
expectStatusAndBody(t, found, http.StatusOK, "")
if !reflect.DeepEqual(objectKeys(decodeContentObject(t, found)), entryKeys) {
t.Errorf("unexpected body %s", found.Body.String())
}
expectStatusAndBody(t, scoped, http.StatusNotFound, apperr.EntryNotFoundErrorMessage)
})
t.Run("Entry routes with a bad id or a missing resource", func(t *testing.T) {
valid := `{"data": {"title": "Pad thai", "difficulty": "easy"}}`
examples := []struct {
method, path, body string
status int
message string
}{
{http.MethodGet, "/v1/content-type/recipe/entry/abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodGet, "/v1/content-type/recipe/entry/-1", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodGet, "/v1/content-type/recipe/entry/4294967296", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodGet, "/v1/content-type/recipe/entry/999", "", 404, apperr.EntryNotFoundErrorMessage},
{http.MethodPut, "/v1/content-type/recipe/entry/999", valid, 404, apperr.EntryNotFoundErrorMessage},
{http.MethodDelete, "/v1/content-type/recipe/entry/999", "", 404, apperr.EntryNotFoundErrorMessage},
{http.MethodGet, "/v1/content-type/nope/entry/1", "", 404, apperr.ContentTypeNotFoundErrorMessage},
{http.MethodPost, "/v1/content-type/nope/entry", valid, 404, apperr.ContentTypeNotFoundErrorMessage},
{http.MethodGet, "/v1/content-type/nope/entry", "", 404, apperr.ContentTypeNotFoundErrorMessage},
}
for _, example := range examples {
t.Run(example.method+" "+example.path, func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
rec := env.as(t, ctMemberId, example.method, example.path, example.body)
expectStatusAndBody(t, rec, example.status, example.message)
})
}
})
t.Run("Replacing an entry validates the whole document and keeps provenance", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
env.clock = env.clock.Add(time.Hour)
rec := env.as(t, ctOwnerId, http.MethodPut, "/v1/content-type/recipe/entry/1", `{"data": {"title": "Green curry", "difficulty": "medium"}}`)
expectStatusAndBody(t, rec, http.StatusOK, "")
body := decodeContentObject(t, rec)
want := map[string]any{"title": "Green curry", "difficulty": "medium"}
if !reflect.DeepEqual(body["data"], want) || body["created_by"] != float64(2) || body["updated_by"] != float64(1) || body["updated_at"] == body["created_at"] {
t.Errorf("unexpected body %v", body)
}
refused := env.as(t, ctOwnerId, http.MethodPut, "/v1/content-type/recipe/entry/1", `{"data": {"difficulty": "medium"}}`)
expectStatusAndBody(t, refused, http.StatusUnprocessableEntity, "invalid_entry: title: required")
if env.entries.entries[1].Data["title"] != "Green curry" {
t.Errorf("expected the entry to be unchanged")
}
})
t.Run("Deleting an entry", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
rec := env.admin(t, http.MethodDelete, "/v1/content-type/recipe/entry/1", "")
expectStatusAndBody(t, rec, http.StatusNoContent, "")
expectStatusAndBody(t, env.admin(t, http.MethodGet, "/v1/content-type/recipe/entry/1", ""), http.StatusNotFound, apperr.EntryNotFoundErrorMessage)
})
}
func TestListingEntries(t *testing.T) {
seed := func(t *testing.T) *contentTypeEnv {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
env.createType(t, "dish", dishFields())
for index := 1; index <= 30; index++ {
title := "Recipe " + strconv.Itoa(1000+index)
expectStatusAndBody(t, env.createEntry(t, "recipe", ctAdminId, `{"title": "`+title+`", "difficulty": "easy"}`), http.StatusCreated, "")
}
env.createEntry(t, "dish", ctAdminId, `{"title": "Dish one"}`)
return env
}
examples := []struct {
query string
status int
items int
total int
page int
size int
firstId int
lastId int
badQuery bool
}{
{"", 200, 25, 30, 1, 25, 30, 6, false},
{"?page=2", 200, 5, 30, 2, 25, 5, 1, false},
{"?size=5&page=2", 200, 5, 30, 2, 5, 25, 21, false},
{"?sort=id:asc", 200, 25, 30, 1, 25, 1, 25, false},
{"?sort=created_at:desc", 200, 25, 30, 1, 25, 30, 6, false},
{"?page=99", 200, 0, 30, 99, 25, 0, 0, false},
{"?page=1000000", 200, 0, 30, 1000000, 25, 0, 0, false},
{"?page=1000001", 400, 0, 0, 0, 0, 0, 0, true},
{"?page=0", 400, 0, 0, 0, 0, 0, 0, true},
{"?size=7", 400, 0, 0, 0, 0, 0, 0, true},
{"?sort=data:asc", 400, 0, 0, 0, 0, 0, 0, true},
{"?page=abc", 400, 0, 0, 0, 0, 0, 0, true},
}
t.Run("Listing entries is paginated and sortable", func(t *testing.T) {
for _, example := range examples {
t.Run(example.query, func(t *testing.T) {
env := seed(t)
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/recipe/entry"+example.query, "")
if example.badQuery {
expectStatusAndBody(t, rec, example.status, apperr.BodyErrorMessage)
return
}
expectStatusAndBody(t, rec, example.status, "")
body := decodeContentObject(t, rec)
items := body["items"].([]any)
if len(items) != example.items || body["total"] != float64(example.total) || body["page"] != float64(example.page) || body["size"] != float64(example.size) {
t.Fatalf("unexpected page %v", body)
}
if len(items) > 0 {
first := int(items[0].(map[string]any)["id"].(float64))
last := int(items[len(items)-1].(map[string]any)["id"].(float64))
if first != example.firstId || last != example.lastId {
t.Errorf("expected ids %d..%d, got %d..%d", example.firstId, example.lastId, first, last)
}
}
})
}
})
t.Run("An empty content type lists no entries", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "event", dishFields())
rec := env.admin(t, http.MethodGet, "/v1/content-type/event/entry", "")
expectStatusAndBody(t, rec, http.StatusOK, `{"items":[],"total":0,"page":1,"size":25}`)
})
}
func TestEntryRouteAccess(t *testing.T) {
examples := []struct {
caller string
userId uint32
viaKey bool
status int
}{
{"an admin cookie", ctAdminId, false, 201},
{"an owner cookie", ctOwnerId, false, 201},
{"an active API key of admin 2", 0, true, 201},
{"no credentials", 0, false, 401},
{"a cookie for unverified 4", ctUnverifiedId, false, 403},
{"a stale token_version cookie", ctStaleId, false, 401},
}
t.Run("Entry routes accept any verified user and API keys, and refuse everyone else", func(t *testing.T) {
for _, example := range examples {
t.Run(example.caller, func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "recipe", recipeFields())
body := `{"data": {"title": "Pad thai", "difficulty": "easy"}}`
rec := env.request(t, http.MethodPost, "/v1/content-type/recipe/entry", body, func(req *http.Request) {
env.asUser(t, example.userId)(req)
if example.viaKey {
req.Header.Set("Authorization", "Bearer "+env.issueKey(ctAdminId))
}
})
if rec.Code != example.status {
t.Errorf("expected %d, got %d %q", example.status, rec.Code, rec.Body.String())
}
})
}
})
t.Run("Built-in resources are not reachable through the generic engine", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "article", dishFields())
rec := env.admin(t, http.MethodGet, "/v1/content-type/article/entry", "")
expectStatusAndBody(t, rec, http.StatusOK, `{"items":[],"total":0,"page":1,"size":25}`)
})
}
+661
View File
@@ -0,0 +1,661 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/mappers"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"math/rand"
"reflect"
"sort"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
func recipeSchema(t *testing.T) []contenttype.Field {
t.Helper()
min3, max80, min1, max50, max500 := float64(3), float64(80), float64(1), float64(50), float64(500)
fields, err := contenttype.ValidateFields([]contenttype.Field{
{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Unique: true, Min: min3, Max: max80},
{Key: "servings", Label: "Servings", Type: contenttype.TypeNumber, Default: float64(4), Min: min1, Max: max50},
{Key: "vegetarian", Label: "Vegetarian", Type: contenttype.TypeBoolean, Default: false},
{Key: "published_on", Label: "Published on", Type: contenttype.TypeDate},
{Key: "difficulty", Label: "Difficulty", Type: contenttype.TypeSelect, Required: true, Options: []string{"easy", "medium", "hard"}},
{Key: "notes", Label: "Notes", Type: contenttype.TypeRichText, Max: max500},
})
if err != nil {
t.Fatalf("sample schema: %v", err)
}
return fields
}
type contentTypeFixture struct {
types *mockContentTypeRepo
entries *mockEntryRepo
now func() time.Time
}
func newContentTypeFixture(t *testing.T) (*contentTypeFixture, contenttype.ContentType) {
t.Helper()
entries := newMockEntryRepo()
fixture := &contentTypeFixture{types: newMockContentTypeRepo(), entries: entries, now: func() time.Time { return time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) }}
stored, _ := fixture.types.Create(contenttype.ContentType{Key: "recipe", Name: "Recipe", Fields: recipeSchema(t)})
fixture.types.createCalls = 0
return fixture, stored
}
func TestCreateEntryUseCase(t *testing.T) {
t.Run("Use case: validating an entry against a schema returns domain sentinels", func(t *testing.T) {
breaking := []map[string]any{
{"difficulty": "easy"},
{"title": 5, "difficulty": "easy"},
{"title": "ab", "difficulty": "easy"},
{"title": "Soup", "difficulty": "extreme"},
{"title": "Soup", "difficulty": "easy", "servings": 0.0},
{"title": "Soup", "difficulty": "easy", "colour": "red"},
}
for _, data := range breaking {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data, UserID: 2})
if !errors.Is(err, entry.ErrInvalidEntry) || fixture.entries.createCalls != 0 {
t.Errorf("data %v: err %v, creates %d", data, err, fixture.entries.createCalls)
}
}
})
t.Run("Use case: a duplicate unique value returns ErrUniqueViolation", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.now)
data := func() map[string]any { return map[string]any{"title": "Pad thai", "difficulty": "easy"} }
if _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2}); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2})
if !errors.Is(err, entry.ErrUniqueViolation) || fixture.entries.createCalls != 1 {
t.Errorf("err %v, creates %d", err, fixture.entries.createCalls)
}
})
t.Run("Use case: the injected clock stamps the entry", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.now)
created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: map[string]any{"title": "Pad thai", "difficulty": "easy"}, UserID: 2})
if err != nil || !created.CreatedAt.Equal(fixture.now()) || !created.UpdatedAt.Equal(fixture.now()) || created.CreatedBy != 2 {
t.Errorf("unexpected %+v %v", created, err)
}
})
}
func TestUpdateContentTypeUseCase(t *testing.T) {
seedEntry := func(fixture *contentTypeFixture, stored contenttype.ContentType, data map[string]any) {
_, _ = fixture.entries.Create(entry.Entry{ContentTypeID: stored.ID, Data: data})
}
t.Run("Use case: schema updates re-validate stored entries", func(t *testing.T) {
fixture, stored := newContentTypeFixture(t)
seedEntry(fixture, stored, map[string]any{"title": "Pad thai", "difficulty": "easy"})
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
narrowed := recipeSchema(t)
narrowed[4].Options = []string{"hard"}
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "recipe", Name: "Recipe", Fields: narrowed})
if !errors.Is(err, contenttype.ErrSchemaIncompatible) || fixture.types.updateCalls != 0 {
t.Errorf("err %v, updates %d", err, fixture.types.updateCalls)
}
})
t.Run("Use case: a type change returns ErrInvalidField", func(t *testing.T) {
fixture, stored := newContentTypeFixture(t)
seedEntry(fixture, stored, map[string]any{"title": "Pad thai", "difficulty": "easy"})
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
retyped := recipeSchema(t)
retyped[0].Type = contenttype.TypeRichText
retyped[0].Unique = false
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "recipe", Name: "Recipe", Fields: retyped})
if !errors.Is(err, contenttype.ErrInvalidField) || fixture.types.updateCalls != 0 {
t.Errorf("err %v, updates %d", err, fixture.types.updateCalls)
}
})
t.Run("Use case: a compatible update stores the new name and fields", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
updated, err := update.Update(useCases.UpdateContentTypeCommand{Key: "recipe", Name: "Dish", Fields: recipeSchema(t)[:2]})
if err != nil || updated.Name != "Dish" || len(updated.Fields) != 2 || updated.Key != "recipe" || !updated.UpdatedAt.Equal(fixture.now()) {
t.Errorf("unexpected %+v %v", updated, err)
}
})
}
func TestDeleteContentTypeUseCase(t *testing.T) {
t.Run("Use case: DeleteContentType with entries returns ErrHasEntries and nothing is deleted", func(t *testing.T) {
fixture, stored := newContentTypeFixture(t)
_, _ = fixture.entries.Create(entry.Entry{ContentTypeID: stored.ID, Data: map[string]any{}})
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries)
err := remove.Delete("recipe")
if !errors.Is(err, contenttype.ErrHasEntries) || fixture.types.deleteCalls != 0 {
t.Errorf("err %v, deletes %d", err, fixture.types.deleteCalls)
}
})
}
func TestContentTypeDomainRules(t *testing.T) {
t.Run("Keys follow the slug rule and the length bounds", func(t *testing.T) {
accepted := []string{"ab", "recipe", "my-type-2", strings.Repeat("a", 40)}
refused := []string{"", "a", "Recipe", "my_type", "-ab", strings.Repeat("a", 41)}
for _, key := range accepted {
if err := contenttype.ValidateKey(key); err != nil {
t.Errorf("%q: %v", key, err)
}
}
for _, key := range refused {
if err := contenttype.ValidateKey(key); !errors.Is(err, contenttype.ErrInvalidKey) {
t.Errorf("%q: %v", key, err)
}
}
})
t.Run("Field values report the first broken rule", func(t *testing.T) {
fields := recipeSchema(t)
cases := []struct {
field int
value any
rule string
}{
{0, "abc", ""},
{0, 5.0, contenttype.RuleType},
{0, "ab", contenttype.RuleMin},
{1, 51.0, contenttype.RuleMax},
{2, "yes", contenttype.RuleType},
{3, "2026-02-28", ""},
{3, "2026-02-30", contenttype.RuleType},
{4, "hard", ""},
{4, "extreme", contenttype.RuleOptions},
}
for _, c := range cases {
if got := fields[c.field].Violation(c.value); got != c.rule {
t.Errorf("field %s value %v: got %q, want %q", fields[c.field].Key, c.value, got, c.rule)
}
}
})
t.Run("Defaults fill only absent keys and projection drops unknown keys", func(t *testing.T) {
fields := recipeSchema(t)
filled := entry.ApplyDefaults(fields, map[string]any{"published_on": nil, "servings": nil})
projected := entry.Project(fields, map[string]any{"title": "x", "gone": 1})
if !reflect.DeepEqual(filled, map[string]any{"published_on": nil, "servings": nil, "vegetarian": false}) {
t.Errorf("unexpected defaults %v", filled)
}
if !reflect.DeepEqual(projected, map[string]any{"title": "x"}) {
t.Errorf("unexpected projection %v", projected)
}
})
}
func TestContentTypePersistence(t *testing.T) {
t.Run("Content types and entries round-trip through the generic tables", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
types := persistence.NewContentTypeRepository(db)
entries := persistence.NewEntryRepository(db)
created, err := types.Create(contenttype.ContentType{Key: "recipe", Name: "Recipe", Fields: recipeSchema(t)})
if err != nil {
t.Fatalf("create type: %v", err)
}
loaded, err := types.GetByKey("recipe")
if err != nil || loaded.ID != created.ID || !reflect.DeepEqual(loaded.Fields, recipeSchema(t)) {
t.Fatalf("round trip: %+v %v", loaded, err)
}
if _, err := types.GetByKey("nope"); !errors.Is(err, contenttype.ErrContentTypeNotFound) {
t.Errorf("expected not found, got %v", err)
}
first, _ := entries.Create(entry.Entry{ContentTypeID: created.ID, Data: map[string]any{"title": "Pad thai", "servings": 4.0}, CreatedBy: 2, UpdatedBy: 2})
_, _ = entries.Create(entry.Entry{ContentTypeID: created.ID, Data: map[string]any{"title": "Soup"}, CreatedBy: 2, UpdatedBy: 2})
_, _ = entries.Create(entry.Entry{ContentTypeID: created.ID + 1, Data: map[string]any{"title": "Pad thai"}})
page, err := entries.ListPage(entry.Filters{ContentTypeID: created.ID, Page: 1, Size: 1, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}})
taken, _ := entries.FindByFieldValue(created.ID, "title", "Pad thai", 0)
self, _ := entries.FindByFieldValue(created.ID, "title", "Pad thai", first.ID)
count, _ := entries.CountByContentType(created.ID)
if err != nil || page.Total != 2 || len(page.Items) != 1 || page.Items[0].ID != first.ID || page.Items[0].Data["servings"] != 4.0 {
t.Errorf("unexpected page %+v %v", page, err)
}
if len(taken) != 1 || len(self) != 0 || count != 2 {
t.Errorf("taken %d self %d count %d", len(taken), len(self), count)
}
if _, err := entries.Get(created.ID+1, first.ID); !errors.Is(err, entry.ErrEntryNotFound) {
t.Errorf("expected scoped not found, got %v", err)
}
if err := types.Delete(created.ID); err != nil {
t.Errorf("delete: %v", err)
}
})
}
type nilListContentTypeRepo struct {
*mockContentTypeRepo
}
func (nilListContentTypeRepo) List() ([]contenttype.ContentType, error) {
return nil, nil
}
func TestListContentTypesUseCase(t *testing.T) {
t.Run("A repository failure is returned as is", func(t *testing.T) {
repo := newMockContentTypeRepo()
boom := errors.New("boom")
repo.fail.failNextWith("List", boom)
if found, err := useCases.NewListContentTypesUseCase(repo).List(); !errors.Is(err, boom) || found != nil {
t.Errorf("got %v %v", found, err)
}
})
t.Run("No content type yields an empty non-nil list", func(t *testing.T) {
found, err := useCases.NewListContentTypesUseCase(nilListContentTypeRepo{newMockContentTypeRepo()}).List()
if err != nil || found == nil || len(found) != 0 {
t.Errorf("got %#v %v", found, err)
}
})
}
func TestContentTypeSchemaBounds(t *testing.T) {
booleans := func(count int) []contenttype.Field {
fields := make([]contenttype.Field, 0, count)
for i := 0; i < count; i++ {
fields = append(fields, contenttype.Field{Key: fmt.Sprintf("f%d", i), Label: "Flag", Type: contenttype.TypeBoolean})
}
return fields
}
t.Run("A schema holds between 1 and 50 fields", func(t *testing.T) {
for count, accepted := range map[int]bool{0: false, 1: true, 50: true, 51: false} {
_, err := contenttype.ValidateFields(booleans(count))
if (err == nil) != accepted {
t.Errorf("%d fields: %v", count, err)
}
}
})
t.Run("Bounds may be equal but never inverted, and a zero length cap is whole", func(t *testing.T) {
cases := []struct {
min, max any
accepted bool
}{
{2.0, 2.0, true},
{3.0, 2.0, false},
{nil, 0.0, true},
{nil, -1.0, false},
{nil, 1.5, false},
}
for _, c := range cases {
_, err := contenttype.ValidateFields([]contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText, Min: c.min, Max: c.max}})
if (err == nil) != c.accepted {
t.Errorf("min %v max %v: %v", c.min, c.max, err)
}
}
})
t.Run("Date bounds compare calendar days", func(t *testing.T) {
field := contenttype.Field{Key: "day", Label: "Day", Type: contenttype.TypeDate, Min: "2024-01-01", Max: "2024-01-02"}
cases := map[string]string{"2023-12-31": contenttype.RuleMin, "2024-01-01": "", "2024-01-02": "", "2024-01-03": contenttype.RuleMax}
for value, rule := range cases {
if got := field.Violation(value); got != rule {
t.Errorf("%s: %q", value, got)
}
}
})
}
func TestContentTypeRepositoryEdges(t *testing.T) {
open := func(t *testing.T) (*persistence.ContentTypeRepository, *persistence.EntryRepository, *gorm.DB) {
t.Helper()
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
return persistence.NewContentTypeRepository(db), persistence.NewEntryRepository(db), db
}
flag := []contenttype.Field{{Key: "flag", Label: "Flag", Type: contenttype.TypeBoolean}}
t.Run("Content types list by case-insensitive name and update by id", func(t *testing.T) {
types, _, _ := open(t)
if listed, err := types.List(); err != nil || len(listed) != 0 {
t.Fatalf("empty list: %v %v", listed, err)
}
_, _ = types.Create(contenttype.ContentType{Key: "zeta", Name: "zeta", Fields: flag})
beta, _ := types.Create(contenttype.ContentType{Key: "beta", Name: "Beta", Fields: flag})
_, _ = types.Create(contenttype.ContentType{Key: "alpha", Name: "Alpha", Fields: flag})
listed, err := types.List()
if err != nil || len(listed) != 3 || listed[0].Key != "alpha" || listed[1].Key != "beta" || listed[2].Key != "zeta" {
t.Fatalf("unexpected order %+v %v", listed, err)
}
beta.Name = "Gamma"
if _, err := types.Update(beta); err != nil {
t.Fatalf("update: %v", err)
}
reloaded, _ := types.GetByKey("beta")
if reloaded.Name != "Gamma" {
t.Errorf("name not updated: %q", reloaded.Name)
}
if _, err := types.Update(contenttype.ContentType{ID: 999, Name: "x", Fields: flag}); !errors.Is(err, contenttype.ErrContentTypeNotFound) {
t.Errorf("update missing: %v", err)
}
if err := types.Delete(999); !errors.Is(err, contenttype.ErrContentTypeNotFound) {
t.Errorf("delete missing: %v", err)
}
unmarshalable := []contenttype.Field{{Key: "flag", Label: "Flag", Type: contenttype.TypeBoolean, Default: make(chan int)}}
if _, err := types.Update(contenttype.ContentType{ID: beta.ID, Name: "x", Fields: unmarshalable}); err == nil {
t.Errorf("expected a marshal error")
}
if _, err := types.Create(contenttype.ContentType{Key: "chan", Name: "x", Fields: unmarshalable}); err == nil {
t.Errorf("expected a marshal error on create")
}
})
t.Run("Stored rows that cannot be decoded and a closed database are reported", func(t *testing.T) {
types, entries, db := open(t)
if err := db.Exec("INSERT INTO content_types (type_key, name, fields, created_at, updated_at) VALUES ('bad', 'Bad', 'not json', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").Error; err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := types.List(); err == nil {
t.Errorf("expected a decode error from List")
}
if _, err := types.GetByKey("bad"); err == nil || errors.Is(err, contenttype.ErrContentTypeNotFound) {
t.Errorf("expected a decode error from GetByKey, got %v", err)
}
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if _, err := types.List(); err == nil {
t.Errorf("expected a connection error from List")
}
if _, err := entries.CountByContentType(1); err == nil {
t.Errorf("expected a connection error from CountByContentType")
}
})
t.Run("Entries page, tie-break, update, delete and bulk delete per content type", func(t *testing.T) {
_, entries, _ := open(t)
same := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var ids []uint32
for _, title := range []string{"a", "b", "c"} {
created, err := entries.Create(entry.Entry{ContentTypeID: 1, Data: map[string]any{"title": title}, CreatedAt: same, UpdatedAt: same})
if err != nil {
t.Fatalf("create: %v", err)
}
ids = append(ids, created.ID)
}
other, _ := entries.Create(entry.Entry{ContentTypeID: 2, Data: map[string]any{"title": "z"}})
byCreated, err := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: 1, Size: 10, Sort: entry.Sort{Field: entry.SortByCreatedAt, Direction: entry.SortAsc}})
if err != nil || len(byCreated.Items) != 3 || byCreated.Items[0].ID != ids[2] || byCreated.Items[1].ID != ids[1] || byCreated.Items[2].ID != ids[0] {
t.Errorf("ties must fall back to id desc: %+v %v", byCreated, err)
}
byID, _ := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: 1, Size: 10, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortDesc}})
if len(byID.Items) != 3 || byID.Items[0].ID != ids[2] || byID.Items[2].ID != ids[0] {
t.Errorf("id desc: %+v", byID)
}
second, _ := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: 2, Size: 2, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}})
if second.Total != 3 || len(second.Items) != 1 || second.Items[0].ID != ids[2] {
t.Errorf("page 2: %+v", second)
}
if _, err := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: 1, Size: 2, Sort: entry.Sort{Field: "bogus", Direction: "sideways"}}); !errors.Is(err, entry.ErrInvalidSort) {
t.Errorf("unknown sort must be refused: %v", err)
}
if _, err := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: entry.MaxPage + 1, Size: 100, Sort: entry.DefaultSort()}); !errors.Is(err, entry.ErrInvalidPage) {
t.Errorf("out of range page must be refused: %v", err)
}
if _, err := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: entry.MaxPage, Size: 100, Sort: entry.DefaultSort()}); err != nil {
t.Errorf("max page must be accepted: %v", err)
}
unpaged, _ := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: 1, Size: 1, Unpaged: true, Sort: entry.DefaultSort()})
if len(unpaged.Items) != 3 {
t.Errorf("unpaged: %+v", unpaged)
}
updated := byID.Items[0]
updated.Data = map[string]any{"title": "changed"}
updated.UpdatedBy = 9
if _, err := entries.Update(updated); err != nil {
t.Fatalf("update: %v", err)
}
reloaded, _ := entries.Get(1, updated.ID)
if reloaded.Data["title"] != "changed" || reloaded.UpdatedBy != 9 || reloaded.CreatedBy != 0 {
t.Errorf("update not persisted: %+v", reloaded)
}
if _, err := entries.Update(entry.Entry{ID: 999, Data: map[string]any{}}); !errors.Is(err, entry.ErrEntryNotFound) {
t.Errorf("update missing: %v", err)
}
if _, err := entries.Update(entry.Entry{ID: updated.ID, Data: map[string]any{"x": make(chan int)}}); err == nil {
t.Errorf("expected a marshal error on update")
}
if _, err := entries.Create(entry.Entry{ContentTypeID: 1, Data: map[string]any{"x": make(chan int)}}); err == nil {
t.Errorf("expected a marshal error on create")
}
if err := entries.Delete(2, ids[0]); !errors.Is(err, entry.ErrEntryNotFound) {
t.Errorf("scoped delete: %v", err)
}
if err := entries.Delete(1, ids[0]); err != nil {
t.Errorf("delete: %v", err)
}
if err := entries.DeleteByContentType(1); err != nil {
t.Errorf("bulk delete: %v", err)
}
left, _ := entries.ListPage(entry.Filters{ContentTypeID: 1, Page: 1, Size: 10, Sort: entry.DefaultSort()})
kept, err := entries.Get(2, other.ID)
if left.Total != 0 || err != nil || kept.ID != other.ID {
t.Errorf("bulk delete scope: %+v %v", left, err)
}
})
}
func TestEntrySortRules(t *testing.T) {
t.Run("Only the whitelisted field and direction pairs parse", func(t *testing.T) {
accepted := []string{"id:asc", "id:desc", "created_at:asc", "created_at:desc", "updated_at:asc", "updated_at:desc"}
for _, value := range accepted {
parsed, err := entry.ParseSort(value)
if err != nil || parsed.String() != value || !parsed.Valid() {
t.Errorf("%q: %v %v", value, parsed, err)
}
}
refused := []string{"", "id", "id:", ":asc", "data:asc", "id:up", "id:asc:x", "ID:asc", "id:ASC"}
for _, value := range refused {
if _, err := entry.ParseSort(value); !errors.Is(err, entry.ErrInvalidSort) {
t.Errorf("%q: %v", value, err)
}
}
if entry.DefaultSort().String() != "updated_at:desc" || !entry.DefaultSort().Valid() {
t.Errorf("unexpected default sort %v", entry.DefaultSort())
}
if (entry.Sort{Field: entry.SortByID}).Valid() || (entry.Sort{Direction: entry.SortAsc}).Valid() {
t.Errorf("half specified sorts must be invalid")
}
})
t.Run("The order clause is always one of the fixed strings", func(t *testing.T) {
fixed := map[string]bool{
"id ASC": true, "id DESC": true,
"created_at ASC, id DESC": true, "created_at DESC, id DESC": true,
"updated_at ASC, id DESC": true, "updated_at DESC, id DESC": true,
}
random := rand.New(rand.NewSource(7))
alphabet := []rune("abcdeitmpuxyz_:; -'\"()DROPTABLE0123")
generated := []string{"id; DROP TABLE entries", "id", "created_at", "asc", "desc", "", "id ASC"}
for range 500 {
length := random.Intn(24)
runes := make([]rune, length)
for position := range runes {
runes[position] = alphabet[random.Intn(len(alphabet))]
}
generated = append(generated, string(runes))
}
fields := append([]string{"id", "created_at", "updated_at"}, generated...)
directions := append([]string{"asc", "desc"}, generated...)
valid := 0
for _, field := range fields {
for _, direction := range directions[:12] {
clause, err := persistence.EntryOrderClause(entry.Sort{Field: entry.SortField(field), Direction: entry.SortDirection(direction)})
if err != nil {
if clause != "" || !errors.Is(err, entry.ErrInvalidSort) {
t.Fatalf("%q %q: %q %v", field, direction, clause, err)
}
continue
}
valid++
if !fixed[clause] {
t.Fatalf("%q %q produced %q", field, direction, clause)
}
}
}
if valid < 6 {
t.Errorf("expected the 6 valid combinations, got %d", valid)
}
for _, field := range generated {
for _, direction := range []string{"asc", "desc"} {
clause, err := persistence.EntryOrderClause(entry.Sort{Field: entry.SortField(field), Direction: entry.SortDirection(direction)})
if err == nil && !fixed[clause] {
t.Fatalf("%q %q produced %q", field, direction, clause)
}
}
}
})
}
func TestEntryPagingAndEquality(t *testing.T) {
t.Run("Pages beyond the maximum are refused by the use case", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
list := useCases.NewListEntriesUseCase(fixture.types, fixture.entries)
cases := []struct {
page, size int
sort entry.Sort
err error
}{
{entry.MaxPage, 100, entry.DefaultSort(), nil},
{entry.MaxPage + 1, 25, entry.DefaultSort(), entry.ErrInvalidPage},
{1, 101, entry.DefaultSort(), entry.ErrInvalidPage},
{1, 25, entry.Sort{Field: "id"}, entry.ErrInvalidSort},
{1, 25, entry.Sort{}, entry.ErrInvalidSort},
}
for _, c := range cases {
_, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "recipe", Page: c.page, Size: c.size, Sort: c.sort})
if !errors.Is(err, c.err) {
t.Errorf("page %d size %d sort %v: %v", c.page, c.size, c.sort, err)
}
}
})
t.Run("Values are equal when they decode to the same JSON value", func(t *testing.T) {
if !entry.SameValue("a", "a") || !entry.SameValue(4.0, 4.0) || !entry.SameValue(true, true) {
t.Errorf("equal values must match")
}
if entry.SameValue("a", "b") || entry.SameValue(4.0, "4") || entry.SameValue(nil, "") || entry.SameValue(1.0, 2.0) {
t.Errorf("different values must not match")
}
})
}
func TestContentTypeNameRule(t *testing.T) {
t.Run("A name is required after trimming and holds at most 100 characters", func(t *testing.T) {
cases := map[string]bool{"": false, " ": false, "\t\n": false, "a": true, " a ": true, strings.Repeat("é", 100): true, strings.Repeat("é", 101): false}
for name, accepted := range cases {
if err := contenttype.ValidateName(name); (err == nil) != accepted || (err != nil && !errors.Is(err, contenttype.ErrInvalidName)) {
t.Errorf("%q: %v", name, err)
}
}
})
t.Run("The use cases enforce it for non-HTTP callers", func(t *testing.T) {
fixture, existing := newContentTypeFixture(t)
create := useCases.NewCreateContentTypeUseCase(fixture.types, fixture.now)
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
t.Errorf("create blank: %v", err)
}
if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: strings.Repeat("n", 101), Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
t.Errorf("create long: %v", err)
}
if _, err := update.Update(useCases.UpdateContentTypeCommand{Key: existing.Key, Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
t.Errorf("update blank: %v", err)
}
if _, err := update.Update(useCases.UpdateContentTypeCommand{Key: existing.Key, Name: strings.Repeat("n", 101), Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
t.Errorf("update long: %v", err)
}
if len(fixture.types.types) != 1 {
t.Errorf("nothing may be stored: %d", len(fixture.types.types))
}
})
}
func TestContentTypeStoredFormat(t *testing.T) {
t.Run("The stored field JSON keeps its exact key set", func(t *testing.T) {
model, err := mappers.ContentTypeToModel(contenttype.ContentType{Key: "recipe", Name: "Recipe", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}})
if err != nil {
t.Fatalf("map: %v", err)
}
var stored []map[string]any
if err := json.Unmarshal([]byte(model.Fields), &stored); err != nil || len(stored) != 1 {
t.Fatalf("decode: %v %v", stored, err)
}
keys := make([]string, 0, len(stored[0]))
for key := range stored[0] {
keys = append(keys, key)
}
sort.Strings(keys)
expected := []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
if !reflect.DeepEqual(keys, expected) {
t.Errorf("stored keys %v", keys)
}
})
t.Run("A duplicate content type key surfaces as the key taken sentinel", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
types := persistence.NewContentTypeRepository(db)
flag := []contenttype.Field{{Key: "flag", Label: "Flag", Type: contenttype.TypeBoolean}}
if _, err := types.Create(contenttype.ContentType{Key: "recipe", Name: "Recipe", Fields: flag}); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := types.Create(contenttype.ContentType{Key: "recipe", Name: "Again", Fields: flag}); !errors.Is(err, contenttype.ErrKeyTaken) {
t.Errorf("expected key taken, got %v", err)
}
other, _ := types.Create(contenttype.ContentType{Key: "dish", Name: "Dish", Fields: flag})
if err := db.Exec("DROP TABLE content_types").Error; err != nil {
t.Fatalf("drop: %v", err)
}
if _, err := types.Create(contenttype.ContentType{Key: "other", Name: "Other", Fields: flag}); err == nil || errors.Is(err, contenttype.ErrKeyTaken) {
t.Errorf("other failures must pass through: %v", err)
}
if _, err := types.Update(other); err == nil || errors.Is(err, contenttype.ErrKeyTaken) {
t.Errorf("other update failures must pass through: %v", err)
}
})
}
+1 -1
View File
@@ -216,7 +216,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
} }
func isNewTableObject(name string) bool { func isNewTableObject(name string) bool {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_"} { for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_"} {
if strings.HasPrefix(name, prefix) { if strings.HasPrefix(name, prefix) {
return true return true
} }