Files
sm-journal-plugin/headings_test.go
Jakub Zych a41d537e52 fix: H1 conversion leaves code alone in markdown that starts with HTML
ConvertContentH1 reads content as markdown on every path and converts it
outside goldmark's code ranges only; a body opening with an HTML block
keeps the h1 tags of its code samples, a raw h1 outside code is converted
as in v0.3.1 (the 33-15 test pin for that case is reversed).

quick-261104-xae, review WR-01.
2026-11-05 00:17:53 +01:00

776 lines
33 KiB
Go

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 = "<h1>A</h1><p>x</p>"
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", "<h2>A</h2><p>x</p>", "<h2>A</h2><p>x</p>")
pl := translatedAttrs(t, env, id, "pl")
if pl["content"] != "<h2>A</h2><p>x</p>" || pl["content_html"] != "<h2>A</h2><p>x</p>" {
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": `<h1 class="x">T</h1>`,
"translations": map[string]any{"pl": map[string]any{"title": "Nagłówek API", "content": `<h1 class="x">P</h1>`}},
})
id := apiPostID(t, rec)
p := storedPost(t, env, id)
if p.Content != `<h2 class="x">T</h2>` || !strings.Contains(p.ContentHTML, `<h2 class="x">T</h2>`) || strings.Contains(p.ContentHTML, "<h1") {
t.Fatalf("content = %q, content_html = %q, want the H2 with its class", p.Content, p.ContentHTML)
}
pl := translatedAttrs(t, env, id, "pl")
if pl["content"] != `<h2 class="x">P</h2>` || !strings.Contains(pl["content_html"].(string), `<h2 class="x">P</h2>`) {
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, "<h2") || strings.Contains(p.ContentHTML, "<h1") {
t.Fatalf("content = %q, content_html = %q, want markdown kept and an H2", p.Content, p.ContentHTML)
}
if notices := apiNotices(t, rec); len(notices) != 1 || notices[0]["message"] != postH1Phrase {
t.Fatalf("notices = %#v", notices)
}
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET content_html = '<h1>Old</h1>' WHERE id = ?`, id).Error; err != nil {
t.Fatal(err)
}
rec = env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{"title": "Renamed"})
if notices := apiNotices(t, rec); notices != nil {
t.Fatalf("title-only update notices = %#v", notices)
}
if got := storedPost(t, env, id).ContentHTML; got != "<h1>Old</h1>" {
t.Fatalf("title-only update rewrote content_html: %q", got)
}
})
t.Run("API with the setting off stores H1", func(t *testing.T) {
setH1ToH2(t, env, false)
t.Cleanup(func() { setH1ToH2(t, env, true) })
rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
"title": "Kept heading", "content": `<h1 class="x">T</h1>`,
})
p := storedPost(t, env, apiPostID(t, rec))
if p.Content != `<h1 class="x">T</h1>` || !strings.Contains(p.ContentHTML, `<h1 class="x">T</h1>`) {
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: "<h1>I</h1><p>x</p>",
Translations: map[string]map[string]any{"pl": {"title": "Import", "content": "<h1>J</h1>"}},
})
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 != "<h2>I</h2><p>x</p>" || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h2>I</h2>") {
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), "<h2>J</h2>") {
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<h1>Hello</h1>\n```\n\nInline `<h1>` 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, "<h1") || !strings.Contains(p.ContentHTML, "&lt;h1&gt;Hello&lt;/h1&gt;") {
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
}
dir := t.TempDir()
raw, err := json.Marshal(console.PostFile{Slug: "imported-markdown", Title: "Imported markdown", Content: md})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "imported-markdown.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
var buf strings.Builder
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
t.Fatalf("import: %v (%s)", err, buf.String())
}
var imported models.Post
if err := env.gdb.Where("slug = ?", "imported-markdown").Take(&imported).Error; err != nil {
t.Fatal(err)
}
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") || !strings.Contains(imported.ContentHTML, "<h2") {
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
}
})
t.Run("markdown that starts with an HTML block keeps its h1 samples", func(t *testing.T) {
setH1ToH2(t, env, true)
md := "<p class=\"lead\">Intro</p>\n\n# Title\n\n```html\n<h1>Hello</h1>\n```"
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "HTML lead", "content": md}))
p := storedPost(t, env, id)
if p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "&lt;h1&gt;Hello&lt;/h1&gt;") || !strings.Contains(p.ContentHTML, "<h2") {
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
}
dir := t.TempDir()
raw, err := json.Marshal(console.PostFile{Slug: "imported-html-lead", Title: "Imported HTML lead", Content: md})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "imported-html-lead.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
var buf strings.Builder
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
t.Fatalf("import: %v (%s)", err, buf.String())
}
var imported models.Post
if err := env.gdb.Where("slug = ?", "imported-html-lead").Take(&imported).Error; err != nil {
t.Fatal(err)
}
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") {
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
}
})
t.Run("category pillar text through the admin", func(t *testing.T) {
catToken := env.admin(t, controllers.PermissionAccessCategories)
save := func(t *testing.T, slug string) (*string, []adminNotice) {
t.Helper()
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), catToken, map[string]any{
"name": map[string]any{"en": "Pillar " + slug, "pl": "Filar " + slug},
"slug": map[string]any{"en": slug, "pl": slug + "-pl"},
"content": "<h1>C</h1><p>x</p>",
})
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 != "<h2>C</h2><p>x</p>" {
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 != "<h1>C</h1><p>x</p>" || 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": "<h1>A</h1><p>x</p>",
"translations": map[string]any{"pl": map[string]any{"title": "Zapisany " + slug, "content": "<h1>B</h1>"}},
}))
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": "<h1>C</h1>",
}))
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 != "<h1>A</h1><p>x</p>" || categoryContent(t, env, cat) != "<h1>C</h1>" {
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 != "<h2>A</h2><p>x</p>" || p.ContentHTML != "<h2>A</h2><p>x</p>" {
t.Fatalf("applied post = %q / %q", p.Content, p.ContentHTML)
}
if pl := translatedAttrs(t, env, post, "pl"); pl["content"] != "<h2>B</h2>" || strings.Contains(pl["content_html"].(string), "<h1") {
t.Fatalf("applied pl = %#v", pl)
}
if got := categoryContent(t, env, cat); got != "<h2>C</h2>" {
t.Fatalf("applied category = %q", got)
}
seen := map[string]bool{}
for _, ev := range records.savedEvents() {
seen[ev.ev.Model] = true
if ev.ev.Model == p.MorphName() {
if c, ok := findChange(ev.ev.Changes, "content_html", "pl"); !ok || c.New == nil {
t.Fatalf("post event without the pl content_html change: %#v", ev.ev.Changes)
}
}
}
if !seen[p.MorphName()] || !seen[models.Category{}.MorphName()] {
t.Fatalf("RecordSaved models = %v, want the post and the category", seen)
}
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "No H1 headings found in post or category content.") {
t.Fatalf("second run = %q, %v", text, err)
}
// L1 CR-02: a markdown post with a fenced h1 sample keeps its source;
// only its stored content_html is converted.
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n"
rendered, err := classes.FormatHTML(md)
if err != nil {
t.Fatal(err)
}
mdPost := models.Post{Title: "Markdown sample", Slug: "fix-cli-markdown", Content: md, ContentHTML: rendered}
if err := env.gdb.Create(&mdPost).Error; err != nil {
t.Fatal(err)
}
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "Converted 1 H1 headings in 1 posts and 0 categories.") {
t.Fatalf("markdown run = %q, %v", text, err)
}
if p := storedPost(t, env, mdPost.ID); p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "&lt;h1&gt;Hello&lt;/h1&gt;") {
t.Fatalf("markdown post after --apply = %q / %q", p.Content, p.ContentHTML)
}
setH1ToH2(t, env, false)
t.Cleanup(func() { setH1ToH2(t, env, true) })
text, err = run(t, cmdInput{"apply": "true"})
if err == nil || !errors.Is(err, bonfire.ErrReported) || !strings.Contains(text, h1Refusal) {
t.Fatalf("setting off = %q, %v; want the refusal and exit code 1", text, err)
}
}
// TestJournalSettingsTools: GET and PUT /_journal/api/v1/settings and POST
// /fix-headings, as the MCP tools call them, with
// golem15.journal.manage_settings, and 401/403 without (T-33-05).
func TestJournalSettingsTools(t *testing.T) {
env := newPostsEnv(t)
settings := journalAPIPrefix + "/settings"
manager := env.admin(t, controllers.PermissionManageSettings)
editor := env.admin(t, controllers.PermissionAccessPosts)
env.expect(t, http.StatusUnauthorized, http.MethodGet, settings, "", nil)
env.expect(t, http.StatusForbidden, http.MethodGet, settings, editor, nil)
env.expect(t, http.StatusForbidden, http.MethodPut, settings, editor, map[string]any{"content_h1_to_h2": false})
env.expect(t, http.StatusForbidden, http.MethodPost, journalAPIPrefix+"/fix-headings", editor, map[string]any{})
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
t.Fatalf("settings body %s: %v", rec.Body.String(), err)
}
return body.Data
}
got := decode(t, env.expect(t, http.StatusOK, http.MethodGet, settings, manager, nil))
if got["content_h1_to_h2"] != true || got["generic_anchors"] != nil || got["rss_enabled"] != true || got["rss_posts_per_feed"] != float64(20) {
t.Fatalf("settings = %#v", got)
}
got = decode(t, env.expect(t, http.StatusOK, http.MethodPut, settings, manager, map[string]any{
"content_h1_to_h2": false, "generic_anchors": "click here\nhere",
}))
if got["content_h1_to_h2"] != false || got["generic_anchors"] != "click here\nhere" || got["rss_enabled"] != true {
t.Fatalf("updated settings = %#v", got)
}
var row models.Settings
if err := env.gdb.First(&row, 1).Error; err != nil {
t.Fatal(err)
}
if row.ContentH1ToH2 || row.GenericAnchors == nil || *row.GenericAnchors != "click here\nhere" {
t.Fatalf("stored settings = %+v", row)
}
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{"rss_posts_per_feed": 500})
if !strings.Contains(rec.Body.String(), "rss_posts_per_feed") {
t.Fatalf("invalid settings answer %s", rec.Body.String())
}
for key, bad := range map[string]any{"rss_image_url": "not a url", "generic_anchors": 5} {
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{key: bad})
if !strings.Contains(rec.Body.String(), key) {
t.Fatalf("invalid %s answer %s", key, rec.Body.String())
}
}
post, _ := seedStoredH1(t, env, "fix-api")
fix := journalAPIPrefix + "/fix-headings"
setH1ToH2(t, env, false)
rec = env.expect(t, http.StatusConflict, http.MethodPost, fix, manager, map[string]any{})
if !strings.Contains(rec.Body.String(), "H1 to H2 conversion is turned off") {
t.Fatalf("refusal %s", rec.Body.String())
}
setH1ToH2(t, env, true)
answer := func(rec *httptest.ResponseRecorder) map[string]any {
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("fix body %s: %v", rec.Body.String(), err)
}
return body
}
dry := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{}))
if dry["dry_run"] != true || dry["posts"] != float64(1) || dry["categories"] != float64(1) || dry["headings"] != float64(3) {
t.Fatalf("dry run answer = %#v", dry)
}
if storedPost(t, env, post).ContentHTML != "<h1>A</h1><p>x</p>" {
t.Fatal("dry run wrote the post")
}
applied := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{"dry_run": false}))
if applied["dry_run"] != false || applied["headings"] != float64(3) {
t.Fatalf("applied answer = %#v", applied)
}
if got := storedPost(t, env, post).ContentHTML; got != "<h2>A</h2><p>x</p>" {
t.Fatalf("applied post content_html = %q", got)
}
}
// TestSetImageAlt: POST /_journal/api/v1/images/alt sets alt on the
// matching img of a post's content_html (default or translated), answers
// the per-change diff, writes nothing on a dry run, refuses the whole batch
// with 422 when one src is not in the post, escapes the alt through the
// tokenizer (T-33-04) and needs golem15.journal.access_posts (D-85, P33-05).
func TestSetImageAlt(t *testing.T) {
env := newPostsEnv(t)
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
manager := env.admin(t, controllers.PermissionManageSettings)
const a, b = "https://cdn.example.test/a.jpg", "https://cdn.example.test/b.jpg"
body := `<p><img src="` + a + `"></p><p><img src="` + b + `" alt="old"></p>`
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": `<p><img src="` + a + `"></p>`}},
}))
path := journalAPIPrefix + "/images/alt"
const tricky = `Lion's "mane" <b>`
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&#39;s &#34;mane&#34; &lt;b&gt;"`) || strings.Contains(html, "<b>") || !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())
}
// L1 WR-05: on a markdown post the alt lands in the markdown, outside
// code, and survives the next save of the same content.
sample := "```\n![](" + a + ")\n```\n"
md := "Intro\n\n![](" + a + ")\n\n" + sample
mdPost := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Markdown images", "content": md}))
env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
"changes": []any{map[string]any{"post_id": mdPost, "src": a, "alt": "Grzyb [leśny]"}},
})
stored := storedPost(t, env, mdPost)
if stored.Content != "Intro\n\n![Grzyb \\[leśny\\]]("+a+")\n\n"+sample || !strings.Contains(stored.ContentHTML, `alt="Grzyb [leśny]"`) {
t.Fatalf("markdown post after the alt = %q / %q", stored.Content, stored.ContentHTML)
}
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/posts/%d", journalAPIPrefix, mdPost), editor, map[string]any{"content": stored.Content})
if again := storedPost(t, env, mdPost); !strings.Contains(again.ContentHTML, `alt="Grzyb [leśny]"`) {
t.Fatalf("the next content save lost the alt: %q", again.ContentHTML)
}
// A reference-style image is not rewritten: 422, nothing written.
ref := "![old][r]\n\n[r]: " + a + "\n"
refPost := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Reference image", "content": ref}))
before := storedPost(t, env, refPost)
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
"changes": []any{map[string]any{"post_id": refPost, "src": a, "alt": "new"}},
})
if !strings.Contains(rec.Body.String(), "changes.0.src") || !strings.Contains(rec.Body.String(), "not an inline image") {
t.Fatalf("reference-style answer %s", rec.Body.String())
}
if after := storedPost(t, env, refPost); after.Content != before.Content || after.ContentHTML != before.ContentHTML {
t.Fatalf("a refused markdown change wrote: %q / %q", after.Content, after.ContentHTML)
}
}
// 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)
}