feat: custom content types backend
This commit is contained in:
1 parent
ed67b7e70d
commit
58d58f5a04
41 files changed
+4086
-2
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user