test(17-06): Journal menu item type unit tests

- TestMenuItemTypesPatterns (Docker-free): the five types, ordered
  placeholder fill, date parts only for published posts in UTC,
  alternates and the unprefixed locale, translation fallback, reference
  parse, category tree, refusals without a booted app
- TestMenuItemTypes (Postgres): host overrides of one record type reach
  only its list types, item and list-key overrides, references, nesting
  at every depth, order with ties by id, published filters, descendant
  categories, alternates in sort_order with ties by id
This commit is contained in:
2026-10-12 19:33:36 +02:00
parent 4e2aab6fe0
commit 3b252f2cfa
2 changed files with 491 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
package classes
import (
"context"
"fmt"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the five
// registered types, the ordered placeholder fill of PHP buildUrlFromPattern,
// the post placeholders (date parts only for a published post), the
// alternates (x-default, unprefixed locale, prefixes, first alternate as the
// URL), the translation fallback, the reference parse, the category tree and
// the refusals without a booted app.
func TestMenuItemTypesPatterns(t *testing.T) {
t.Run("the five types", func(t *testing.T) {
types := MenuItemTypes(nil)
var got []string
for _, typ := range types {
refs := typ.References != nil
got = append(got, fmt.Sprintf("%s|%s|%s|nest=%v|dyn=%v|refs=%v|%s|%s", typ.Key, typ.DefaultPattern, typ.PatternFrom, typ.Nesting, typ.DynamicItems, refs, strings.Join(typ.Placeholders, ","), typ.Label))
if typ.Resolve == nil {
t.Fatalf("%s has no Resolve", typ.Key)
}
}
want := []string{
"journal-category|/blog/category/:slug||nest=true|dyn=true|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_category",
"all-journal-categories|/blog/category/:slug|journal-category|nest=false|dyn=true|refs=false|:slug,:id|golem15.journal::lang.menuitem.all_journal_categories",
"journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post",
"all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts",
"category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
// Placeholders are copies: changing one type's list leaves the others.
types[2].Placeholders[0] = "changed"
if again := MenuItemTypes(nil); again[2].Placeholders[0] != ":slug" || types[3].Placeholders[0] != ":slug" {
t.Fatal("placeholder slices are shared")
}
})
t.Run("ordered fill", func(t *testing.T) {
for _, tc := range []struct {
pattern string
params [][2]string
want string
}{
{"/blog/:slug", [][2]string{{"id", "7"}, {"slug", "hello"}}, "/blog/hello"},
// PHP replaces in params order: a slug holding ":id" was filled
// after :id, so it stays literal.
{"/:id/:slug", [][2]string{{"id", "7"}, {"slug", "a-:id"}}, "/7/a-:id"},
// A value replaced first can feed a later placeholder.
{"/:slug", [][2]string{{"slug", ":category"}, {"category", "news"}}, "/news"},
{"/:slug/:slug", [][2]string{{"slug", "x"}}, "/x/x"},
{"/:unknown/:slug", [][2]string{{"slug", "x"}}, "/:unknown/x"},
{"/:slugger", [][2]string{{"slug", "x"}}, "/xger"},
{"/static", nil, "/static"},
} {
if got := fillMenuPattern(tc.pattern, tc.params); got != tc.want {
t.Fatalf("fillMenuPattern(%q, %v) = %q, want %q", tc.pattern, tc.params, got, tc.want)
}
}
})
t.Run("post placeholders", func(t *testing.T) {
at := time.Date(2026, 3, 4, 23, 30, 0, 0, time.FixedZone("plus2", 2*3600))
published := &models.Post{ID: 9, Slug: "s", Published: true, PublishedAt: &at}
if got := fmt.Sprint(postURLParams(published, "slug", "cat")); got != "[[id 9] [slug slug] [category cat] [year 2026] [month 03] [day 04]]" {
t.Fatalf("published params = %s", got)
}
late := time.Date(2026, 12, 31, 23, 30, 0, 0, time.FixedZone("minus5", -5*3600))
utc := &models.Post{ID: 1, Published: true, PublishedAt: &late}
if got := fmt.Sprint(postURLParams(utc, "s", "")); !strings.Contains(got, "[year 2027] [month 01] [day 01]") {
t.Fatalf("date parts are not UTC: %s", got)
}
for name, p := range map[string]*models.Post{
"draft with a date": {ID: 2, Published: false, PublishedAt: &at},
"published, no date": {ID: 3, Published: true},
"draft without dates": {ID: 4},
} {
params := postURLParams(p, "s", "c")
if len(params) != 3 {
t.Fatalf("%s: params = %v, want id, slug and category only", name, params)
}
if got := fillMenuPattern("/:year/:month/:day/:slug", params); got != "/:year/:month/:day/s" {
t.Fatalf("%s: url = %q", name, got)
}
}
})
t.Run("alternates", func(t *testing.T) {
params := func(locale string) [][2]string { return [][2]string{{"slug", "slug-" + locale}} }
title := func(locale string) string { return "T-" + locale }
for _, tc := range []struct {
name string
locales menuLocales
want string
}{
{"no enabled locale", menuLocales{}, "x-default=/p/slug-|T-"},
{"default unprefixed", menuLocales{codes: []string{"pl", "en"}, defaultCode: "en", unprefixed: "en"}, "pl=/pl/p/slug-pl|T-pl en=/p/slug-en|T-en"},
{"another unprefixed", menuLocales{codes: []string{"en", "pl", "de"}, defaultCode: "en", unprefixed: "pl"}, "en=/en/p/slug-en|T-en pl=/p/slug-pl|T-pl de=/de/p/slug-de|T-de"},
} {
var parts []string
for _, alt := range menuAlternates("/p/:slug", tc.locales, params, title) {
parts = append(parts, alt.Locale+"="+alt.URL+"|"+alt.Title)
}
if got := strings.Join(parts, " "); got != tc.want {
t.Fatalf("%s: %s, want %s", tc.name, got, tc.want)
}
}
mtime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
item := resolvedMenuItem("/p/:slug", menuLocales{codes: []string{"pl", "en"}, defaultCode: "en", unprefixed: "en"}, "Host", &mtime, params, title)
if item.URL != "/pl/p/slug-pl" || item.Title != "Host" || item.MTime == nil || !item.MTime.Equal(mtime) || item.MTime == &mtime {
t.Fatalf("item = %+v (URL is the first alternate's, MTime a copy)", item)
}
bare := resolvedMenuItem("/p/:slug", menuLocales{}, "Host", nil, params, title)
if bare.URL != "/p/slug-" || bare.MTime != nil || len(bare.Alternates) != 1 {
t.Fatalf("item without locales = %+v", bare)
}
})
t.Run("translation fallback", func(t *testing.T) {
tr := menuTranslations{defaultCode: "en", data: map[string]map[uint]map[string]any{
"pl": {1: {"slug": "witaj", "title": "", "zero": "0", "num": float64(2), "falsy": false, "list": []any{}}},
}}
for _, tc := range []struct {
locale, field, want string
id uint
}{
{"pl", "slug", "witaj", 1},
{"pl", "title", "host", 1},
{"pl", "zero", "host", 1},
{"pl", "falsy", "host", 1},
{"pl", "list", "host", 1},
{"pl", "num", "2", 1},
{"pl", "missing", "host", 1},
{"pl", "slug", "host", 2},
{"de", "slug", "host", 1},
{"en", "slug", "host", 1},
{"", "slug", "host", 1},
} {
if got := tr.value(tc.locale, tc.id, tc.field, "host"); got != tc.want {
t.Fatalf("value(%s, %d, %s) = %q, want %q", tc.locale, tc.id, tc.field, got, tc.want)
}
}
for v, want := range map[any]bool{nil: false, "": false, "0": false, "a": true, true: true, false: false, float64(0): false, float64(1): true} {
if phpTruthy(v) != want {
t.Fatalf("phpTruthy(%#v) != %v", v, want)
}
}
if !phpTruthy(map[string]any{"a": 1}) || phpTruthy(map[string]any{}) || !phpTruthy([]any{1}) {
t.Fatal("phpTruthy of maps and lists")
}
})
t.Run("references", func(t *testing.T) {
for ref, want := range map[string]uint{"7": 7, " 12 ": 12} {
if got, ok := menuReferenceID(ref); !ok || got != want {
t.Fatalf("menuReferenceID(%q) = %d, %v", ref, got, ok)
}
}
for _, ref := range []string{"", "0", "-1", "abc", "1.5", "99999999999"} {
if _, ok := menuReferenceID(ref); ok {
t.Fatalf("menuReferenceID(%q) accepted", ref)
}
}
})
t.Run("category tree", func(t *testing.T) {
parent := func(id uint) *uint { return &id }
cats := []models.Category{
{ID: 1, Name: "Root", Slug: "root"},
{ID: 2, Name: "Child", Slug: "child", ParentID: parent(1)},
{ID: 3, Name: "Grandchild", Slug: "grand", ParentID: parent(2)},
{ID: 4, Name: "Orphan", Slug: "orphan", ParentID: parent(99)},
{ID: 5, Name: "Self", Slug: "self", ParentID: parent(5)},
}
tree := categoryTree{cats: cats, index: map[uint]int{}, children: map[uint][]int{}}
for i, c := range cats {
tree.index[c.ID] = i
}
for i, c := range cats {
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
if _, ok := tree.index[*c.ParentID]; ok {
tree.children[*c.ParentID] = append(tree.children[*c.ParentID], i)
continue
}
}
tree.roots = append(tree.roots, i)
}
var walk func(refs []pact.MenuItemReference, depth int) string
walk = func(refs []pact.MenuItemReference, depth int) string {
var parts []string
for _, r := range refs {
parts = append(parts, fmt.Sprintf("%d:%s=%s", depth, r.Value, r.Label)+walk(r.Items, depth+1))
}
if len(parts) == 0 {
return ""
}
return "[" + strings.Join(parts, " ") + "]"
}
if got := walk(tree.references(tree.roots), 0); got != "[0:1=Root[1:2=Child[2:3=Grandchild]] 0:4=Orphan 0:5=Self]" {
t.Fatalf("references = %s", got)
}
ci := categoryItems{pattern: "/c/:id/:slug", locales: menuLocales{}}
sub := ci.subtree(tree, 1)
if len(sub) != 1 || sub[0].URL != "/c/2/child" || len(sub[0].Items) != 1 || sub[0].Items[0].URL != "/c/3/grand" || len(sub[0].Items[0].Items) != 0 {
t.Fatalf("subtree = %+v", sub)
}
if got := ci.subtree(tree, 3); len(got) != 0 {
t.Fatalf("leaf subtree = %+v", got)
}
})
t.Run("without a booted app every resolve is an error, and a bad reference resolves to nothing first", func(t *testing.T) {
for name, app := range map[string]func() *backpack.App{"nil accessor": nil, "nil app": func() *backpack.App { return nil }} {
for _, typ := range MenuItemTypes(app) {
got, err := typ.Resolve(context.Background(), pact.MenuItemResolve{Type: typ.Key, Reference: "1", Pattern: typ.DefaultPattern})
if err == nil || got != nil || !strings.HasPrefix(err.Error(), "golem15.journal: menu item types: ") {
t.Fatalf("%s %s: %+v %v", name, typ.Key, got, err)
}
if typ.References != nil {
if _, err := typ.References(context.Background()); err == nil {
t.Fatalf("%s %s references without an app", name, typ.Key)
}
}
if typ.Key == MenuTypeAllCategories || typ.Key == MenuTypeAllPosts {
continue
}
got, err = typ.Resolve(context.Background(), pact.MenuItemResolve{Type: typ.Key, Reference: "abc"})
if err != nil || got != nil {
t.Fatalf("%s %s with a bad reference: %+v %v", name, typ.Key, got, err)
}
}
}
})
}