package controllers import ( "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/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "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 = "

Grzyby

\n

Tekst z linkiem & pogrubieniem.

\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 strips and reports", func(t *testing.T) { got, err := decode(t, `

a

x`) if err != nil { t.Fatal(err) } if got.Value != `

a

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 = " \n

Stored HTML

" 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 "

Stored render

", true, nil }) if got != "

Stored render

" { 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 != "

Hello world

\n" { t.Fatalf("Load(markdown, empty content_html) = %q", got) } if got := load(t, "Hello **world**", nil); got != "

Hello world

\n" { t.Fatalf("Load(markdown, no reader) = %q", got) } }) t.Run("list value", func(t *testing.T) { got := ml.ListValue(context.Background(), "

Title

Some bold\n text

") if got != "Title Some bold text" { t.Fatalf("ListValue = %q", got) } long := ml.ListValue(context.Background(), "

"+strings.Repeat("ż", 130)+"

") 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: "

A

"}, {Locale: "en", Value: "

B

"}, }, }) post := &models.Post{Content: "

A

", ContentHTML: "old"} if err := ctl.FormBeforeUpdate(ctx, post); err != nil { t.Fatal(err) } if post.ContentHTML != "

A

" { t.Fatalf("ContentHTML = %q, want

A

", 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: "

B

"}) { 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: "

kept

"} if err := ctl.FormBeforeUpdate(ctx, post); err != nil { t.Fatal(err) } if post.ContentHTML != "

kept

" { 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 != "

kept

" || 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) } }) }