The descriptor matrix for posts, categories and tags: Describe per locale with and without Polish unprefixed, Match exact and inexact per locale, Records paging and cursors, host patterns, unknown and malformed ids; journal-tag with tag_pages by default, on and off; translated slugs in menu items and matching, including a changed Polish slug.
396 lines
17 KiB
Go
396 lines
17 KiB
Go
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"
|
|
)
|
|
|
|
// contentSeed is the records the descriptor tests read: a published post
|
|
// with a Polish slug in the news category (Polish slug wiadomosci), a
|
|
// draft, and a tag.
|
|
type contentSeed struct {
|
|
news models.Category
|
|
hello models.Post
|
|
draft models.Post
|
|
tag models.Tag
|
|
}
|
|
|
|
func seedContent(t *testing.T, gdb *gorm.DB) contentSeed {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
for _, row := range []struct {
|
|
code, name string
|
|
def bool
|
|
order int
|
|
}{{"en", "English", true, 1}, {"pl", "Polski", false, 2}} {
|
|
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order)
|
|
SELECT ?, ?, ?, TRUE, ? WHERE NOT EXISTS (SELECT 1 FROM golem15_translate_locales WHERE code = ?)`,
|
|
row.code, row.name, row.def, row.order, row.code).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var s contentSeed
|
|
s.news = menuCategory(t, gdb, "News", "news", nil, 1, 2)
|
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
|
s.hello = models.Post{Title: "Hello", Slug: "hello", Excerpt: "<p>Short & sweet</p>", Content: "x", ContentHTML: "<p>One two three</p>", Published: true, PublishedAt: &past}
|
|
if err := gdb.Create(&s.hello).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
|
|
attachCategory(t, gdb, s.hello, s.news)
|
|
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj"} {
|
|
if err := translate.SetTranslated(ctx, gdb, &s.hello, field, "pl", value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := translate.SetTranslated(ctx, gdb, &s.news, "slug", "pl", "wiadomosci"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.tag = models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
|
|
if err := gdb.Create(&s.tag).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func contentKeys(types []pact.MenuItemType) string {
|
|
keys := []string{}
|
|
for _, t := range types {
|
|
keys = append(keys, t.Key)
|
|
}
|
|
return strings.Join(keys, " ")
|
|
}
|
|
|
|
func localeURLs(rec *pact.ContentRecord) string {
|
|
parts := []string{}
|
|
for _, l := range rec.Locales {
|
|
parts = append(parts, l.Locale+"="+l.URL)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// matchPost runs journal-post's Match directly with a pattern.
|
|
func matchPost(t *testing.T, reg *signpost.Registry, path, pattern string) *pact.ContentMatchResult {
|
|
t.Helper()
|
|
typ, ok := reg.ContentType(classes.MenuTypePost)
|
|
if !ok || typ.Content.Match == nil {
|
|
t.Fatal("journal-post has no Match")
|
|
}
|
|
res, err := typ.Content.Match(context.Background(), pact.ContentMatch{Path: path, Pattern: pattern})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// TestContentDescriptors (D-93, D-11, D-20c): Journal's post and category
|
|
// descriptors describe, page and match their records through signpost;
|
|
// strict segments make a wrong category, a stale locale prefix or another
|
|
// locale's slug inexact, and drafts never match.
|
|
func TestContentDescriptors(t *testing.T) {
|
|
requirePostgres(t)
|
|
ctx := context.Background()
|
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
|
s := seedContent(t, gdb)
|
|
|
|
if got := contentKeys(reg.ContentTypes()); got != "journal-category journal-post journal-tag" {
|
|
t.Fatalf("ContentTypes = %s", got)
|
|
}
|
|
for controller, key := range map[string]string{
|
|
"golem15.journal.posts": classes.MenuTypePost,
|
|
"golem15.journal.categories": classes.MenuTypeCategory,
|
|
"golem15.journal.tags": classes.MenuTypeTag,
|
|
} {
|
|
if typ, ok := reg.ContentTypeForController(controller); !ok || typ.Key != key {
|
|
t.Fatalf("ContentTypeForController(%s) = %q %v", controller, typ.Key, ok)
|
|
}
|
|
}
|
|
if typ, ok := reg.ContentTypeForModel(models.Category{}.MorphName()); !ok || typ.Key != classes.MenuTypeCategory {
|
|
t.Fatalf("category model maps to %q %v", typ.Key, ok)
|
|
}
|
|
|
|
t.Run("describe", func(t *testing.T) {
|
|
rec, err := reg.Describe(ctx, classes.MenuTypePost, id(s.hello.ID), "")
|
|
if err != nil || rec == nil {
|
|
t.Fatalf("Describe post = %+v, %v", rec, err)
|
|
}
|
|
if got := localeURLs(rec); got != "en=/blog/hello pl=/pl/blog/witaj" {
|
|
t.Fatalf("post URLs = %s", got)
|
|
}
|
|
en := rec.Locales[0]
|
|
if !rec.Published || en.Title != "Hello" || rec.Locales[1].Title != "Witaj" || en.Description != "Short & sweet" || en.Words == nil || *en.Words != 3 {
|
|
t.Fatalf("post record = %+v, en %+v", rec, en)
|
|
}
|
|
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
|
|
if err != nil || draft == nil || draft.Published {
|
|
t.Fatalf("Describe draft = %+v, %v", draft, err)
|
|
}
|
|
cat, err := reg.Describe(ctx, classes.MenuTypeCategory, id(s.news.ID), "")
|
|
if err != nil || cat == nil || localeURLs(cat) != "en=/blog/category/news pl=/pl/blog/category/wiadomosci" || !cat.Published {
|
|
t.Fatalf("Describe category = %+v, %v", cat, err)
|
|
}
|
|
if missing, err := reg.Describe(ctx, classes.MenuTypePost, "999999", ""); err != nil || missing != nil {
|
|
t.Fatalf("Describe of a missing post = %+v, %v", missing, err)
|
|
}
|
|
})
|
|
|
|
t.Run("records", func(t *testing.T) {
|
|
page, err := reg.Records(ctx, classes.MenuTypePost, "", 1, "")
|
|
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.hello.ID) || page.Next != id(s.hello.ID) {
|
|
t.Fatalf("first page = %+v, %v", page, err)
|
|
}
|
|
page, err = reg.Records(ctx, classes.MenuTypePost, page.Next, 1, "")
|
|
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.draft.ID) || page.Next != "" {
|
|
t.Fatalf("last page = %+v, %v", page, err)
|
|
}
|
|
// The migrations seed a default category before news.
|
|
cats, err := reg.Records(ctx, classes.MenuTypeCategory, "", 0, "")
|
|
if err != nil || len(cats.Records) == 0 || cats.Records[len(cats.Records)-1].ID != id(s.news.ID) || cats.Next != "" {
|
|
t.Fatalf("category page = %+v, %v", cats, err)
|
|
}
|
|
tags, err := reg.Records(ctx, classes.MenuTypeTag, "", 10, "")
|
|
if err != nil || len(tags.Records) != 1 || tags.Records[0].ID != id(s.tag.ID) {
|
|
t.Fatalf("tag page = %+v, %v", tags, err)
|
|
}
|
|
})
|
|
|
|
t.Run("match", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
path, key, locale string
|
|
exact bool
|
|
}{
|
|
{"/blog/hello", classes.MenuTypePost, "en", true},
|
|
{"/pl/blog/witaj", classes.MenuTypePost, "pl", true},
|
|
{"/pl/blog/hello", classes.MenuTypePost, "pl", false},
|
|
{"/blog/witaj", classes.MenuTypePost, "en", false},
|
|
{"/en/blog/hello", classes.MenuTypePost, "en", false},
|
|
{"/blog/category/news", classes.MenuTypeCategory, "en", true},
|
|
{"/pl/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
|
|
{"/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
|
|
} {
|
|
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
|
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
|
}
|
|
}
|
|
for _, path := range []string{"/blog/draft", "/blog/nothing", "/blog/hello/extra", "/elsewhere"} {
|
|
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
|
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
|
|
}
|
|
}
|
|
// Strict segments under a host pattern with the category.
|
|
if res := matchPost(t, reg, "/news/hello", "/:category/:slug"); res == nil || !res.Exact || res.ID != id(s.hello.ID) {
|
|
t.Fatalf("right category = %+v", res)
|
|
}
|
|
if res := matchPost(t, reg, "/sport/hello", "/:category/:slug"); res == nil || res.Exact || res.ID != id(s.hello.ID) {
|
|
t.Fatalf("wrong category = %+v, want the post, inexact", res)
|
|
}
|
|
if res := matchPost(t, reg, "/pl/wiadomosci/witaj", "/:category/:slug"); res == nil || !res.Exact || res.Locale != "pl" {
|
|
t.Fatalf("Polish category path = %+v", res)
|
|
}
|
|
if res := matchPost(t, reg, "/blog/"+id(s.hello.ID)+"-x", "/blog/:id-x"); res != nil {
|
|
t.Fatalf("a placeholder inside a segment matched: %+v", res)
|
|
}
|
|
if res := matchPost(t, reg, "/p/"+id(s.hello.ID), "/p/:id"); res == nil || !res.Exact {
|
|
t.Fatalf("id pattern = %+v", res)
|
|
}
|
|
if res := matchPost(t, reg, "/p/"+id(s.draft.ID), "/p/:id"); res != nil {
|
|
t.Fatalf("draft by id matched: %+v", res)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestJournalTagType (D-102, P-11): journal-tag lists tags as references,
|
|
// resolves to /blog/tag/<slug>, and is described and matched; with
|
|
// golem15.journal.tag_pages false its URLs are empty and its paths never
|
|
// match.
|
|
func TestJournalTagType(t *testing.T) {
|
|
requirePostgres(t)
|
|
ctx := context.Background()
|
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
|
s := seedContent(t, gdb)
|
|
|
|
typ, ok := reg.Type(classes.MenuTypeTag)
|
|
if !ok || typ.DefaultPattern != classes.DefaultTagPattern || typ.Label != "golem15.journal::lang.menuitem.journal_tag" || typ.Content == nil {
|
|
t.Fatalf("journal-tag = %+v %v", typ, ok)
|
|
}
|
|
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeTag)
|
|
if err != nil || !refs.Applies || len(refs.Options) != 1 || refs.Options[0].Value != id(s.tag.ID) || refs.Options[0].Label != "Mushrooms" {
|
|
t.Fatalf("tag references = %+v, %v", refs, err)
|
|
}
|
|
item := resolve(t, reg, classes.MenuTypeTag, id(s.tag.ID), "", false)
|
|
if item.URL != "/blog/tag/mushrooms" || item.Title != "Mushrooms" {
|
|
t.Fatalf("resolved tag = %+v", item)
|
|
}
|
|
rec, err := reg.Describe(ctx, classes.MenuTypeTag, id(s.tag.ID), "")
|
|
if err != nil || rec == nil || localeURLs(rec) != "en=/blog/tag/mushrooms pl=/pl/blog/tag/mushrooms" {
|
|
t.Fatalf("Describe tag = %+v, %v", rec, err)
|
|
}
|
|
|
|
_, offDB, off := bootMenuItemTypes(t, map[string]string{"golem15.yaml": "journal:\n tag_pages: false\n"})
|
|
offSeed := seedContent(t, offDB)
|
|
rec, err = off.Describe(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "")
|
|
if err != nil || rec == nil || localeURLs(rec) != "en= pl=" {
|
|
t.Fatalf("Describe tag without tag pages = %+v, %v", rec, err)
|
|
}
|
|
if key, res, err := off.MatchPath(ctx, "/blog/tag/mushrooms", ""); err != nil || res != nil {
|
|
t.Fatalf("tag path matched without tag pages: %q %+v %v", key, res, err)
|
|
}
|
|
if got, err := off.Resolve(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "", false, ""); err != nil || got != nil {
|
|
t.Fatalf("tag resolved without tag pages: %+v %v", got, err)
|
|
}
|
|
}
|
|
|
|
// TestContentDescriptorsMatrix (D-93, D-20c, D-58): the descriptor matrix
|
|
// for posts, categories and tags: Describe per locale with and without an
|
|
// unprefixed locale, Match exact and inexact per locale, Records paging,
|
|
// host patterns, and unknown or malformed ids.
|
|
func TestContentDescriptorsMatrix(t *testing.T) {
|
|
requirePostgres(t)
|
|
ctx := context.Background()
|
|
|
|
t.Run("describe and match with Polish unprefixed", func(t *testing.T) {
|
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
|
s := seedContent(t, gdb)
|
|
for _, tc := range []struct{ key, id, want string }{
|
|
{classes.MenuTypePost, id(s.hello.ID), "en=/en/blog/hello pl=/blog/witaj"},
|
|
{classes.MenuTypeCategory, id(s.news.ID), "en=/en/blog/category/news pl=/blog/category/wiadomosci"},
|
|
{classes.MenuTypeTag, id(s.tag.ID), "en=/en/blog/tag/mushrooms pl=/blog/tag/mushrooms"},
|
|
} {
|
|
rec, err := reg.Describe(ctx, tc.key, tc.id, "pl")
|
|
if err != nil || rec == nil || localeURLs(rec) != tc.want {
|
|
t.Fatalf("Describe(%s) = %+v, %v; want %s", tc.key, rec, err, tc.want)
|
|
}
|
|
}
|
|
for _, tc := range []struct {
|
|
path, key, locale string
|
|
exact bool
|
|
}{
|
|
{"/blog/witaj", classes.MenuTypePost, "pl", true},
|
|
{"/en/blog/hello", classes.MenuTypePost, "en", true},
|
|
{"/pl/blog/witaj", classes.MenuTypePost, "pl", false},
|
|
{"/blog/hello", classes.MenuTypePost, "pl", false},
|
|
{"/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
|
|
{"/en/blog/category/news", classes.MenuTypeCategory, "en", true},
|
|
{"/blog/category/news", classes.MenuTypeCategory, "pl", false},
|
|
{"/blog/tag/mushrooms", classes.MenuTypeTag, "pl", true},
|
|
{"/en/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
|
|
} {
|
|
key, res, err := reg.MatchPath(ctx, tc.path, "pl")
|
|
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
|
t.Fatalf("MatchPath(%s, pl) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("match per locale without an unprefixed locale", func(t *testing.T) {
|
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
|
s := seedContent(t, gdb)
|
|
for _, tc := range []struct {
|
|
path, key, locale string
|
|
exact bool
|
|
}{
|
|
{"/blog/category/wiadomosci", classes.MenuTypeCategory, "en", false},
|
|
{"/pl/blog/category/news", classes.MenuTypeCategory, "pl", false},
|
|
{"/en/blog/category/news", classes.MenuTypeCategory, "en", false},
|
|
{"/pl/blog/tag/mushrooms", classes.MenuTypeTag, "pl", true},
|
|
} {
|
|
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
|
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
|
}
|
|
}
|
|
for _, path := range []string{"/pl/blog/draft", "/blog/tag/missing", "/blog/category/missing", "/blog/category/news/extra", "/blog/tag/"} {
|
|
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
|
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
|
|
}
|
|
}
|
|
for _, tc := range []struct{ key, id string }{
|
|
{classes.MenuTypePost, "999999"}, {classes.MenuTypePost, "abc"},
|
|
{classes.MenuTypeCategory, "999999"}, {classes.MenuTypeTag, "999999"}, {classes.MenuTypeTag, ""},
|
|
} {
|
|
if rec, err := reg.Describe(ctx, tc.key, tc.id, ""); err != nil || rec != nil {
|
|
t.Fatalf("Describe(%s, %q) = %+v, %v; want nothing", tc.key, tc.id, rec, err)
|
|
}
|
|
}
|
|
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
|
|
if err != nil || draft == nil || draft.Published || localeURLs(draft) != "en=/blog/draft pl=/pl/blog/draft" {
|
|
t.Fatalf("Describe draft = %+v, %v", draft, err)
|
|
}
|
|
})
|
|
|
|
t.Run("records paging", func(t *testing.T) {
|
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
|
s := seedContent(t, gdb)
|
|
second := models.Tag{Name: "Forest", Slug: "forest"}
|
|
if err := gdb.Create(&second).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
all, err := reg.Records(ctx, classes.MenuTypePost, "", 0, "")
|
|
if err != nil || len(all.Records) != 2 || all.Next != "" {
|
|
t.Fatalf("limit 0 = %+v, %v", all, err)
|
|
}
|
|
if again, err := reg.Records(ctx, classes.MenuTypePost, "not-an-id", 0, ""); err != nil || len(again.Records) != 2 {
|
|
t.Fatalf("a malformed cursor = %+v, %v; want the first page", again, err)
|
|
}
|
|
if end, err := reg.Records(ctx, classes.MenuTypePost, id(s.draft.ID), 10, ""); err != nil || len(end.Records) != 0 || end.Next != "" {
|
|
t.Fatalf("after the last post = %+v, %v", end, err)
|
|
}
|
|
first, err := reg.Records(ctx, classes.MenuTypeTag, "", 1, "")
|
|
if err != nil || len(first.Records) != 1 || first.Records[0].ID != id(s.tag.ID) || first.Next != id(s.tag.ID) {
|
|
t.Fatalf("first tag page = %+v, %v", first, err)
|
|
}
|
|
next, err := reg.Records(ctx, classes.MenuTypeTag, first.Next, 1, "")
|
|
if err != nil || len(next.Records) != 1 || next.Records[0].ID != id(second.ID) || next.Next != "" {
|
|
t.Fatalf("second tag page = %+v, %v", next, err)
|
|
}
|
|
if got := localeURLs(&next.Records[0]); got != "en=/blog/tag/forest pl=/pl/blog/tag/forest" {
|
|
t.Fatalf("paged tag URLs = %s", got)
|
|
}
|
|
if !all.Records[0].Published || all.Records[1].Published {
|
|
t.Fatalf("published flags = %v %v", all.Records[0].Published, all.Records[1].Published)
|
|
}
|
|
})
|
|
|
|
t.Run("host patterns", func(t *testing.T) {
|
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /c/:slug\n journal-tag: /t/:slug\n journal-post: /:category/:slug\n"})
|
|
s := seedContent(t, gdb)
|
|
for _, tc := range []struct{ key, id, want string }{
|
|
{classes.MenuTypeCategory, id(s.news.ID), "en=/c/news pl=/pl/c/wiadomosci"},
|
|
{classes.MenuTypeTag, id(s.tag.ID), "en=/t/mushrooms pl=/pl/t/mushrooms"},
|
|
{classes.MenuTypePost, id(s.hello.ID), "en=/news/hello pl=/pl/wiadomosci/witaj"},
|
|
} {
|
|
rec, err := reg.Describe(ctx, tc.key, tc.id, "")
|
|
if err != nil || rec == nil || localeURLs(rec) != tc.want {
|
|
t.Fatalf("Describe(%s) = %+v, %v; want %s", tc.key, rec, err, tc.want)
|
|
}
|
|
}
|
|
for _, tc := range []struct {
|
|
path, key string
|
|
exact bool
|
|
}{
|
|
{"/c/news", classes.MenuTypeCategory, true},
|
|
{"/t/mushrooms", classes.MenuTypeTag, true},
|
|
{"/news/hello", classes.MenuTypePost, true},
|
|
{"/pl/wiadomosci/witaj", classes.MenuTypePost, true},
|
|
} {
|
|
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
|
if err != nil || key != tc.key || res == nil || res.Exact != tc.exact {
|
|
t.Fatalf("MatchPath(%s) = %q %+v %v", tc.path, key, res, err)
|
|
}
|
|
}
|
|
if key, res, err := reg.MatchPath(ctx, "/blog/category/news", ""); err != nil || res != nil {
|
|
t.Fatalf("the default pattern still matched: %q %+v %v", key, res, err)
|
|
}
|
|
})
|
|
}
|