package journal_test import ( "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "git.golem15.com/golem15/sm-journal-plugin/classes" "git.golem15.com/golem15/sm-journal-plugin/console" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/bonfire" ) const journalAPIPrefix = "/_journal/api/v1" // setH1ToH2 switches the Journal H1 setting on the settings row. func setH1ToH2(t *testing.T, env *adminEnv, on bool) { t.Helper() if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = ? WHERE id = 1`, on).Error; err != nil { t.Fatal(err) } } // publishPost publishes a post with a past published_at. func publishPost(t *testing.T, env *adminEnv, id uint) { t.Helper() if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET published = TRUE, published_at = NOW() - INTERVAL '1 hour' WHERE id = ?`, id).Error; err != nil { t.Fatal(err) } } // showH1Flag is GET /_journal/api/v1/posts/{slug}'s content_h1_to_h2. func showH1Flag(t *testing.T, env *adminEnv, slug string) any { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/posts/"+slug, "", nil) var body struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("show body %s: %v", rec.Body.String(), err) } flag, ok := body.Data["content_h1_to_h2"] if !ok { t.Fatalf("show answer has no content_h1_to_h2: %s", rec.Body.String()) } return flag } // TestHeadingConversionTracer is the 33-02 tracer: an editor saves a post // whose content holds an H1 through the admin and gets an H2 back with the // h1_converted notice, and the public post answer reports the setting. // With the setting off the same save keeps the H1, adds no notice, and the // answer reports false. func TestHeadingConversionTracer(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts, ) const submitted = "

A

x

" save := func(t *testing.T, slug string) (uint, []adminNotice) { t.Helper() rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ "title": map[string]any{"en": "Heading " + slug, "pl": "Nagłówek " + slug}, "slug": map[string]any{"en": slug, "pl": slug + "-pl"}, "content": map[string]any{"en": submitted, "pl": submitted}, }) return adminCreatedID(t, rec), adminNotices(t, rec) } t.Run("setting on", func(t *testing.T) { setH1ToH2(t, env, true) id, notices := save(t, "heading-on") assertHostContent(t, env, id, "on", "

A

x

", "

A

x

") pl := translatedAttrs(t, env, id, "pl") if pl["content"] != "

A

x

" || pl["content_html"] != "

A

x

" { t.Fatalf("pl content = %#v, content_html = %#v, want the H2", pl["content"], pl["content_html"]) } for _, locale := range []string{"en", "pl"} { found := false for _, n := range notices { if n.Field == "content" && n.Locale == locale && n.Message == "1 H1 heading was converted to H2, because the page shows the post title as the H1." { found = true } } if !found { t.Fatalf("%s notices = %#v, want the h1_converted phrase with count 1", locale, notices) } } publishPost(t, env, id) if flag := showH1Flag(t, env, "heading-on"); flag != true { t.Fatalf("content_h1_to_h2 = %#v, want true", flag) } }) t.Run("setting off", func(t *testing.T) { setH1ToH2(t, env, false) t.Cleanup(func() { setH1ToH2(t, env, true) }) id, notices := save(t, "heading-off") assertHostContent(t, env, id, "off", submitted, submitted) for _, n := range notices { if strings.Contains(n.Message, "H1") { t.Fatalf("setting off: notice %#v", n) } } if notices != nil { t.Fatalf("setting off: clean save carries meta.notices %#v", notices) } publishPost(t, env, id) if flag := showH1Flag(t, env, "heading-off"); flag != false { t.Fatalf("content_h1_to_h2 = %#v, want false", flag) } // D-50: switching the setting never rewrites stored content. setH1ToH2(t, env, true) var stored models.Post if err := env.gdb.First(&stored, id).Error; err != nil { t.Fatal(err) } if stored.Content != submitted || stored.ContentHTML != submitted { t.Fatalf("switching the setting on rewrote content: %q / %q", stored.Content, stored.ContentHTML) } }) } // cmdInput is a bonfire.Input of flags only. type cmdInput map[string]string func (in cmdInput) Args() []string { return nil } func (in cmdInput) Argument(string) (string, bool) { return "", false } func (in cmdInput) Flags(string) []string { return nil } func (in cmdInput) Flag(name string) (string, bool) { v, ok := in[name]; return v, ok } // apiNotices decodes a Journal API write answer's notices, nil when absent. func apiNotices(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any { t.Helper() var body map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("api body %s: %v", rec.Body.String(), err) } raw, ok := body["notices"] if !ok { return nil } var out []map[string]any if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("notices %s: %v", raw, err) } return out } // apiPostID is the id of a Journal API write answer's post. func apiPostID(t *testing.T, rec *httptest.ResponseRecorder) uint { t.Helper() var body struct { Data struct { ID uint `json:"id"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.ID == 0 { t.Fatalf("api answer %s: %v", rec.Body.String(), err) } return body.Data.ID } func storedPost(t *testing.T, env *adminEnv, id uint) models.Post { t.Helper() var p models.Post if err := env.gdb.First(&p, id).Error; err != nil { t.Fatal(err) } return p } const postH1Phrase = "1 H1 heading was converted to H2, because the page shows the post title as the H1." // TestHeadingConversion covers every other Journal rich-text write path // (D-50, CONTEXT 2026-10-28 H1 conversion scope): the API (and so MCP) // create and update with default and translated content, the markdown // path, journal:import-posts, a category's pillar text through the admin, // and the content_h1_to_h2 flag of GET /categories. With the setting off // nothing converts and no notice appears. func TestHeadingConversion(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts) posts := journalAPIPrefix + "/posts" t.Run("API create converts default and translated content", func(t *testing.T) { setH1ToH2(t, env, true) rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{ "title": "API heading", "content": `

