- the /_journal/api/v1 post writes convert default and translated content in the write transaction and answer an additive notices key - journal:import-posts converts and prints the converted count (ImportFromDirConfig, ImportStats.H1Converted) - a category's pillar text saved in the admin answers editor.h1_converted_category - every GET /categories row reports content_h1_to_h2 (additive key)
344 lines
13 KiB
Go
344 lines
13 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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("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)
|
|
})
|
|
}
|