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: "

Short & sweet

", Content: "x", ContentHTML: "

One two three

", 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/, 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) } }) }