Files
sm-journal-plugin/journal_descriptor_fields_test.go
Jakub Zych 0a62090435 fix: content descriptors list only locales with content
The post, category, tag and author descriptors keep one entry per
enabled locale but give a locale a URL only when the record has content
in it: posts and categories by their own translation (a translated slug
alone is no content), tags and authors when one of their published
posts is translated into it, the default locale always. Without content
the entry's URL is "", so hreflang and the sitemaps never advertise an
untranslated locale. The menu item types' Resolve is unchanged.
2026-11-04 23:27:57 +01:00

273 lines
12 KiB
Go

package journal_test
import (
"context"
"reflect"
"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/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// seedLocales enables en (default) and pl.
func seedLocales(t *testing.T, gdb *gorm.DB) {
t.Helper()
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)
}
}
}
// seedAuthor inserts a backend user with an optional author slug.
func seedAuthor(t *testing.T, gdb *gorm.DB, login, first, last, slug string) uint {
t.Helper()
var userID uint
if err := gdb.Raw(`INSERT INTO backend_users (login, email, password, first_name, last_name, is_activated, is_superuser, created_at, updated_at)
VALUES (?, ?, 'x', ?, ?, TRUE, FALSE, NOW(), NOW()) RETURNING id`, login, login+"@example.test", first, last).Scan(&userID).Error; err != nil || userID == 0 {
t.Fatalf("backend user %s: %d %v", login, userID, err)
}
if slug != "" {
if err := gdb.Exec(`UPDATE backend_users SET golem15_bloghub_author_slug = ? WHERE id = ?`, slug, userID).Error; err != nil {
t.Fatal(err)
}
}
return userID
}
func describe(t *testing.T, reg *signpost.Registry, key, recordID string) *pact.ContentRecord {
t.Helper()
rec, err := reg.Describe(context.Background(), key, recordID, "")
if err != nil || rec == nil {
t.Fatalf("Describe %s %s = %+v, %v", key, recordID, rec, err)
}
return rec
}
// TestJournalDescriptorFields (D-93, D-43, D-96, P33-06): posts describe
// their publication, categories in order, authors, featured and body
// images, YouTube embeds, heading outline, top heading level, kind and
// tools; categories their parent, pillar outline, top heading and tools;
// tags their kind. TopHeading follows the H1 setting.
func TestJournalDescriptorFields(t *testing.T) {
requirePostgres(t)
for _, tc := range []struct {
name string
files map[string]string
authorPages bool
}{
{"author pages on", map[string]string{}, true},
{"author pages off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false},
} {
t.Run(tc.name, func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, tc.files)
seedLocales(t, gdb)
ctx := context.Background()
alpha := menuCategory(t, gdb, "Alpha", "alpha", nil, 1, 4)
beta := menuCategory(t, gdb, "Beta", "beta", nil, 5, 6)
child := menuCategory(t, gdb, "Child", "child", &alpha.ID, 2, 3)
pillar := "<h1>Pillar</h1><p>x</p><h2>Part</h2>"
if err := gdb.Model(&models.Category{}).Where("id = ?", alpha.ID).Update("content", pillar).Error; err != nil {
t.Fatal(err)
}
author := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
body := `<h1>Top</h1><p>x</p><h2> Sub heading </h2>` +
`<p><img src="/a.jpg" alt="A" width="640" height="480"><img src="/d.jpg" alt=""></p>` +
`<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure><h3><a href="/x">Linked</a></h3>`
post := models.Post{Title: "Hello", Slug: "hello", Content: body, ContentHTML: body, Published: true, PublishedAt: &past, UserID: &author}
if err := gdb.Create(&post).Error; err != nil {
t.Fatal(err)
}
attachCategory(t, gdb, post, beta)
attachCategory(t, gdb, post, alpha)
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj", "content_html": `<h2>PL</h2><img src="/p.jpg" alt="P">`} {
if err := translate.SetTranslated(ctx, gdb, &post, field, "pl", value); err != nil {
t.Fatal(err)
}
}
cover := attach.File{DiskName: "cover.jpg", FileName: "cover.jpg", ContentType: "image/jpeg",
AttachmentType: post.MorphName(), AttachmentID: id(post.ID), Field: "featured_images"}
if err := gdb.Create(&cover).Error; err != nil {
t.Fatal(err)
}
tag := models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
if err := gdb.Create(&tag).Error; err != nil {
t.Fatal(err)
}
typ, ok := reg.ContentType(classes.MenuTypePost)
wantTools := map[string]string{
pact.ContentToolEdit: "journal_update_post",
pact.ContentToolHeadings: "journal_fix_headings",
pact.ContentToolImageAlt: "journal_set_image_alt",
}
if !ok || typ.Content.Kind != pact.ContentKindArticle || !reflect.DeepEqual(typ.Content.Tools, wantTools) {
t.Fatalf("post descriptor kind %q tools %v", typ.Content.Kind, typ.Content.Tools)
}
rec := describe(t, reg, classes.MenuTypePost, id(post.ID))
if rec.PublishedAt == nil || !rec.PublishedAt.Equal(past) {
t.Fatalf("PublishedAt = %v", rec.PublishedAt)
}
wantParents := []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}, {Type: classes.MenuTypeCategory, ID: id(beta.ID)}}
if !reflect.DeepEqual(rec.Parents, wantParents) {
t.Fatalf("Parents = %+v, want %+v", rec.Parents, wantParents)
}
wantRef := pact.ContentRef{}
if tc.authorPages {
wantRef = pact.ContentRef{Type: classes.MenuTypeAuthor, ID: id(author)}
}
if !reflect.DeepEqual(rec.Authors, []pact.ContentPerson{{Name: "Ada Lovelace", Ref: wantRef}}) {
t.Fatalf("Authors = %+v", rec.Authors)
}
if rec.TopHeading != 2 {
t.Fatalf("TopHeading = %d, want 2 while the setting is on", rec.TopHeading)
}
en, pl := rec.Locales[0], rec.Locales[1]
wantImages := []pact.ContentImage{
{URL: cover.URL(), Alt: "Hello"},
{URL: "/a.jpg", Alt: "A", Width: 640, Height: 480},
{URL: "/d.jpg", Alt: "", Decorative: true},
}
if !reflect.DeepEqual(en.Images, wantImages) {
t.Fatalf("en Images = %+v, want %+v", en.Images, wantImages)
}
if !reflect.DeepEqual(pl.Images, []pact.ContentImage{{URL: cover.URL(), Alt: "Witaj"}, {URL: "/p.jpg", Alt: "P"}}) {
t.Fatalf("pl Images = %+v", pl.Images)
}
if !reflect.DeepEqual(en.Videos, []pact.ContentVideo{{Provider: "youtube", ID: "dQw4w9WgXcQ"}}) || len(pl.Videos) != 0 {
t.Fatalf("Videos = %+v / %+v", en.Videos, pl.Videos)
}
wantHeadings := []pact.ContentHeading{{Level: 1, Text: "Top"}, {Level: 2, Text: "Sub heading"}, {Level: 3, Text: "Linked"}}
if !reflect.DeepEqual(en.Headings, wantHeadings) || !reflect.DeepEqual(pl.Headings, []pact.ContentHeading{{Level: 2, Text: "PL"}}) {
t.Fatalf("Headings = %+v / %+v", en.Headings, pl.Headings)
}
catType, _ := reg.ContentType(classes.MenuTypeCategory)
if catType.Content.Kind != pact.ContentKindCollection || !reflect.DeepEqual(catType.Content.Tools, map[string]string{pact.ContentToolHeadings: "journal_fix_headings"}) {
t.Fatalf("category descriptor kind %q tools %v", catType.Content.Kind, catType.Content.Tools)
}
kid := describe(t, reg, classes.MenuTypeCategory, id(child.ID))
if !reflect.DeepEqual(kid.Parents, []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}}) || len(kid.Locales[0].Headings) != 0 {
t.Fatalf("child category = %+v", kid)
}
top := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID))
if len(top.Parents) != 0 || top.TopHeading != 2 ||
!reflect.DeepEqual(top.Locales[0].Headings, []pact.ContentHeading{{Level: 1, Text: "Pillar"}, {Level: 2, Text: "Part"}}) {
t.Fatalf("pillar category = %+v", top)
}
tagType, _ := reg.ContentType(classes.MenuTypeTag)
if tagType.Content.Kind != pact.ContentKindCollection {
t.Fatalf("tag kind %q", tagType.Content.Kind)
}
if err := gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = FALSE WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
if got := describe(t, reg, classes.MenuTypePost, id(post.ID)).TopHeading; got != 0 {
t.Fatalf("post TopHeading with the setting off = %d, want 0", got)
}
if got := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID)).TopHeading; got != 0 {
t.Fatalf("category TopHeading with the setting off = %d, want 0", got)
}
})
}
}
// TestJournalAuthorType (D-43, D-34, P33-07): journal-author lists the
// authors with a published post, describes them as profiles at
// /blog/author/:author (the author slug, else the login) per locale, matches
// exactly on the pattern, and with golem15.journal.author_pages false
// describes empty URLs and never matches.
func TestJournalAuthorType(t *testing.T) {
requirePostgres(t)
for _, tc := range []struct {
name string
files map[string]string
on bool
}{
{"default", map[string]string{}, true},
{"off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false},
} {
t.Run(tc.name, func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, tc.files)
seedLocales(t, gdb)
ctx := context.Background()
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
bob := seedAuthor(t, gdb, "bob", "Bob", "", "")
seedAuthor(t, gdb, "carl", "Carl", "Idle", "carl")
for _, p := range []models.Post{
{Title: "By Ada", Slug: "by-ada", Content: "x", Published: true, PublishedAt: &past, UserID: &ada},
{Title: "By Bob", Slug: "by-bob", Content: "x", Published: true, PublishedAt: &past, UserID: &bob},
{Title: "Carl draft", Slug: "carl-draft", Content: "x", Published: false, PublishedAt: &past},
} {
p := p
if err := gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
// Ada's post is translated into pl, Bob's is not (P33-64).
if p.UserID != nil && *p.UserID == ada {
if err := translate.SetTranslated(ctx, gdb, &p, "title", "pl", "Od Ady"); err != nil {
t.Fatal(err)
}
}
}
typ, ok := reg.ContentType(classes.MenuTypeAuthor)
if !ok || typ.Content.Kind != pact.ContentKindProfile || typ.Content.Controller != "" || typ.Content.Model != "Backend\\Models\\User" {
t.Fatalf("journal-author descriptor = %+v", typ.Content)
}
page, err := reg.Records(ctx, classes.MenuTypeAuthor, "", 0, "")
if err != nil || len(page.Records) != 2 || page.Records[0].ID != id(ada) || page.Records[1].ID != id(bob) {
t.Fatalf("Records = %+v, %v; want Ada and Bob only", page, err)
}
rec := describe(t, reg, classes.MenuTypeAuthor, id(ada))
want := "en= pl="
if tc.on {
want = "en=/blog/author/ada-l pl=/pl/blog/author/ada-l"
}
if localeURLs(rec) != want || rec.Locales[0].Title != "Ada Lovelace" || !rec.Published {
t.Fatalf("Describe Ada = %+v; want URLs %s", rec, want)
}
if tc.on {
// Bob has no post with content in pl: no pl address.
if got := localeURLs(describe(t, reg, classes.MenuTypeAuthor, id(bob))); got != "en=/blog/author/bob pl=" {
t.Fatalf("login fallback URLs = %s", got)
}
}
for _, path := range []string{"/blog/author/ada-l", "/pl/blog/author/ada-l"} {
key, res, err := reg.MatchPath(ctx, path, "")
if err != nil {
t.Fatal(err)
}
if tc.on && (key != classes.MenuTypeAuthor || res == nil || !res.Exact || res.ID != id(ada)) {
t.Fatalf("MatchPath(%s) = %q %+v", path, key, res)
}
if !tc.on && res != nil && key == classes.MenuTypeAuthor {
t.Fatalf("MatchPath(%s) matched with author pages off: %+v", path, res)
}
}
if key, res, err := reg.MatchPath(ctx, "/blog/author/carl", ""); err != nil || (res != nil && key == classes.MenuTypeAuthor) {
t.Fatalf("an author without a published post matched: %q %+v %v", key, res, err)
}
item, err := reg.Resolve(ctx, classes.MenuTypeAuthor, id(ada), "", false, "")
if err != nil || (item != nil) != tc.on || (tc.on && item.URL != "/blog/author/ada-l") {
t.Fatalf("Resolve = %+v, %v", item, err)
}
})
}
}