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

Pillar

x

Part

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

Top

x

Sub heading

` + `

A

` + `

Linked

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

PL

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) } }) } }