Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion frontend/src/i18n/errorTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const DOMAIN = 'gophenberg'
export function errorTemplates(): Record<string, string> {
return {
address_reserved: __(
'Gophenberg keeps this address for itself, so your content cannot answer there. Give the item a different slug.',
'Gophenberg keeps /%(path)s for itself or for another content type, so your content cannot answer there. Give the item a different slug.',
DOMAIN,
),
archive_entry_escapes: __(
Expand Down Expand Up @@ -552,6 +552,19 @@ export function authTemplates(): Record<string, string> {
}
}

/**
* Returns the message each code a content type write answers with stands for, read fresh like the others.
* @returns The messages, keyed by code.
*/
export function typeTemplates(): Record<string, string> {
return {
derived_route_word_reserved: __(
'%(name)s would move to /%(word)s, which Gophenberg keeps for itself. Change its plural name and try again.',
DOMAIN,
),
}
}

/**
* Returns the message each code the update checks answer with stands for, read fresh like the others.
* @returns The messages, keyed by code.
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/i18n/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { __, sprintf } from '@wordpress/i18n'

import { authTemplates, errorTemplates, updateTemplates } from './errorTemplates'
import { authTemplates, errorTemplates, typeTemplates, updateTemplates } from './errorTemplates'
import { everyDecimal } from './numbers'

/** What the server answers when it turns a request away. */
Expand Down Expand Up @@ -56,7 +56,7 @@ function writtenMeta(meta: Record<string, unknown>): Record<string, unknown> {
*/
export function errorText(refused: Refused): string {
const spoken = refused.error === '' ? unexplained() : refused.error
const templates = { ...errorTemplates(), ...authTemplates(), ...updateTemplates() }
const templates = { ...errorTemplates(), ...authTemplates(), ...typeTemplates(), ...updateTemplates() }
const template = refused.code === undefined ? undefined : templates[refused.code]
if (template === undefined || !filled(template, refused.meta ?? {})) {
return spoken
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/languages/es-ES.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/src/languages/fr-FR.json

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions frontend/src/test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'
import { join } from 'node:path'

import { errorText } from '../i18n/errors'
import { errorTemplates, updateTemplates } from '../i18n/errorTemplates'
import { errorTemplates, typeTemplates, updateTemplates } from '../i18n/errorTemplates'
import { repositoryRoot } from '../../scripts/config.ts'

test('reads the message a code stands for', () => {
Expand Down Expand Up @@ -104,7 +104,30 @@ test('carries a message for every code the server can answer with', () => {
const emitted = execFileSync('sh', [script], { cwd: repositoryRoot(), encoding: 'utf8' })
.split('\n')
.filter((held) => held !== '')
const held = { ...errorTemplates(), ...updateTemplates() }
const held = { ...errorTemplates(), ...typeTemplates(), ...updateTemplates() }

expect(emitted.filter((code) => held[code] === undefined)).toEqual([])
})

test('names the type and the route word a hand over would give it', () => {
const held = errorText({
error: 'content: route word reserved: "Page" would answer under "page"',
code: 'derived_route_word_reserved',
meta: { type: 'post', name: 'Page', word: 'page' },
})

expect(held).toBe(
'Page would move to /page, which Gophenberg keeps for itself. Change its plural name and try again.',
)
})

test('names the address no item can answer at', () => {
const held = errorText({
error: 'content: the address is reserved: "page/2"',
code: 'address_reserved',
meta: { path: 'page/2' },
})

expect(held).toContain('Gophenberg keeps /page/2 for itself or for another content type')
expect(held).toContain('Give the item a different slug.')
})
18 changes: 16 additions & 2 deletions internal/content/contenttest/root_pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,18 @@ func nestingPosts(t *testing.T, types content.TypeStore) content.Type {
return stored
}

// carriedOnto fails the case unless the refusal carries address_reserved and names the path.
// derivedWordReserved fails the case unless the error carries derived_route_word_reserved, the type and the word.
func derivedWordReserved(t *testing.T, err error, key, name, word string) {
t.Helper()
if code, _ := content.CodeOf(err); code != "derived_route_word_reserved" {
t.Errorf("code = %q, want derived_route_word_reserved", code)
}
if held, _ := content.DetailsOf(err); held["type"] != key || held["name"] != name || held["word"] != word {
t.Errorf("details = %v, want the type %s named %q and the word %q", held, key, name, word)
}
}

// carriedOnto fails the case unless the error carries address_reserved and names the path.
func carriedOnto(t *testing.T, err error, path string) {
t.Helper()
if code, _ := content.CodeOf(err); code != "address_reserved" {
Expand Down Expand Up @@ -84,6 +95,7 @@ func createRefusesARootPaginationAddress(t *testing.T, s Stores) {
if !errors.Is(err, content.ErrReservedAddress) {
t.Errorf("%s Create() error = %v, want %v", name, err, content.ErrReservedAddress)
}
carriedOnto(t, err, "page/2")
if StillStored(t, s.Content, built) {
t.Errorf("%s item stored, want the refused create to store nothing", name)
}
Expand All @@ -96,14 +108,15 @@ func updateRefusesAMoveOntoARootPaginationAddress(t *testing.T, s Stores) {
root := nestingPosts(t, s.Types)
parent := StoreItem(t, s.Content, root, nil, "Page", author)
held := StoreItem(t, s.Content, root, &parent, "About", author)
moved := held.Place(content.PageWord, "2")
moved := held.Place(content.PageWord, "3")
moved.UpdatedAt = time.Now().UTC()

_, err := s.Content.Update(t.Context(), moved, held.UpdatedAt, nil, 0)

if !errors.Is(err, content.ErrReservedAddress) {
t.Fatalf("Update() error = %v, want %v", err, content.ErrReservedAddress)
}
carriedOnto(t, err, "page/3")
unmoved(t, s.Content, held)
}

Expand Down Expand Up @@ -252,6 +265,7 @@ func updateRefusesAHandOverWhoseOldRootLabelGivesPage(t *testing.T, s Stores) {
if !errors.Is(err, content.ErrRouteWordReserved) {
t.Fatalf("Update() error = %v, want %v", err, content.ErrRouteWordReserved)
}
derivedWordReserved(t, err, content.TypePost, "Page", "page")
if held, err := s.Types.ByKey(t.Context(), content.TypePost); err != nil || !held.Default || held.RouteWord != "" {
t.Errorf("post = %+v, %v, want it still holding the root", held, err)
}
Expand Down
1 change: 1 addition & 0 deletions internal/content/contenttest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ func updateRefusesToHandTheRootToAReservedAddress(t *testing.T, s Stores) {
if !errors.Is(err, content.ErrRouteWordReserved) {
t.Fatalf("Update() error = %v, want %v", err, content.ErrRouteWordReserved)
}
derivedWordReserved(t, err, content.TypePost, "Media", "media")
if got := AddressOf(t, s.Content, about.ID); got != "pages/about" {
t.Errorf("page address = %q, want the refused hand over to have moved nothing", got)
}
Expand Down
31 changes: 31 additions & 0 deletions internal/content/contenttest/types_root_word.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ var typeRootWordCases = []Case{
{"UpdateHandsTheRootToATypeWithAnUnderscoredLabel", updateHandsTheRootToATypeWithAnUnderscoredLabel},
{"UpdateRefusesToHandTheRootOntoATakenKey", updateRefusesToHandTheRootOntoATakenKey},
{"UpdateKeepsAPageKeyedRootOffThePageWord", updateKeepsAPageKeyedRootOffThePageWord},
{"UpdateRefusesAHandOverWhoseOldRootKeyIsReserved", updateRefusesAHandOverWhoseOldRootKeyIsReserved},
}

// relabelPost stores the built-in post type under new labels.
Expand Down Expand Up @@ -148,3 +149,33 @@ func updateKeepsAPageKeyedRootOffThePageWord(t *testing.T, s Stores) {
t.Errorf("page address = %q, want the refused hand over to have moved nothing", got)
}
}

// updateRefusesAHandOverWhoseOldRootKeyIsReserved refuses a hand over whose fallback key is a word the CMS keeps.
func updateRefusesAHandOverWhoseOldRootKeyIsReserved(t *testing.T, s Stores) {
files, err := content.NewType("media", "3D File", "3D Files", "files")
if err != nil {
t.Fatalf("NewType(media) error = %v, want nil", err)
}
stored, err := s.Types.Create(t.Context(), files)
if err != nil {
t.Fatalf("Create(media) error = %v, want nil", err)
}
stored.Default, stored.UpdatedAt = true, time.Now().UTC()
if _, err := s.Types.Update(t.Context(), stored); err != nil {
t.Fatalf("handing the root to the media type: %v", err)
}
RegisterPageType(t, s.Types)

_, err = rootPage(t, s.Types)

if !errors.Is(err, content.ErrRouteWordReserved) {
t.Fatalf("Update() error = %v, want %v", err, content.ErrRouteWordReserved)
}
derivedWordReserved(t, err, "media", "3D Files", "media")
if held, err := s.Types.ByKey(t.Context(), "media"); err != nil || !held.Default || held.RouteWord != "" {
t.Errorf("media = %+v, %v, want it still holding the root", held, err)
}
if held, err := s.Types.ByKey(t.Context(), "page"); err != nil || held.Default || held.RouteWord != "pages" {
t.Errorf("page = %+v, %v, want it still under pages", held, err)
}
}
14 changes: 9 additions & 5 deletions internal/content/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package content

import (
"errors"
"fmt"
"strings"

"github.com/google/uuid"
Expand All @@ -24,6 +25,12 @@ var ErrTooDeep = errors.New("content: the nesting limit is reached")
// ErrReservedAddress reports that an address names a place the CMS keeps.
var ErrReservedAddress = errors.New("content: the address is reserved")

// ReservedAddress returns the error naming an address the CMS keeps.
func ReservedAddress(path string) error {
return Refuse(ErrReservedAddress, "address_reserved",
fmt.Sprintf("%s: %q", ErrReservedAddress, path), Details{"path": path})
}

// ErrHoldsChildren reports that content still holds content nested under it.
var ErrHoldsChildren = errors.New("content: the item still holds children")

Expand Down Expand Up @@ -93,11 +100,8 @@ func Reparent(t Type, c Content, parent *Content, height int) (Content, error) {

// guardAddress returns the reason an address names a place the CMS keeps, if it does.
func guardAddress(path string) error {
if ReservedRoot(FirstSegment(path)) {
return ErrReservedAddress
}
if RootPagination(path) {
return ErrReservedAddress
if ReservedRoot(FirstSegment(path)) || RootPagination(path) {
return ReservedAddress(path)
}
return nil
}
Expand Down
43 changes: 43 additions & 0 deletions internal/content/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,49 @@ func TestNewRefusesAReservedAddressBehindADroppedSymbolAndAMark(t *testing.T) {
}
}

func TestReservedAddressNamesTheAddress(t *testing.T) {
t.Parallel()

err := content.ReservedAddress("page/2")

if !errors.Is(err, content.ErrReservedAddress) {
t.Errorf("error = %v, want it to carry %v", err, content.ErrReservedAddress)
}
if code, _ := content.CodeOf(err); code != "address_reserved" {
t.Errorf("code = %q, want address_reserved", code)
}
if held, _ := content.DetailsOf(err); held["path"] != "page/2" {
t.Errorf("details = %v, want the path page/2 named", held)
}
if !strings.Contains(err.Error(), `"page/2"`) {
t.Errorf("message = %q, want it to name the address", err.Error())
}
}

func TestNewNamesTheReservedAddressItRefuses(t *testing.T) {
t.Parallel()

root := postType()
root.Hierarchical = true
parent := mustNew(t, root, nil, "Page")
for _, tc := range []struct {
parent *content.Content
title, path string
}{
{nil, "Admin", "admin"},
{&parent, "2", "page/2"},
} {
_, err := content.New(root, tc.parent, tc.title, uuid.Must(uuid.NewV7()))

if code, _ := content.CodeOf(err); code != "address_reserved" {
t.Errorf("New(%q) code = %q, want address_reserved", tc.title, code)
}
if held, _ := content.DetailsOf(err); held["path"] != tc.path {
t.Errorf("New(%q) details = %v, want the path %s named", tc.title, held, tc.path)
}
}
}

func TestNewLeavesAReservedWordAloneBelowTheRoot(t *testing.T) {
t.Parallel()

Expand Down
21 changes: 21 additions & 0 deletions internal/content/registry_root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,32 @@ package content_test

import (
"errors"
"strings"
"testing"

"github.com/gopherium/gophenberg/internal/content"
)

func TestDerivedRouteWordReservedNamesTheTypeAndTheWord(t *testing.T) {
t.Parallel()

err := content.DerivedRouteWordReserved("media", "3D Files", "media")

if !errors.Is(err, content.ErrRouteWordReserved) {
t.Errorf("error = %v, want it to carry %v", err, content.ErrRouteWordReserved)
}
if code, _ := content.CodeOf(err); code != "derived_route_word_reserved" {
t.Errorf("code = %q, want derived_route_word_reserved", code)
}
held, _ := content.DetailsOf(err)
if held["type"] != "media" || held["name"] != "3D Files" || held["word"] != "media" {
t.Errorf("details = %v, want the type media, the name 3D Files and the word media named", held)
}
if !strings.Contains(err.Error(), `"3D Files" would answer under "media"`) {
t.Errorf("message = %q, want it to name the type and the word", err.Error())
}
}

func TestRegistryRefusesANewTypeClaimingTheRoot(t *testing.T) {
t.Parallel()

Expand Down
7 changes: 7 additions & 0 deletions internal/content/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ var ErrRouteWordTaken = errors.New("content: route word taken")
// ErrRouteWordReserved reports that the route word names a place the CMS keeps.
var ErrRouteWordReserved = errors.New("content: route word reserved")

// DerivedRouteWordReserved returns the error naming the type and the derived route word the CMS keeps.
func DerivedRouteWordReserved(key, name, word string) error {
return Refuse(ErrRouteWordReserved, "derived_route_word_reserved",
fmt.Sprintf("%s: %q would answer under %q", ErrRouteWordReserved, name, word),
Details{"type": key, "name": name, "word": word})
}

// ErrRootTaken reports that another content type already lives at the root.
var ErrRootTaken = errors.New("content: another type holds the root")

Expand Down
4 changes: 2 additions & 2 deletions internal/content/typestore/steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,15 @@ var typeArms = map[string]arm{
return valued(s.Update(ctx, rootBook(true)))
},
log: []step{begun, sent("LockContentType", keyed{"book"}), lockedRoot, rolledBack},
ends: content.ErrRouteWordReserved,
ends: content.DerivedRouteWordReserved("page", "Media", "media"),
},
"Update taking the root cannot move the previous root under page": {
stocked: libraryWith(func(stocked *shelf) { stocked.types[0].PluralLabel = "Page" }),
run: func(ctx context.Context, s *Store) (any, error) {
return valued(s.Update(ctx, rootBook(true)))
},
log: []step{begun, sent("LockContentType", keyed{"book"}), lockedRoot, rolledBack},
ends: content.ErrRouteWordReserved,
ends: content.DerivedRouteWordReserved("page", "Page", "page"),
},
"Update taking the root from a type with no plural label wraps the label failure": {
stocked: libraryWith(func(stocked *shelf) { stocked.types[0].PluralLabel = "" }),
Expand Down
12 changes: 10 additions & 2 deletions internal/content/typestore/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func (s *Store) updateTypeFailure(err error) error {
return fmt.Errorf("%s: update content type: %w", s.backend.Engine, err)
}

// handRootOver moves the type holding the root under a word of its own so another may take it.
// handRootOver moves the type holding the root under a word of its own.
func handRootOver(ctx context.Context, queries Queries, at time.Time) error {
row, err := queries.LockDefaultContentType(ctx)
if errors.Is(err, sql.ErrNoRows) {
Expand All @@ -214,14 +214,22 @@ func handRootOver(ctx context.Context, queries Queries, at time.Time) error {
demoted.RouteWord = demoted.DerivedRouteWord()
demoted.Default, demoted.UpdatedAt = false, at
if err := demoted.ValidateChange(content.Type{}); err != nil {
return err
return handOverFailure(demoted, err)
}
if _, err := queries.UpdateContentType(ctx, updateParams(demoted)); err != nil {
return err
}
return carryContent(ctx, queries, demoted, row.RouteWord)
}

// handOverFailure returns the error the hand over carries, naming the type and its word when the CMS keeps that word.
func handOverFailure(demoted content.Type, err error) error {
if !errors.Is(err, content.ErrRouteWordReserved) {
return err
}
return content.DerivedRouteWordReserved(demoted.Key, demoted.PluralLabel, demoted.RouteWord)
}

// Delete removes the type, or reports it missing or still in use.
func (s *Store) Delete(ctx context.Context, key string) error {
rows, err := s.backend.Pool.DeleteContentType(ctx, key)
Expand Down
2 changes: 1 addition & 1 deletion internal/i18n/catalogs/es-ES.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/i18n/catalogs/fr-FR.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions internal/postgres/pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import (
func checkPaginationPaths(paths []string) error {
for _, path := range paths {
if content.RootPagination(path) {
return content.Refuse(content.ErrReservedAddress, "address_reserved",
content.ErrReservedAddress.Error(), content.Details{"path": path})
return content.ReservedAddress(path)
}
}
return nil
Expand All @@ -30,7 +29,7 @@ func checkPaginationEdit(ctx context.Context, queries *db.Queries, params db.Upd
return err
}
if stored.Path != params.Path {
return content.ErrReservedAddress
return content.ReservedAddress(params.Path)
}
return nil
}
Expand Down
Loading
Loading