From 58d58f5a04eb330bf576b8942ea1549e3d51a684 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 19 Oct 2026 17:01:37 +0200 Subject: [PATCH] feat: custom content types backend --- README.md | 1 + api/dependecyInjection.go | 26 + .../contenttype/contentTypeHandler.go | 153 ++++ api/handlers/entry/entryHandler.go | 232 ++++++ api/pkg/apperr/messages.go | 9 + api/pkg/response/json.go | 21 + api/router.go | 33 + docs/claude-design-content-types-prompt.md | 108 +++ docs/custom-content-types.md | 14 + features/custom_content_types.feature | 355 ++++++++++ features/custom_content_types.qa.md | 100 +++ .../domain/contenttype/contentTypeDomain.go | 64 ++ .../contentTypeRepositoryInterface.go | 9 + internal/domain/contenttype/fieldKinds.go | 80 +++ internal/domain/contenttype/fieldSchema.go | 101 +++ internal/domain/contenttype/fieldValue.go | 41 ++ internal/domain/entry/entryData.go | 90 +++ internal/domain/entry/entryDomain.go | 64 ++ .../domain/entry/entryRepositoryInterface.go | 71 ++ .../persistence/database/migrate.go | 2 +- .../persistence/mappers/contentTypeMapper.go | 37 + .../persistence/mappers/entryMapper.go | 39 + .../persistence/models/contentTypeModel.go | 12 + .../persistence/models/entryModel.go | 13 + .../repositories/contentTypeRepository.go | 98 +++ .../repositories/entryRepository.go | 152 ++++ .../useCases/CreateContentType.go | 57 ++ .../infrastructure/useCases/CreateEntry.go | 50 ++ .../useCases/DeleteContentType.go | 30 + .../infrastructure/useCases/DeleteEntry.go | 23 + .../infrastructure/useCases/GetContentType.go | 15 + internal/infrastructure/useCases/GetEntry.go | 27 + .../useCases/ListContentTypes.go | 22 + .../infrastructure/useCases/ListEntries.go | 51 ++ .../useCases/UpdateContentType.go | 100 +++ .../infrastructure/useCases/UpdateEntry.go | 49 ++ .../infrastructure/useCases/entrySupport.go | 29 + test/custom_content_types_support_test.go | 379 ++++++++++ test/custom_content_types_test.go | 668 ++++++++++++++++++ test/custom_content_types_usecases_test.go | 661 +++++++++++++++++ test/sqlite_schema_unchanged_test.go | 2 +- 41 files changed, 4086 insertions(+), 2 deletions(-) create mode 100644 api/handlers/contenttype/contentTypeHandler.go create mode 100644 api/handlers/entry/entryHandler.go create mode 100644 api/pkg/response/json.go create mode 100644 docs/claude-design-content-types-prompt.md create mode 100644 docs/custom-content-types.md create mode 100644 features/custom_content_types.feature create mode 100644 features/custom_content_types.qa.md create mode 100644 internal/domain/contenttype/contentTypeDomain.go create mode 100644 internal/domain/contenttype/contentTypeRepositoryInterface.go create mode 100644 internal/domain/contenttype/fieldKinds.go create mode 100644 internal/domain/contenttype/fieldSchema.go create mode 100644 internal/domain/contenttype/fieldValue.go create mode 100644 internal/domain/entry/entryData.go create mode 100644 internal/domain/entry/entryDomain.go create mode 100644 internal/domain/entry/entryRepositoryInterface.go create mode 100644 internal/infrastructure/persistence/mappers/contentTypeMapper.go create mode 100644 internal/infrastructure/persistence/mappers/entryMapper.go create mode 100644 internal/infrastructure/persistence/models/contentTypeModel.go create mode 100644 internal/infrastructure/persistence/models/entryModel.go create mode 100644 internal/infrastructure/persistence/repositories/contentTypeRepository.go create mode 100644 internal/infrastructure/persistence/repositories/entryRepository.go create mode 100644 internal/infrastructure/useCases/CreateContentType.go create mode 100644 internal/infrastructure/useCases/CreateEntry.go create mode 100644 internal/infrastructure/useCases/DeleteContentType.go create mode 100644 internal/infrastructure/useCases/DeleteEntry.go create mode 100644 internal/infrastructure/useCases/GetContentType.go create mode 100644 internal/infrastructure/useCases/GetEntry.go create mode 100644 internal/infrastructure/useCases/ListContentTypes.go create mode 100644 internal/infrastructure/useCases/ListEntries.go create mode 100644 internal/infrastructure/useCases/UpdateContentType.go create mode 100644 internal/infrastructure/useCases/UpdateEntry.go create mode 100644 internal/infrastructure/useCases/entrySupport.go create mode 100644 test/custom_content_types_support_test.go create mode 100644 test/custom_content_types_test.go create mode 100644 test/custom_content_types_usecases_test.go diff --git a/README.md b/README.md index 010a754..9737db5 100644 --- a/README.md +++ b/README.md @@ -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/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/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/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one | diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index c99f07f..708e0b5 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -5,6 +5,8 @@ import ( domainArticle "RenewCMS/internal/domain/article" domainAudit "RenewCMS/internal/domain/audit" domainAuthor "RenewCMS/internal/domain/author" + domainContentType "RenewCMS/internal/domain/contenttype" + domainEntry "RenewCMS/internal/domain/entry" domainImage "RenewCMS/internal/domain/image" domainInvite "RenewCMS/internal/domain/invite" domainMail "RenewCMS/internal/domain/mail" @@ -63,6 +65,16 @@ type UseCases struct { UpdateSiteUseCase *useCases.UpdateSiteUseCase SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase 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 CreateUserUseCase *useCases.CreateUserUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase @@ -140,6 +152,8 @@ func (c *UseCases) RunSchedulers(ctx context.Context) { type Repositories struct { ArticleRepo domainArticle.Repository SiteRepo domainSite.Repository + ContentTypeRepo domainContentType.Repository + EntryRepo domainEntry.Repository AuthorRepo domainAuthor.Repository UserRepo domainUser.Repository ImageRepo domainImage.Repository @@ -280,6 +294,8 @@ func getRepositories(connection *database.Connection, settings config.Settings) return &Repositories{ ArticleRepo: persistence.NewArticleRepository(db), SiteRepo: persistence.NewSiteRepository(db), + ContentTypeRepo: persistence.NewContentTypeRepository(db), + EntryRepo: persistence.NewEntryRepository(db), AuthorRepo: persistence.NewAuthorRepository(db), UserRepo: persistence.NewUserRepository(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), SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(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), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder), diff --git a/api/handlers/contenttype/contentTypeHandler.go b/api/handlers/contenttype/contentTypeHandler.go new file mode 100644 index 0000000..7c439fd --- /dev/null +++ b/api/handlers/contenttype/contentTypeHandler.go @@ -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) +} diff --git a/api/handlers/entry/entryHandler.go b/api/handlers/entry/entryHandler.go new file mode 100644 index 0000000..5964208 --- /dev/null +++ b/api/handlers/entry/entryHandler.go @@ -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) +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index b8028c7..4604cb7 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -86,3 +86,12 @@ const ArticleSlugTakenErrorMessage = "slug_taken" const ArticleImageNotFoundErrorMessage = "image_not_found" const InvalidTitleTemplateErrorMessage = "invalid_title_template" 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" diff --git a/api/pkg/response/json.go b/api/pkg/response/json.go new file mode 100644 index 0000000..3c01c46 --- /dev/null +++ b/api/pkg/response/json.go @@ -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) + } +} diff --git a/api/router.go b/api/router.go index 7925a81..6bd39df 100644 --- a/api/router.go +++ b/api/router.go @@ -6,6 +6,8 @@ import ( "RenewCMS/api/handlers/article" "RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/author" + "RenewCMS/api/handlers/contenttype" + "RenewCMS/api/handlers/entry" "RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/media" @@ -244,6 +246,36 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler 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 { 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("/site", NewSiteRouter(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("/media", NewMediaRouter(container, settings.TrustedProxies)) r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies)) diff --git a/docs/claude-design-content-types-prompt.md b/docs/claude-design-content-types-prompt.md new file mode 100644 index 0000000..1cff5ce --- /dev/null +++ b/docs/claude-design-content-types-prompt.md @@ -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: : ", 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. diff --git a/docs/custom-content-types.md b/docs/custom-content-types.md new file mode 100644 index 0000000..25ff0d7 --- /dev/null +++ b/docs/custom-content-types.md @@ -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. diff --git a/features/custom_content_types.feature b/features/custom_content_types.feature new file mode 100644 index 0000000..89da473 --- /dev/null +++ b/features/custom_content_types.feature @@ -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": []} + 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 + Then the response status is + And the response body is + 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": []} | 400 | invalid_content_type_key | + | {"key": "r", "name": "R", "fields": []} | 400 | invalid_content_type_key | + | {"key": "recipe", "name": "", "fields": []} | 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 + When the client sends POST /v1/content-type with a valid body + Then the response status is + And the response body is + 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 /v1/content-type/ + Then the response status is + And the response body is + + 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 + Then the response status is + And the response body is + And the stored schema is + + 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 + Then the response status is + And the response body is + 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 + Then the response status is + And the response body is + + 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 + Then the response status is + And + + 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 sends POST /v1/content-type/recipe/entry with a valid body + Then the response status is + + 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 diff --git a/features/custom_content_types.qa.md b/features/custom_content_types.qa.md new file mode 100644 index 0000000..ae70e8b --- /dev/null +++ b/features/custom_content_types.qa.md @@ -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). diff --git a/internal/domain/contenttype/contentTypeDomain.go b/internal/domain/contenttype/contentTypeDomain.go new file mode 100644 index 0000000..4761fd2 --- /dev/null +++ b/internal/domain/contenttype/contentTypeDomain.go @@ -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"` +} diff --git a/internal/domain/contenttype/contentTypeRepositoryInterface.go b/internal/domain/contenttype/contentTypeRepositoryInterface.go new file mode 100644 index 0000000..9493692 --- /dev/null +++ b/internal/domain/contenttype/contentTypeRepositoryInterface.go @@ -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 +} diff --git a/internal/domain/contenttype/fieldKinds.go b/internal/domain/contenttype/fieldKinds.go new file mode 100644 index 0000000..7161f9e --- /dev/null +++ b/internal/domain/contenttype/fieldKinds.go @@ -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 +} diff --git a/internal/domain/contenttype/fieldSchema.go b/internal/domain/contenttype/fieldSchema.go new file mode 100644 index 0000000..d347c7f --- /dev/null +++ b/internal/domain/contenttype/fieldSchema.go @@ -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) == "" +} diff --git a/internal/domain/contenttype/fieldValue.go b/internal/domain/contenttype/fieldValue.go new file mode 100644 index 0000000..2203df3 --- /dev/null +++ b/internal/domain/contenttype/fieldValue.go @@ -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) +} diff --git a/internal/domain/entry/entryData.go b/internal/domain/entry/entryData.go new file mode 100644 index 0000000..23139f3 --- /dev/null +++ b/internal/domain/entry/entryData.go @@ -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 +} diff --git a/internal/domain/entry/entryDomain.go b/internal/domain/entry/entryDomain.go new file mode 100644 index 0000000..a3da443 --- /dev/null +++ b/internal/domain/entry/entryDomain.go @@ -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} +} diff --git a/internal/domain/entry/entryRepositoryInterface.go b/internal/domain/entry/entryRepositoryInterface.go new file mode 100644 index 0000000..2c56797 --- /dev/null +++ b/internal/domain/entry/entryRepositoryInterface.go @@ -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) +} diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index ee865c6..e34c653 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -7,7 +7,7 @@ import ( ) 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 { return err } diff --git a/internal/infrastructure/persistence/mappers/contentTypeMapper.go b/internal/infrastructure/persistence/mappers/contentTypeMapper.go new file mode 100644 index 0000000..57ee1c1 --- /dev/null +++ b/internal/infrastructure/persistence/mappers/contentTypeMapper.go @@ -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 +} diff --git a/internal/infrastructure/persistence/mappers/entryMapper.go b/internal/infrastructure/persistence/mappers/entryMapper.go new file mode 100644 index 0000000..d4b4864 --- /dev/null +++ b/internal/infrastructure/persistence/mappers/entryMapper.go @@ -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 +} diff --git a/internal/infrastructure/persistence/models/contentTypeModel.go b/internal/infrastructure/persistence/models/contentTypeModel.go new file mode 100644 index 0000000..2f26956 --- /dev/null +++ b/internal/infrastructure/persistence/models/contentTypeModel.go @@ -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"` +} diff --git a/internal/infrastructure/persistence/models/entryModel.go b/internal/infrastructure/persistence/models/entryModel.go new file mode 100644 index 0000000..944c9fb --- /dev/null +++ b/internal/infrastructure/persistence/models/entryModel.go @@ -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"` +} diff --git a/internal/infrastructure/persistence/repositories/contentTypeRepository.go b/internal/infrastructure/persistence/repositories/contentTypeRepository.go new file mode 100644 index 0000000..2e20528 --- /dev/null +++ b/internal/infrastructure/persistence/repositories/contentTypeRepository.go @@ -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 +} diff --git a/internal/infrastructure/persistence/repositories/entryRepository.go b/internal/infrastructure/persistence/repositories/entryRepository.go new file mode 100644 index 0000000..4cdf4eb --- /dev/null +++ b/internal/infrastructure/persistence/repositories/entryRepository.go @@ -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 +} diff --git a/internal/infrastructure/useCases/CreateContentType.go b/internal/infrastructure/useCases/CreateContentType.go new file mode 100644 index 0000000..38c844a --- /dev/null +++ b/internal/infrastructure/useCases/CreateContentType.go @@ -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 +} diff --git a/internal/infrastructure/useCases/CreateEntry.go b/internal/infrastructure/useCases/CreateEntry.go new file mode 100644 index 0000000..a465e6f --- /dev/null +++ b/internal/infrastructure/useCases/CreateEntry.go @@ -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 +} diff --git a/internal/infrastructure/useCases/DeleteContentType.go b/internal/infrastructure/useCases/DeleteContentType.go new file mode 100644 index 0000000..372c1bb --- /dev/null +++ b/internal/infrastructure/useCases/DeleteContentType.go @@ -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) +} diff --git a/internal/infrastructure/useCases/DeleteEntry.go b/internal/infrastructure/useCases/DeleteEntry.go new file mode 100644 index 0000000..ca37d3f --- /dev/null +++ b/internal/infrastructure/useCases/DeleteEntry.go @@ -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) +} diff --git a/internal/infrastructure/useCases/GetContentType.go b/internal/infrastructure/useCases/GetContentType.go new file mode 100644 index 0000000..09ffb53 --- /dev/null +++ b/internal/infrastructure/useCases/GetContentType.go @@ -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) +} diff --git a/internal/infrastructure/useCases/GetEntry.go b/internal/infrastructure/useCases/GetEntry.go new file mode 100644 index 0000000..eb16501 --- /dev/null +++ b/internal/infrastructure/useCases/GetEntry.go @@ -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 +} diff --git a/internal/infrastructure/useCases/ListContentTypes.go b/internal/infrastructure/useCases/ListContentTypes.go new file mode 100644 index 0000000..c1dbc92 --- /dev/null +++ b/internal/infrastructure/useCases/ListContentTypes.go @@ -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 +} diff --git a/internal/infrastructure/useCases/ListEntries.go b/internal/infrastructure/useCases/ListEntries.go new file mode 100644 index 0000000..e98c5f0 --- /dev/null +++ b/internal/infrastructure/useCases/ListEntries.go @@ -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 +} diff --git a/internal/infrastructure/useCases/UpdateContentType.go b/internal/infrastructure/useCases/UpdateContentType.go new file mode 100644 index 0000000..4d2300d --- /dev/null +++ b/internal/infrastructure/useCases/UpdateContentType.go @@ -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 +} diff --git a/internal/infrastructure/useCases/UpdateEntry.go b/internal/infrastructure/useCases/UpdateEntry.go new file mode 100644 index 0000000..b94efa1 --- /dev/null +++ b/internal/infrastructure/useCases/UpdateEntry.go @@ -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 +} diff --git a/internal/infrastructure/useCases/entrySupport.go b/internal/infrastructure/useCases/entrySupport.go new file mode 100644 index 0000000..953de55 --- /dev/null +++ b/internal/infrastructure/useCases/entrySupport.go @@ -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 +} diff --git a/test/custom_content_types_support_test.go b/test/custom_content_types_support_test.go new file mode 100644 index 0000000..f810cc3 --- /dev/null +++ b/test/custom_content_types_support_test.go @@ -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()) + } +} diff --git a/test/custom_content_types_test.go b/test/custom_content_types_test.go new file mode 100644 index 0000000..3a3dedd --- /dev/null +++ b/test/custom_content_types_test.go @@ -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}`) + }) +} diff --git a/test/custom_content_types_usecases_test.go b/test/custom_content_types_usecases_test.go new file mode 100644 index 0000000..60ea7af --- /dev/null +++ b/test/custom_content_types_usecases_test.go @@ -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) + } + }) +} diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index e159dbc..88c7c05 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -216,7 +216,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { } 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) { return true }