T

`, "translations": map[string]any{"pl": map[string]any{"title": "Nagłówek API", "content": `

P

`}}, }) id := apiPostID(t, rec) p := storedPost(t, env, id) if p.Content != `

T

` || !strings.Contains(p.ContentHTML, `

T

`) || strings.Contains(p.ContentHTML, "P` || !strings.Contains(pl["content_html"].(string), `

P

`) { t.Fatalf("pl = %#v, want the H2", pl) } notices := apiNotices(t, rec) if len(notices) != 2 || notices[0]["message"] != postH1Phrase || notices[0]["count"] != float64(1) || notices[1]["locale"] != "pl" || notices[1]["count"] != float64(1) { t.Fatalf("notices = %#v, want the default and the pl phrase with count 1", notices) } }) t.Run("API update converts markdown; an update without content converts nothing", func(t *testing.T) { setH1ToH2(t, env, true) id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{ "title": "Markdown heading", "content": "Body", })) rec := env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{ "content": "# T\n\nText", }) p := storedPost(t, env, id) if p.Content != "# T\n\nText" || !strings.Contains(p.ContentHTML, "T`, }) p := storedPost(t, env, apiPostID(t, rec)) if p.Content != `

T

` || !strings.Contains(p.ContentHTML, `

T

`) { t.Fatalf("content = %q, content_html = %q, want the H1 kept", p.Content, p.ContentHTML) } if notices := apiNotices(t, rec); notices != nil { t.Fatalf("setting off: notices %#v", notices) } }) t.Run("journal:import-posts converts and prints the count", func(t *testing.T) { setH1ToH2(t, env, true) dir := t.TempDir() raw, err := json.Marshal(console.PostFile{ Slug: "imported-heading", Title: "Imported", Content: "

I

x

", Translations: map[string]map[string]any{"pl": {"title": "Import", "content": "

J

"}}, }) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "imported-heading.json"), raw, 0o644); err != nil { t.Fatal(err) } var buf strings.Builder out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, out); err != nil { t.Fatalf("import: %v (%s)", err, buf.String()) } if !strings.Contains(buf.String(), "Converted 2 H1 headings to H2.") { t.Fatalf("import output %q, want the converted count", buf.String()) } var p models.Post if err := env.gdb.Where("slug = ?", "imported-heading").Take(&p).Error; err != nil { t.Fatal(err) } if p.Content != "

I

x

" || strings.Contains(p.ContentHTML, "I") { t.Fatalf("imported content = %q, content_html = %q", p.Content, p.ContentHTML) } if pl := translatedAttrs(t, env, p.ID, "pl"); !strings.Contains(pl["content_html"].(string), "

J

") { t.Fatalf("imported pl = %#v", pl) } }) t.Run("markdown with a fenced h1 sample keeps its source", func(t *testing.T) { setH1ToH2(t, env, true) md := "# Sample\n\n```html\n

Hello

\n```\n\nInline `

` tag." id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "Markdown sample", "content": md})) p := storedPost(t, env, id) if p.Content != md || strings.Contains(p.ContentHTML, "C

x

", }) var cat models.Category if err := env.gdb.First(&cat, adminCreatedID(t, rec)).Error; err != nil { t.Fatal(err) } return cat.Content, adminNotices(t, rec) } setH1ToH2(t, env, true) content, notices := save(t, "pillar-on") if content == nil || *content != "

C

x

" { t.Fatalf("category content = %#v, want the H2", content) } if len(notices) != 1 || notices[0].Field != "content" || notices[0].Message != "1 H1 heading was converted to H2, because the page shows the category name as the H1." { t.Fatalf("category notices = %#v", notices) } setH1ToH2(t, env, false) t.Cleanup(func() { setH1ToH2(t, env, true) }) content, notices = save(t, "pillar-off") if content == nil || *content != "

C

x

" || notices != nil { t.Fatalf("setting off: content = %#v, notices = %#v", content, notices) } }) t.Run("GET /categories reports the setting on every row", func(t *testing.T) { for _, on := range []bool{true, false} { setH1ToH2(t, env, on) rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/categories?include_empty=1", "", nil) var body struct { Data []map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) == 0 { t.Fatalf("categories %s: %v", rec.Body.String(), err) } for _, row := range body.Data { if row["content_h1_to_h2"] != on { t.Fatalf("setting %v: row %#v", on, row) } } } setH1ToH2(t, env, true) }) } const h1Refusal = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings." // seedStoredH1 stores, with the setting off, a post whose default and // Polish content hold one H1 each and a category whose pillar text holds // one H1, and switches the setting back on. It returns the post and the // category id. func seedStoredH1(t *testing.T, env *adminEnv, slug string) (uint, uint) { t.Helper() setH1ToH2(t, env, false) token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories) post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", token, map[string]any{ "title": "Stored " + slug, "slug": slug, "content": "

A

x

", "translations": map[string]any{"pl": map[string]any{"title": "Zapisany " + slug, "content": "

B

"}}, })) cat := adminCreatedID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{ "name": map[string]any{"en": "Stored " + slug, "pl": "Zapisana " + slug}, "slug": map[string]any{"en": slug + "-cat", "pl": slug + "-kat"}, "content": "

C

", })) setH1ToH2(t, env, true) return post, cat } func categoryContent(t *testing.T, env *adminEnv, id uint) string { t.Helper() var c models.Category if err := env.gdb.First(&c, id).Error; err != nil { t.Fatal(err) } if c.Content == nil { return "" } return *c.Content } // TestFixHeadingsCommand: journal:fix-headings is a dry run by default, // --apply converts default and translated post content and category pillar // text and fires RecordSaved per changed record, a second run finds // nothing, and with the setting off it refuses with exit code 1 (D-50, // UI-SPEC C27 console copy, P33-54). func TestFixHeadingsCommand(t *testing.T) { env := newPostsEnv(t) post, cat := seedStoredH1(t, env, "fix-cli") run := func(t *testing.T, flags cmdInput) (string, error) { t.Helper() var buf strings.Builder out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) err := console.FixHeadings(env.app).Run(t.Context(), flags, out) return buf.String(), err } text, err := run(t, cmdInput{}) if err != nil || !strings.Contains(text, "Would convert 3 H1 headings in 1 posts and 1 categories. Run with --apply to save.") { t.Fatalf("dry run = %q, %v", text, err) } if p := storedPost(t, env, post); p.ContentHTML != "

A

x

" || categoryContent(t, env, cat) != "

C

" { t.Fatalf("dry run wrote: %q / %q", p.ContentHTML, categoryContent(t, env, cat)) } records := listenRecords(t, env) text, err = run(t, cmdInput{"apply": "true"}) if err != nil || !strings.Contains(text, "Converted 3 H1 headings in 1 posts and 1 categories.") { t.Fatalf("apply = %q, %v", text, err) } p := storedPost(t, env, post) if p.Content != "

A

x

" || p.ContentHTML != "

A

x

" { t.Fatalf("applied post = %q / %q", p.Content, p.ContentHTML) } if pl := translatedAttrs(t, env, post, "pl"); pl["content"] != "

B

" || strings.Contains(pl["content_html"].(string), "

old

` post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{ "title": "Images", "content": body, "translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content": `

`}}, })) path := journalAPIPrefix + "/images/alt" const tricky = `Lion's "mane" ` env.expect(t, http.StatusUnauthorized, http.MethodPost, path, "", map[string]any{"changes": []any{}}) env.expect(t, http.StatusForbidden, http.MethodPost, path, manager, map[string]any{"changes": []any{}}) decode := func(rec *httptest.ResponseRecorder) ([]map[string]any, any) { var out struct { Changes []map[string]any `json:"changes"` DryRun any `json:"dry_run"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("alt body %s: %v", rec.Body.String(), err) } return out.Changes, out.DryRun } changes, dry := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{ "dry_run": true, "changes": []any{map[string]any{"post_id": post, "src": a, "alt": tricky}}, })) if dry != true || len(changes) != 1 || changes[0]["old_alt"] != nil || changes[0]["alt"] != tricky || changes[0]["changed"] != true { t.Fatalf("dry run answer = %#v, %v", changes, dry) } if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "alt=\"Lion") { t.Fatalf("dry run wrote: %q", got) } rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{ "changes": []any{ map[string]any{"post_id": post, "src": a, "alt": "kept out"}, map[string]any{"post_id": post, "src": "https://cdn.example.test/missing.jpg", "alt": "x"}, }, }) if !strings.Contains(rec.Body.String(), "changes.1.src") { t.Fatalf("missing src answer %s", rec.Body.String()) } if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "kept out") { t.Fatalf("refused batch wrote: %q", got) } records := listenRecords(t, env) changes, dry = decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{ "changes": []any{ map[string]any{"post_id": post, "src": a, "alt": tricky}, map[string]any{"post_id": post, "src": b, "alt": ""}, map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"}, }, })) if dry != false || len(changes) != 3 || changes[1]["old_alt"] != "old" || changes[2]["locale"] != "pl" { t.Fatalf("applied answer = %#v, %v", changes, dry) } p := storedPost(t, env, post) for _, html := range []string{p.ContentHTML, p.Content} { if !strings.Contains(html, `alt="Lion's "mane" <b>"`) || strings.Contains(html, "") || !strings.Contains(html, `alt=""`) { t.Fatalf("applied default = %q", html) } } pl := translatedAttrs(t, env, post, "pl") if !strings.Contains(pl["content_html"].(string), `alt="Lew"`) { t.Fatalf("applied pl = %#v", pl) } found := false for _, ev := range records.savedEvents() { if ev.ev.Model == p.MorphName() { _, def := findChange(ev.ev.Changes, "content_html", "") _, tr := findChange(ev.ev.Changes, "content_html", "pl") found = def && tr } } if !found { t.Fatalf("no RecordSaved with the default and pl content_html: %#v", records.savedEvents()) } } // TestBlogEditorRequestOptions (J1, UI-SPEC C28): the post and category // editor fields serve headingLevels and genericAnchors per form schema // request, following the H1 setting and the settings' generic link texts. func TestBlogEditorRequestOptions(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories) options := func(t *testing.T, path string) map[string]any { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil) var body struct { Data struct { Fields []map[string]any `json:"fields"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("schema %s: %v", rec.Body.String(), err) } for _, f := range body.Data.Fields { if f["name"] == "content" { opts, _ := f["typeOptions"].(map[string]any) return opts } } t.Fatalf("schema has no content field: %s", rec.Body.String()) return nil } for _, path := range []string{adminPostsPath, adminCategoriesPath} { setH1ToH2(t, env, true) opts := options(t, path) anchors, _ := opts["genericAnchors"].([]any) if fmt.Sprint(opts["headingLevels"]) != "[2 3 4]" || len(anchors) == 0 || anchors[0] != "click here" { t.Fatalf("%s options with the setting on = %v", path, opts) } if opts["uploadFolder"] == nil { t.Fatalf("%s lost its fields.yaml options: %v", path, opts) } setH1ToH2(t, env, false) if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = ? WHERE id = 1`, "Tutaj\n\nmore info").Error; err != nil { t.Fatal(err) } opts = options(t, path) if fmt.Sprint(opts["headingLevels"]) != "[1 2 3 4]" || fmt.Sprint(opts["genericAnchors"]) != "[tutaj more info]" { t.Fatalf("%s options with the setting off = %v", path, opts) } if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = NULL WHERE id = 1`).Error; err != nil { t.Fatal(err) } } setH1ToH2(t, env, true) }