Files
sm-journal-plugin/content_descriptors_test.go
Jakub Zych e17cebd761 feat: content descriptors for posts, categories and the new journal-tag type
- journal-post, journal-category and journal-tag carry pact.ContentDescriptor
  (controller, morph name, Describe, Records, Match) through signpost
- Match reads the locale prefix and the pattern with signpost.MatchPattern,
  finds the record by its slug in that locale, the default slug, another
  locale's slug or :id, and is exact only on the record's own URL (D-20c)
- journal-tag (/blog/tag/:slug, P-11) with references and Resolve; host key
  golem15.journal.tag_pages false empties tag URLs and stops tag matches
- TestContentDescriptors, TestJournalTagType; README documents both
2026-10-26 22:46:32 +01:00

252 lines
10 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 &amp; 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)
}
}