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:
244
classes/menu_item_types_test.go
Normal file
244
classes/menu_item_types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
247
menu_item_types_test.go
Normal file
247
menu_item_types_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestMenuItemTypes pins D-04 on Postgres: each type's pattern layers
|
||||
// (a host override of only one record type reaches only its list types; an
|
||||
// item override reaches the list children and the nested categories), the
|
||||
// references, nesting with the subtree at any depth, list order with ties by
|
||||
// id, the published filters, descendant categories, alternates in sort_order
|
||||
// with ties by id, translated titles with PHP's fallback, date placeholders
|
||||
// only for published posts, and unknown references.
|
||||
func TestMenuItemTypes(t *testing.T) {
|
||||
requirePostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("a host override of journal-post reaches only the post list types", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-post: /p/:slug\n"})
|
||||
seedMenuTree(t, gdb)
|
||||
for key, want := range map[string]string{
|
||||
classes.MenuTypePost: "/p/:slug",
|
||||
classes.MenuTypeAllPosts: "/p/:slug",
|
||||
classes.MenuTypeCategoryPosts: "/p/:slug",
|
||||
classes.MenuTypeCategory: classes.DefaultCategoryPattern,
|
||||
classes.MenuTypeAllCategories: classes.DefaultCategoryPattern,
|
||||
} {
|
||||
if got := reg.Pattern(key, ""); got != want {
|
||||
t.Fatalf("Pattern(%s) = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
got := resolve(t, reg, classes.MenuTypeAllCategories, "", "", false)
|
||||
if itemURLs(got.Items) != "Alpha=/blog/category/alpha Beta=/blog/category/beta Gamma=/blog/category/gamma" {
|
||||
t.Fatalf("categories under the default pattern = %s", itemURLs(got.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a host override of journal-category reaches only the category list type", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /:slug\n"})
|
||||
seed := seedMenuTree(t, gdb)
|
||||
got := resolve(t, reg, classes.MenuTypeAllCategories, "", "", false)
|
||||
if itemURLs(got.Items) != "Alpha=/alpha Beta=/beta Gamma=/gamma" {
|
||||
t.Fatalf("categories = %s", itemURLs(got.Items))
|
||||
}
|
||||
got = resolve(t, reg, classes.MenuTypeAllPosts, "", "", false)
|
||||
if !strings.HasPrefix(itemURLs(got.Items), "Apple=/blog/apple") {
|
||||
t.Fatalf("posts keep their default = %s", itemURLs(got.Items))
|
||||
}
|
||||
// An item override of the list item reaches its children.
|
||||
got = resolve(t, reg, classes.MenuTypeAllCategories, "", "/topics/:id-:slug", false)
|
||||
if want := "Alpha=/topics/" + id(seed.alpha.ID) + "-alpha"; !strings.HasPrefix(itemURLs(got.Items), want) {
|
||||
t.Fatalf("item override = %s, want prefix %s", itemURLs(got.Items), want)
|
||||
}
|
||||
// The list key's own host override wins over its record type's.
|
||||
_, gdb2, reg2 := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /:slug\n all-journal-categories: /all/:slug\n"})
|
||||
seedMenuTree(t, gdb2)
|
||||
got = resolve(t, reg2, classes.MenuTypeAllCategories, "", "", false)
|
||||
if !strings.HasPrefix(itemURLs(got.Items), "Alpha=/all/alpha") {
|
||||
t.Fatalf("list key override = %s", itemURLs(got.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("references, nesting, order, published filters and descendants", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||
seed := seedMenuTree(t, gdb)
|
||||
|
||||
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypePost)
|
||||
if err != nil || !refs.Applies {
|
||||
t.Fatalf("post references = %+v %v", refs, err)
|
||||
}
|
||||
var labels []string
|
||||
for _, opt := range refs.Options {
|
||||
labels = append(labels, opt.Label)
|
||||
}
|
||||
// Every post by title, drafts and future posts included, ties by id.
|
||||
if strings.Join(labels, ",") != "Apple,Draft,Future,Same,Same,Zebra" || refs.Options[3].Value != id(seed.same1.ID) {
|
||||
t.Fatalf("post references = %v (%+v)", labels, refs.Options)
|
||||
}
|
||||
refs, err = signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategory)
|
||||
if err != nil || len(refs.Options) != 3 || refs.Options[0].Label != "Beta" || refs.Options[1].Label != "Alpha" || refs.Options[1].Depth != 1 || refs.Options[2].Label != "Gamma" || refs.Options[2].Depth != 2 {
|
||||
t.Fatalf("category references = %+v %v", refs, err)
|
||||
}
|
||||
if same, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategoryPosts); err != nil || len(same.Options) != 3 {
|
||||
t.Fatalf("category-journal-posts references = %+v %v", same, err)
|
||||
}
|
||||
|
||||
// journal-post resolves without a published filter.
|
||||
if got := resolve(t, reg, classes.MenuTypePost, id(seed.draft.ID), "", false); got.URL != "/blog/draft" {
|
||||
t.Fatalf("draft post = %+v", got)
|
||||
}
|
||||
// Nesting returns the subtree at every depth; without nesting no items.
|
||||
got := resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "", true)
|
||||
if got.URL != "/blog/category/beta" || len(got.Items) != 1 || got.Items[0].Title != "Alpha" || len(got.Items[0].Items) != 1 || got.Items[0].Items[0].URL != "/blog/category/gamma" {
|
||||
t.Fatalf("nested = %+v", got)
|
||||
}
|
||||
got = resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "/c/:slug", true)
|
||||
if got.URL != "/c/beta" || got.Items[0].URL != "/c/alpha" || got.Items[0].Items[0].URL != "/c/gamma" {
|
||||
t.Fatalf("nested with an item override = %+v", got)
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategory, id(seed.gamma.ID), "", true); len(got.Items) != 0 {
|
||||
t.Fatalf("leaf nesting = %+v", got)
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "", false); len(got.Items) != 0 {
|
||||
t.Fatalf("no nesting = %+v", got)
|
||||
}
|
||||
|
||||
// all-journal-posts: published with a past date only, by title, ties by id.
|
||||
got = resolve(t, reg, classes.MenuTypeAllPosts, "", "/:id", false)
|
||||
if want := "Apple=/" + id(seed.apple.ID) + " Same=/" + id(seed.same1.ID) + " Same=/" + id(seed.same2.ID) + " Zebra=/" + id(seed.zebra.ID); itemURLs(got.Items) != want {
|
||||
t.Fatalf("all posts = %s, want %s", itemURLs(got.Items), want)
|
||||
}
|
||||
// category-journal-posts: the category and every descendant.
|
||||
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.beta.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple Same=/blog/same-1 Zebra=/blog/zebra" {
|
||||
t.Fatalf("beta posts = %s", itemURLs(got.Items))
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.alpha.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple Same=/blog/same-1" {
|
||||
t.Fatalf("alpha posts = %s", itemURLs(got.Items))
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.gamma.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple" {
|
||||
t.Fatalf("gamma posts = %s", itemURLs(got.Items))
|
||||
}
|
||||
|
||||
// :category is the first category by name; date parts only when published.
|
||||
got = resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "/:category/:year/:month/:day/:slug", false)
|
||||
if got.URL != "/alpha/2026/03/04/apple" {
|
||||
t.Fatalf("published post url = %q", got.URL)
|
||||
}
|
||||
got = resolve(t, reg, classes.MenuTypePost, id(seed.draft.ID), "/:category/:year/:slug", false)
|
||||
if got.URL != "/beta/:year/draft" {
|
||||
t.Fatalf("draft post url = %q", got.URL)
|
||||
}
|
||||
|
||||
// Unknown or malformed references resolve to nothing.
|
||||
for _, key := range []string{classes.MenuTypePost, classes.MenuTypeCategory, classes.MenuTypeCategoryPosts} {
|
||||
for _, ref := range []string{"999999", "abc", ""} {
|
||||
if got, err := reg.Resolve(ctx, key, ref, "", true, ""); err != nil || got != nil {
|
||||
t.Fatalf("%s %q = %+v %v", key, ref, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alternates in sort order with ties by id, translated titles with fallback", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||
seed := seedMenuTree(t, gdb)
|
||||
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES ('de', 'Deutsch', FALSE, TRUE, 5), ('cs', 'Čeština', FALSE, TRUE, 5)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`UPDATE golem15_translate_locales SET sort_order = CASE code WHEN 'en' THEN 1 WHEN 'pl' THEN 5 ELSE sort_order END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
if err := gdb.Raw(`SELECT code FROM golem15_translate_locales WHERE is_enabled ORDER BY sort_order, id`).Scan(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "slug", "pl", "jablko"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "title", "pl", "Jabłko"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "slug", "de", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "", false)
|
||||
var parts []string
|
||||
for _, alt := range got.Alternates {
|
||||
parts = append(parts, alt.Locale+"="+alt.URL+"|"+alt.Title)
|
||||
}
|
||||
want := []string{}
|
||||
for _, code := range order {
|
||||
switch code {
|
||||
case "en":
|
||||
want = append(want, "en=/blog/apple|Apple")
|
||||
case "pl":
|
||||
want = append(want, "pl=/pl/blog/jablko|Jabłko")
|
||||
default:
|
||||
want = append(want, code+"=/"+code+"/blog/apple|Apple")
|
||||
}
|
||||
}
|
||||
if strings.Join(parts, " ") != strings.Join(want, " ") || got.URL != "/blog/apple" || got.Title != "Apple" {
|
||||
t.Fatalf("alternates = %s\nwant %s (url %q)", strings.Join(parts, " "), strings.Join(want, " "), got.URL)
|
||||
}
|
||||
// The unprefixed locale may be another one.
|
||||
got = resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "", false, "pl")
|
||||
if got.Alternates[0].URL != "/en/blog/apple" || !strings.Contains(altString(got.Alternates), "pl=/blog/jablko") {
|
||||
t.Fatalf("unprefixed pl = %s", altString(got.Alternates))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// menuSeed is the fixture of TestMenuItemTypes: categories Beta > Alpha >
|
||||
// Gamma, posts in them, a draft, a future post and two posts titled Same.
|
||||
type menuSeed struct {
|
||||
beta, alpha, gamma models.Category
|
||||
apple, zebra, draft, future, same1, same2 models.Post
|
||||
}
|
||||
|
||||
func seedMenuTree(t *testing.T, gdb *gorm.DB) menuSeed {
|
||||
t.Helper()
|
||||
if err := gdb.Exec(`DELETE FROM golem15_journal_categories`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s menuSeed
|
||||
s.beta = menuCategory(t, gdb, "Beta", "beta", nil, 1, 6)
|
||||
s.alpha = menuCategory(t, gdb, "Alpha", "alpha", &s.beta.ID, 2, 5)
|
||||
s.gamma = menuCategory(t, gdb, "Gamma", "gamma", &s.alpha.ID, 3, 4)
|
||||
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||
future := time.Now().Add(240 * time.Hour)
|
||||
s.zebra = menuPost(t, gdb, "Zebra", "zebra", true, &past)
|
||||
s.apple = menuPost(t, gdb, "Apple", "apple", true, &past)
|
||||
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
|
||||
s.future = menuPost(t, gdb, "Future", "future", true, &future)
|
||||
s.same1 = menuPost(t, gdb, "Same", "same-1", true, &past)
|
||||
s.same2 = menuPost(t, gdb, "Same", "same-2", true, &past)
|
||||
attachCategory(t, gdb, s.apple, s.gamma)
|
||||
attachCategory(t, gdb, s.apple, s.alpha)
|
||||
attachCategory(t, gdb, s.zebra, s.beta)
|
||||
attachCategory(t, gdb, s.draft, s.beta)
|
||||
attachCategory(t, gdb, s.future, s.beta)
|
||||
attachCategory(t, gdb, s.same1, s.alpha)
|
||||
return s
|
||||
}
|
||||
|
||||
// resolve resolves one item and fails when it resolves to nothing.
|
||||
func resolve(t *testing.T, reg *signpost.Registry, key, ref, pattern string, nesting bool, unprefixed ...string) *pact.ResolvedMenuItem {
|
||||
t.Helper()
|
||||
locale := ""
|
||||
if len(unprefixed) > 0 {
|
||||
locale = unprefixed[0]
|
||||
}
|
||||
got, err := reg.Resolve(context.Background(), key, ref, pattern, nesting, locale)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("Resolve(%s, %q, %q) = %+v, %v", key, ref, pattern, got, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
Reference in New Issue
Block a user