Files
sm-journal-plugin/controllers/posts_editor_smoke_test.go
Jakub Zych 159323197d fix(16.2): CR-01 serve content_html when a locale's content is blank
A translation that carries only content_html (an API write through
FormatTranslation) opened as an empty editor, and the first edit in
that locale silently replaced the translated body. Load now serves the
htmlColumn attribute whenever the stored content is blank and that
attribute is not empty; an untouched save echoes it and is skipped, an
edit writes the edited HTML to both columns.
2026-10-10 16:33:08 +02:00

593 lines
23 KiB
Go

package controllers
import (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// recordingWriter is a cabana.TranslationWriter that records writes.
type recordingWriter struct {
writes []recordedWrite
}
type recordedWrite struct {
field, locale, value string
}
func (*recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "pl", nil }
func (*recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
return []string{"pl", "en"}, nil
}
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, _ any, field, locale, value string) error {
w.writes = append(w.writes, recordedWrite{field: field, locale: locale, value: value})
return nil
}
func (*recordingWriter) TranslatedExact(context.Context, *gorm.DB, any, string, string) (string, bool, error) {
return "", false, nil
}
// blogType returns the named Blog Editor field type.
func blogType(t *testing.T, name string) pact.FormFieldType {
t.Helper()
for _, ft := range classes.BlogEditorTypes(nil) {
if ft.Name == name {
return ft
}
}
t.Fatalf("no field type %s", name)
return pact.FormFieldType{}
}
// formField returns a compiled form field by name.
func formField(t *testing.T, schema *cabana.FormSchema, name string) cabana.FormField {
t.Helper()
for _, f := range schema.Fields {
if f.Name == name {
return f
}
}
t.Fatalf("form has no field %s", name)
return cabana.FormField{}
}
// editorPrincipal is a backend administrator who may edit and publish any
// post.
func editorPrincipal() *bouncer.Principal {
return &bouncer.Principal{ID: 7, Backend: true, IsSuperuser: true}
}
// TestBlogEditorTracerSmoke is the Phase 16.2 plan 03 tracer: post content
// compiles as mlblogeditor, Decode stores the policy's output with notices
// naming what it removed, Load serves HTML and markdown posts per D-07, and
// the Form hooks write the written content to content_html per locale
// without goldmark.
func TestBlogEditorTracerSmoke(t *testing.T) {
ml := blogType(t, "mlblogeditor")
t.Run("posts form compiles with mlblogeditor", func(t *testing.T) {
schema, err := cabana.CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
if err != nil {
t.Fatal(err)
}
content := formField(t, schema, "content")
if content.Type != "mlblogeditor" || content.Element != classes.BlogEditorElement || !content.Translatable {
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
}
if content.TypeOptions["htmlColumn"] != "content_html" || content.TypeOptions["uploadFolder"] != "posts" {
t.Fatalf("content typeOptions = %#v", content.TypeOptions)
}
if excerpt := formField(t, schema, "excerpt"); excerpt.Type != "mltextarea" {
t.Fatalf("excerpt type = %q, want mltextarea", excerpt.Type)
}
})
t.Run("type definitions", func(t *testing.T) {
if ml.Element != "golem15-journal-blogeditor" || len(ml.Scripts) != 1 || ml.Scripts[0] != "assets/dist/blogeditor.js" ||
len(ml.Styles) != 1 || ml.Styles[0] != "assets/dist/blogeditor.css" {
t.Fatalf("mlblogeditor = %+v", ml)
}
for _, asset := range append(append([]string{}, ml.Scripts...), ml.Styles...) {
if _, err := os.Stat("../" + asset); err != nil {
t.Fatalf("asset %s: %v", asset, err)
}
}
plain := blogType(t, "blogeditor")
if plain.Translatable || len(plain.Options) != 1 || plain.Options[0] != "uploadFolder" {
t.Fatalf("blogeditor = %+v", plain)
}
for _, bad := range []map[string]any{
{"htmlColumn": "content html"},
{"htmlColumn": 3},
{"uploadFolder": "../up"},
{"uploadFolder": "a b"},
{"uploadFolder": strings.Repeat("a", 101)},
} {
if err := ml.CheckOptions(bad); err == nil {
t.Fatalf("CheckOptions(%v) = nil, want an error", bad)
}
}
if err := ml.CheckOptions(map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts/2026"}); err != nil {
t.Fatal(err)
}
})
decode := func(t *testing.T, value any) (pact.FormFieldDecoded, error) {
t.Helper()
return ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value})
}
t.Run("decode keeps clean HTML byte for byte", func(t *testing.T) {
const clean = "<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> &amp; <strong>pogrubieniem</strong>.</p>\n"
got, err := decode(t, clean)
if err != nil {
t.Fatal(err)
}
if got.Value != clean || len(got.Notices) != 0 {
t.Fatalf("Decode(clean) = %#v", got)
}
})
t.Run("decode keeps the embed placeholder", func(t *testing.T) {
const placeholder = `<p>Film:</p><figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>`
got, err := decode(t, placeholder)
if err != nil {
t.Fatal(err)
}
if got.Value != placeholder || len(got.Notices) != 0 {
t.Fatalf("Decode(placeholder) = %#v", got)
}
})
t.Run("decode strips and reports", func(t *testing.T) {
got, err := decode(t, `<p>a<script>alert(1)</script></p><img src="https://example.test/x.png" alt="x" onerror="alert(1)">`)
if err != nil {
t.Fatal(err)
}
if got.Value != `<p>a</p><img src="https://example.test/x.png" alt="x">` {
t.Fatalf("Decode value = %q", got.Value)
}
if len(got.Notices) != 2 {
t.Fatalf("notices = %#v, want two", got.Notices)
}
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_elements" || n.Count != 1 || n.Params["names"] != "script" {
t.Fatalf("first notice = %#v", n)
}
if n := got.Notices[1]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onerror" {
t.Fatalf("second notice = %#v", n)
}
})
t.Run("decode nil and non-string", func(t *testing.T) {
got, err := decode(t, nil)
if err != nil || got.Value != "" || len(got.Notices) != 0 {
t.Fatalf("Decode(nil) = %#v, %v", got, err)
}
_, err = decode(t, 42.0)
var refused *pact.FormFieldError
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != "golem15.journal::lang.editor.content_invalid" {
t.Fatalf("Decode(42) error = %v, want a FormFieldError", err)
}
})
load := func(t *testing.T, value any, translated func(string) (string, bool, error)) any {
t.Helper()
got, err := ml.Load(context.Background(), pact.FormFieldValue{
Field: "content",
Options: map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"},
Locale: "en",
Value: value,
Translated: translated,
})
if err != nil {
t.Fatal(err)
}
return got
}
noRead := func(string) (string, bool, error) {
t.Fatal("Translated read for an HTML value")
return "", false, nil
}
t.Run("load serves HTML content as it is", func(t *testing.T) {
const html = " <!-- lead -->\n<p>Stored <em>HTML</em></p>"
if got := load(t, html, noRead); got != html {
t.Fatalf("Load(HTML) = %q", got)
}
})
t.Run("load serves a markdown post's content_html", func(t *testing.T) {
got := load(t, "Hello **world**", func(attribute string) (string, bool, error) {
if attribute != "content_html" {
t.Fatalf("Translated(%q), want content_html", attribute)
}
return "<p>Stored render</p>", true, nil
})
if got != "<p>Stored render</p>" {
t.Fatalf("Load(markdown) = %q", got)
}
})
t.Run("load renders markdown when content_html is empty", func(t *testing.T) {
got := load(t, "Hello **world**", func(string) (string, bool, error) { return "", true, nil })
if got != "<p>Hello <strong>world</strong></p>\n" {
t.Fatalf("Load(markdown, empty content_html) = %q", got)
}
if got := load(t, "Hello **world**", nil); got != "<p>Hello <strong>world</strong></p>\n" {
t.Fatalf("Load(markdown, no reader) = %q", got)
}
})
// CR-01: a translation that carries only content_html opens with that
// body. An untouched save echoes the served value, which cabana skips;
// an edit writes the edited HTML to both content and content_html.
t.Run("blank content opens with content_html and an edit writes both columns", func(t *testing.T) {
const apiBody = "<h2>Translated</h2>\n<p>Written through the API.</p>\n"
served := load(t, "", func(attribute string) (string, bool, error) {
if attribute != "content_html" {
t.Fatalf("Translated(%q), want content_html", attribute)
}
return apiBody, true, nil
})
if served != apiBody {
t.Fatalf("Load(blank content) = %q, want the stored content_html", served)
}
const edited = "<h2>Translated</h2>\n<p>Edited in the admin.</p>\n"
decoded, err := ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "en", Value: edited})
if err != nil || decoded.Value != edited {
t.Fatalf("Decode(edited) = %#v, %v", decoded, err)
}
writer := &recordingWriter{}
app := backpack.New(nil)
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
t.Fatal(err)
}
ctl := postsAdminController{app: func() *backpack.App { return app }}
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{
"content": {{Locale: "en", Value: decoded.Value}},
})
post := &models.Post{Content: "<p>Host</p>", ContentHTML: "<p>Host</p>"}
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
t.Fatal(err)
}
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: edited}) {
t.Fatalf("translation writes = %#v, want en content_html = the edited value", writer.writes)
}
})
t.Run("list value", func(t *testing.T) {
got := ml.ListValue(context.Background(), "<h2>Title</h2><p>Some <strong>bold</strong>\n text</p><style>p{}</style>")
if got != "Title Some bold text" {
t.Fatalf("ListValue = %q", got)
}
long := ml.ListValue(context.Background(), "<p>"+strings.Repeat("ż", 130)+"</p>")
if []rune(long)[120] != '…' || len([]rune(long)) != 121 {
t.Fatalf("ListValue(long) = %q", long)
}
})
t.Run("hooks write content_html per written locale", func(t *testing.T) {
writer := &recordingWriter{}
app := backpack.New(nil)
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
t.Fatal(err)
}
ctl := postsAdminController{app: func() *backpack.App { return app }}
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{
"content": {
{Locale: "pl", Default: true, Value: "<p>A</p>"},
{Locale: "en", Value: "<p>B</p>"},
},
})
post := &models.Post{Content: "<p>A</p>", ContentHTML: "old"}
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
t.Fatal(err)
}
if post.ContentHTML != "<p>A</p>" {
t.Fatalf("ContentHTML = %q, want <p>A</p>", post.ContentHTML)
}
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
t.Fatal(err)
}
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: "<p>B</p>"}) {
t.Fatalf("translation writes = %#v", writer.writes)
}
if err := ctl.FormAfterCreate(ctx, post); err != nil {
t.Fatal(err)
}
if len(writer.writes) != 2 {
t.Fatalf("FormAfterCreate writes = %#v", writer.writes)
}
})
t.Run("hooks leave untouched content alone", func(t *testing.T) {
writer := &recordingWriter{}
app := backpack.New(nil)
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
t.Fatal(err)
}
ctl := postsAdminController{app: func() *backpack.App { return app }}
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
const markdown = "Hello **world**"
post := &models.Post{Content: markdown, ContentHTML: "<p>kept</p>"}
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
t.Fatal(err)
}
if post.ContentHTML != "<p>kept</p>" {
t.Fatalf("ContentHTML = %q, want it unchanged (no goldmark)", post.ContentHTML)
}
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"title": {{Locale: "en", Value: "x"}}})
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
t.Fatal(err)
}
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
t.Fatal(err)
}
if post.ContentHTML != "<p>kept</p>" || len(writer.writes) != 0 {
t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes)
}
})
t.Run("notice phrases have plural forms", func(t *testing.T) {
fsys := fstest.MapFS{}
for _, locale := range []string{"en", "pl"} {
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
if err != nil {
t.Fatal(err)
}
fsys["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: raw}
}
cat := phrasebook.NewCatalog()
if err := cat.Load("golem15.journal", fsys); err != nil {
t.Fatal(err)
}
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
names := map[string]string{"names": "script, iframe"}
for _, tc := range []struct {
locale, key string
count int
want string
}{
{"en", "removed_elements", 1, "1 element was removed: script, iframe"},
{"en", "removed_elements", 2, "2 elements were removed: script, iframe"},
{"en", "removed_attributes", 3, "3 attributes were removed: script, iframe"},
{"en", "removed_comments", 1, "1 HTML comment was removed"},
{"pl", "removed_elements", 1, "Usunięto 1 element: script, iframe"},
{"pl", "removed_elements", 3, "Usunięto 3 elementy: script, iframe"},
{"pl", "removed_attributes", 5, "Usunięto 5 atrybutów: script, iframe"},
{"pl", "removed_comments", 2, "Usunięto 2 komentarze HTML"},
} {
if got := tr.ChoiceIn(tc.locale, "golem15.journal::lang.editor."+tc.key, tc.count, names); got != tc.want {
t.Fatalf("%s %s %d = %q, want %q", tc.locale, tc.key, tc.count, got, tc.want)
}
}
if got := tr.GetIn("pl", "golem15.journal::lang.editor.content_invalid", nil); got != "Treść musi być tekstem HTML." {
t.Fatalf("pl content_invalid = %q", got)
}
})
}
// pngBytes, jpegBytes and htmlBytes are file heads http.DetectContentType
// recognizes.
var (
pngBytes = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
jpegBytes = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...)
gifBytes = append([]byte("GIF89a"), make([]byte, 64)...)
webpBytes = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...)
htmlBytes = []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
svgBytes = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
)
// TestBlogEditorUploadSmoke covers plan 03 Task 2: the media storage shared
// by the public upload route and the editor Upload, the editor Upload's
// magic-byte check and safe names, and category content on blogeditor.
func TestBlogEditorUploadSmoke(t *testing.T) {
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
app := backpack.New(nil)
if err := app.Publish(bucket); err != nil {
t.Fatal(err)
}
ctx := context.Background()
stored := func(t *testing.T, key string) []byte {
t.Helper()
data, err := bucket.ReadAll(ctx, key)
if err != nil {
t.Fatalf("read %s: %v", key, err)
}
return data
}
mediaError := func(t *testing.T, err error, message string) {
t.Helper()
var refused *classes.MediaError
if !errors.As(err, &refused) || refused.Field != "file" || refused.Message != message {
t.Fatalf("error = %v, want MediaError on file %q", err, message)
}
}
t.Run("StoreMedia keeps the public route's layout", func(t *testing.T) {
got, err := classes.StoreMedia(ctx, bucket, "photos", "cover.png", bytes.NewReader(pngBytes))
if err != nil {
t.Fatal(err)
}
if got.Path != "/journal/photos/cover.png" || got.URL != attach.PublicURL("journal/photos/cover.png") {
t.Fatalf("StoreMedia = %+v", got)
}
if !bytes.Equal(stored(t, "journal/photos/cover.png"), pngBytes) {
t.Fatal("stored bytes differ")
}
again, err := classes.StoreMedia(ctx, bucket, "/journal/photos/", `C:\up\cover.png`, bytes.NewReader(pngBytes))
if err != nil {
t.Fatal(err)
}
if again.Path != "/journal/photos/cover-1.png" {
t.Fatalf("second StoreMedia path = %q, want the unique suffix", again.Path)
}
})
t.Run("StoreMedia refuses other extensions and large files", func(t *testing.T) {
_, err := classes.StoreMedia(ctx, bucket, "", "logo.svg", bytes.NewReader(svgBytes))
mediaError(t, err, "The file must be an image.")
big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...)
_, err = classes.StoreMedia(ctx, bucket, "", "big.png", bytes.NewReader(big))
mediaError(t, err, "The file may not be greater than 10240 kilobytes.")
if exists, _ := bucket.Exists(ctx, "journal/big.png"); exists {
t.Fatal("a refused file was stored")
}
if _, err := classes.StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(pngBytes)); err == nil {
t.Fatal("StoreMedia without a bucket = nil error")
} else if errors.As(err, new(*classes.MediaError)) {
t.Fatalf("StoreMedia without a bucket = %v, want an opaque error", err)
}
})
types := classes.BlogEditorTypes(func() *backpack.App { return app })
for _, ft := range types {
if ft.Upload == nil {
t.Fatalf("%s has no Upload", ft.Name)
}
}
upload := func(ft pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) {
return ft.Upload(ctx, pact.FormFieldUpload{Field: "content", Options: options, Filename: name, Body: bytes.NewReader(body)})
}
refused := func(t *testing.T, err error, message string) {
t.Helper()
var fe *pact.FormFieldError
if !errors.As(err, &fe) || len(fe.Messages) != 1 || fe.Messages[0] != message {
t.Fatalf("error = %v, want FormFieldError %q", err, message)
}
}
posts := map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}
t.Run("editor upload stores a sniffed image under a safe name", func(t *testing.T) {
got, err := upload(types[1], posts, "Zdjęcie 1.PNG", pngBytes)
if err != nil {
t.Fatal(err)
}
if got.URL != attach.PublicURL("journal/posts/zdj-cie-1.png") {
t.Fatalf("URL = %q", got.URL)
}
if !bytes.Equal(stored(t, "journal/posts/zdj-cie-1.png"), pngBytes) {
t.Fatal("stored bytes differ from the upload")
}
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {
if _, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, name, body); err != nil {
t.Fatalf("upload %s: %v", name, err)
}
}
got, err = upload(types[0], nil, "...", pngBytes)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got.URL, attach.PublicURL("journal/editor/")) {
t.Fatalf("default folder URL = %q, want journal/editor/", got.URL)
}
})
t.Run("editor upload refuses what does not sniff as its image type", func(t *testing.T) {
const invalid = "golem15.journal::lang.editor.image_invalid"
_, err := upload(types[1], posts, "x.png", htmlBytes)
refused(t, err, invalid)
_, err = upload(types[1], posts, "logo.svg", svgBytes)
refused(t, err, invalid)
_, err = upload(types[1], posts, "photo.jpg", pngBytes)
refused(t, err, invalid)
_, err = upload(types[1], posts, "photo.svg", pngBytes)
refused(t, err, "The file must be an image.")
big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...)
_, err = upload(types[1], posts, "big.png", big)
refused(t, err, "The file may not be greater than 10240 kilobytes.")
})
t.Run("editor upload without a bucket is an opaque error", func(t *testing.T) {
bare := classes.BlogEditorTypes(func() *backpack.App { return backpack.New(nil) })
_, err := bare[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Options: posts, Filename: "a.png", Body: bytes.NewReader(pngBytes)})
if err == nil || errors.As(err, new(*pact.FormFieldError)) {
t.Fatalf("error = %v, want an opaque error", err)
}
nilApp := classes.BlogEditorTypes(nil)
if _, err := nilApp[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Filename: "a.png", Body: bytes.NewReader(pngBytes)}); err == nil {
t.Fatal("upload without an app = nil error")
}
})
t.Run("category content compiles as blogeditor", func(t *testing.T) {
schema, err := cabana.CompileFormWithTypes("golem15.journal", categoriesAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
if err != nil {
t.Fatal(err)
}
content := formField(t, schema, "content")
if content.Type != "blogeditor" || content.Element != classes.BlogEditorElement || content.Translatable {
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
}
if content.TypeOptions["uploadFolder"] != "categories" || content.Size != "huge" {
t.Fatalf("content typeOptions = %#v size %q", content.TypeOptions, content.Size)
}
})
plain := types[0]
t.Run("category load serves the stored HTML", func(t *testing.T) {
html := "<p>Pillar</p>"
for _, tc := range []struct {
value any
want string
}{{&html, html}, {(*string)(nil), ""}, {nil, ""}, {"plain text", "plain text"}} {
got, err := plain.Load(ctx, pact.FormFieldValue{Field: "content", Options: map[string]any{"uploadFolder": "categories"}, Default: true, Value: tc.value})
if err != nil || got != tc.want {
t.Fatalf("Load(%#v) = %#v, %v, want %q", tc.value, got, err, tc.want)
}
}
})
t.Run("category decode strips with a notice", func(t *testing.T) {
got, err := plain.Decode(ctx, pact.FormFieldValue{Field: "content", Default: true, Value: `<p onclick="steal()">x</p>`})
if err != nil {
t.Fatal(err)
}
if got.Value != "<p>x</p>" || len(got.Notices) != 1 {
t.Fatalf("Decode = %#v", got)
}
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onclick" {
t.Fatalf("notice = %#v", n)
}
})
t.Run("category hooks map blank to NULL and accept markup", func(t *testing.T) {
ctl := categoriesAdminController{}
blank := " \n\t"
cat := &models.Category{Content: &blank}
if err := ctl.FormBeforeUpdate(ctx, cat); err != nil || cat.Content != nil {
t.Fatalf("blank: content = %v, err = %v, want NULL", cat.Content, err)
}
body := `<p onclick="steal()">x</p>`
cat = &models.Category{Content: &body}
if err := ctl.FormBeforeCreate(ctx, cat); err != nil {
t.Fatalf("FormBeforeCreate refused markup: %v", err)
}
if cat.Content == nil || *cat.Content != body {
t.Fatalf("content = %v, want it as received", cat.Content)
}
})
}