Files
sm-journal-plugin/headings_test.go
Jakub Zych 7662d54ab3 fix: image alt texts on markdown posts are written into the markdown
POST /_journal/api/v1/images/alt writes the alt of a markdown post into
its source: the inline image ![alt](src) outside code
(classes.SetMarkdownImageAlt) or a raw img outside code
(classes.SetImageAltOutsideCode), so the next content save keeps it. An
image the markdown does not hold that way is refused with 422
changes.N.src and nothing is written.
2026-11-04 23:20:02 +01:00

747 lines
31 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("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)
}