From d5a5413525a6c016381c8552e0269ae715d9c6ea Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 15:42:31 +0200 Subject: [PATCH] test(16.2-06): pin the imported-post resave contract - element.test.ts: an untouched value survives Source tab, tab buttons, preview and reattach byte for byte with no summer-change - TestBlogEditorLoad: HTML verbatim, markdown as content_html, FormatHTML fallback, nil and empty values - TestPostsAdminResaveKeepsHTML (Docker host): GET then PUT the served values for an HTML and a markdown post, exact bytes, no notices, then a clean edited save in both locales --- classes/blog_editor_test.go | 138 +++++++++++++++++ posts_admin_smoke_test.go | 256 +++++++++++++++++++++++++++---- tests/blogeditor/element.test.ts | 110 +++++++++++++ 3 files changed, 476 insertions(+), 28 deletions(-) create mode 100644 classes/blog_editor_test.go create mode 100644 tests/blogeditor/element.test.ts diff --git a/classes/blog_editor_test.go b/classes/blog_editor_test.go new file mode 100644 index 0000000..cc2a8ac --- /dev/null +++ b/classes/blog_editor_test.go @@ -0,0 +1,138 @@ +package classes + +import ( + "context" + "errors" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Unit tests of the Blog Editor field types (Phase 16.2 plan 06). + +// blogOptions are the post content field's options. +var blogOptions = map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"} + +// noTranslated is a Translated reader that fails the test when called. +func noTranslated(t *testing.T) func(string) (string, bool, error) { + return func(attribute string) (string, bool, error) { + t.Errorf("Translated(%q) called for HTML content", attribute) + return "", false, nil + } +} + +// translatedReader serves one attribute and records what was asked. +func translatedReader(asked *[]string, value string, found bool, err error) func(string) (string, bool, error) { + return func(attribute string) (string, bool, error) { + *asked = append(*asked, attribute) + return value, found, err + } +} + +func TestBlogEditorLoad(t *testing.T) { + const markdown = "## Nagłówek\n\nTekst z *kursywą*." + rendered, err := FormatHTML(markdown) + if err != nil { + t.Fatal(err) + } + if rendered == markdown || rendered == "" { + t.Fatalf("FormatHTML(%q) = %q", markdown, rendered) + } + html := "

Grzyby

" + empty := "" + + t.Run("HTML content is served verbatim", func(t *testing.T) { + for _, stored := range []string{ + "

Grzyby rosną.

\n\n", + "\n \t

leading white space

", + "

leading comment

", + "\n\n

both

\n", + "

unsafe markup is served too; Decode strips it on save

", + } { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: blogOptions, Locale: "pl", Default: true, Value: stored, Translated: noTranslated(t), + }) + if err != nil || got != stored { + t.Fatalf("Load(%q) = %q, %v; want the stored bytes", stored, got, err) + } + } + }) + + t.Run("markdown content is served as that locale's content_html", func(t *testing.T) { + var asked []string + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: blogOptions, Locale: "en", Value: markdown, + Translated: translatedReader(&asked, "

Imported

\n", true, nil), + }) + if err != nil || got != "

Imported

\n" { + t.Fatalf("Load = %q, %v", got, err) + } + if len(asked) != 1 || asked[0] != "content_html" { + t.Fatalf("asked %v, want [content_html]", asked) + } + }) + + t.Run("an empty or missing content_html falls back to FormatHTML", func(t *testing.T) { + for _, tc := range []struct { + name string + translated func(string) (string, bool, error) + }{ + {"empty", translatedReader(new([]string), "", true, nil)}, + {"missing", translatedReader(new([]string), "", false, nil)}, + {"no writer", nil}, + } { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: blogOptions, Locale: "pl", Default: true, Value: markdown, Translated: tc.translated, + }) + if err != nil || got != rendered { + t.Fatalf("%s: Load = %q, %v; want %q", tc.name, got, err, rendered) + } + } + }) + + t.Run("a Translated error is the Load error", func(t *testing.T) { + boom := errors.New("read failed") + _, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: blogOptions, Value: markdown, Translated: translatedReader(new([]string), "", false, boom), + }) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + }) + + t.Run("nil and empty values give the empty string", func(t *testing.T) { + for _, value := range []any{nil, (*string)(nil), &empty, "", " \n"} { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: value, Translated: noTranslated(t)}) + want := storedText(value) + if err != nil || got != want { + t.Fatalf("Load(%#v) = %q, %v; want %q", value, got, err, want) + } + } + }) + + t.Run("a *string is dereferenced", func(t *testing.T) { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Value: &html}) + if err != nil || got != html { + t.Fatalf("Load = %q, %v", got, err) + } + }) + + t.Run("without htmlColumn every value is served as stored", func(t *testing.T) { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: map[string]any{"uploadFolder": "categories"}, Value: markdown, Translated: noTranslated(t)}) + if err != nil || got != markdown { + t.Fatalf("Load = %q, %v; want the stored markdown", got, err) + } + }) + + t.Run("the registered types use this Load", func(t *testing.T) { + for _, typ := range BlogEditorTypes(nil) { + if typ.Load == nil { + t.Fatalf("%s has no Load", typ.Name) + } + got, err := typ.Load(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: "\n

x

"}) + if err != nil || got != "\n

x

" { + t.Fatalf("%s: Load = %q, %v", typ.Name, got, err) + } + } + }) +} diff --git a/posts_admin_smoke_test.go b/posts_admin_smoke_test.go index 06e2281..0d5a9a3 100644 --- a/posts_admin_smoke_test.go +++ b/posts_admin_smoke_test.go @@ -104,9 +104,23 @@ func TestPostsAdminCreateSmoke(t *testing.T) { } // TestPostsAdminResaveKeepsHTML is the acceptance test of the todo -// journal-imported-html-admin-resave (D-03): an admin save of a post whose -// content is HTML, as imported posts are, keeps that HTML in content_html -// instead of goldmark's raw-HTML placeholder. +// journal-imported-html-admin-resave (Phase 16.2 D-03, D-07) on the real +// admin API: the administrator opens a post and saves it without touching +// the editor. The test reads the form values the API serves with GET and +// PUTs them back unchanged, as the admin SPA does. +// +// - An HTML post keeps content and content_html byte for byte, gains no +// translated content for its untouched empty Polish value, and the +// response has no meta.notices key. +// - A markdown post (content markdown, content_html rendered at import, as +// Płytarium-era imports are) is served as content_html (D-07) and keeps +// its markdown content and its content_html byte for byte in both +// locales, even though its content_html holds markup the policy would +// strip: an untouched value is never decoded. +// - An edited save of clean HTML in both locales stores equal content and +// content_html per locale and reports nothing. +// +// The harness's default locale is en (host columns); pl is the translation. func TestPostsAdminResaveKeepsHTML(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, @@ -114,41 +128,227 @@ func TestPostsAdminResaveKeepsHTML(t *testing.T) { controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts, ) - const content = "

Grzyby rosną w lesie.

\n

Druga część z linkiem.

" - body := map[string]any{ - "title": map[string]any{"en": "Imported", "pl": "Zaimportowany"}, - "slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"}, - "content": map[string]any{"en": content, "pl": content}, - "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, - } - rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, body) + + t.Run("HTML post", func(t *testing.T) { + const content = "

Wstęp

\n" + + "

Grzyby rosną w lesie.

\n" + + "

Druga część z linkiem.

\n" + + "
\n" + rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ + "title": map[string]any{"en": "Imported", "pl": "Zaimportowany"}, + "slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"}, + "content": map[string]any{"en": content}, + "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, + }) + if notices := adminNotices(t, rec); notices != nil { + t.Fatalf("clean create carries meta.notices: %s", rec.Body.String()) + } + id := adminCreatedID(t, rec) + assertHostContent(t, env, id, "create", content, content) + before := translatedAttrs(t, env, id, "pl") + rowsBefore := translatedRows(t, env, id) + + served := servedForm(t, env, token, id) + if got := servedLocales(t, served, "content"); got["en"] != content || got["pl"] != "" { + t.Fatalf("served content = %#v, want en as stored and an empty pl", got) + } + put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt")) + if _, ok := adminMeta(t, put)["notices"]; ok { + t.Fatalf("untouched save carries meta.notices: %s", put.Body.String()) + } + assertHostContent(t, env, id, "untouched re-save", content, content) + after := translatedAttrs(t, env, id, "pl") + for _, key := range []string{"content", "content_html"} { + old, had := before[key] + now, has := after[key] + if had != has || old != now { + t.Fatalf("untouched re-save changed pl %s: before %#v (%v), after %#v (%v)", key, old, had, now, has) + } + if has { + t.Fatalf("pl %s written for an untouched empty value: %#v", key, now) + } + } + if rows := translatedRows(t, env, id); rows != rowsBefore { + t.Fatalf("untouched re-save added translation rows: %d -> %d", rowsBefore, rows) + } + + const editedEN = "

Edited English text.

\n
  1. three
\n" + const editedPL = "

Poprawiony polski tekst.

\n

\"grzyb\"

\n" + editBody := servedBody(served, "title", "slug", "excerpt") + editBody["content"] = map[string]any{"en": editedEN, "pl": editedPL} + edit := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, editBody) + if _, ok := adminMeta(t, edit)["notices"]; ok { + t.Fatalf("clean edited save carries meta.notices: %s", edit.Body.String()) + } + assertHostContent(t, env, id, "edited save", editedEN, editedEN) + pl := translatedAttrs(t, env, id, "pl") + if pl["content"] != editedPL || pl["content_html"] != pl["content"] { + t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], editedPL) + } + }) + + t.Run("markdown post", func(t *testing.T) { + const markdownEN = "## Heading\n\nText with *italics* and **bold**.\n\n- one\n- two\n" + const markdownPL = "## Nagłówek\n\nTekst z *kursywą* i **pogrubieniem**.\n\n- jeden\n- dwa\n" + // The content_html an import stored: rendered markdown plus the raw + // author footer and its style block, which the policy would strip. + const footer = "\n\n" + const renderedEN = "

Heading

\n

Text with italics and bold.

\n\n" + footer + const renderedPL = "

Nagłówek

\n

Tekst z kursywą i pogrubieniem.

\n\n" + footer + + rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ + "title": map[string]any{"en": "Markdown", "pl": "Markdown PL"}, + "slug": map[string]any{"en": "imported-markdown", "pl": "zaimportowany-markdown"}, + "content": map[string]any{"en": markdownEN, "pl": markdownPL}, + }) + id := adminCreatedID(t, rec) + assertHostContent(t, env, id, "markdown create", markdownEN, markdownEN) + // What an import leaves behind: content_html rendered from markdown. + if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Update("content_html", renderedEN).Error; err != nil { + t.Fatal(err) + } + setTranslatedAttr(t, env, id, "pl", "content_html", renderedPL) + + served := servedForm(t, env, token, id) + if got := servedLocales(t, served, "content"); got["en"] != renderedEN || got["pl"] != renderedPL { + t.Fatalf("served content = %#v, want each locale's content_html (D-07)", got) + } + put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt")) + if _, ok := adminMeta(t, put)["notices"]; ok { + t.Fatalf("untouched markdown save carries meta.notices: %s", put.Body.String()) + } + assertHostContent(t, env, id, "untouched markdown re-save", markdownEN, renderedEN) + pl := translatedAttrs(t, env, id, "pl") + if pl["content"] != markdownPL || pl["content_html"] != renderedPL { + t.Fatalf("pl content = %#v, content_html = %#v, want the markdown and the imported HTML unchanged", pl["content"], pl["content_html"]) + } + }) +} + +// adminCreatedID returns the id of a create response's record. +func adminCreatedID(t *testing.T, rec *httptest.ResponseRecorder) uint { + t.Helper() var created struct { ID uint `json:"id"` } if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 { t.Fatalf("create data %s: %v", rec.Body.String(), err) } + return created.ID +} - assertHTML := func(stage string) { - t.Helper() - var stored models.Post - if err := env.gdb.First(&stored, created.ID).Error; err != nil { - t.Fatal(err) - } - if strings.Contains(stored.ContentHTML, "raw HTML omitted") { - t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML) - } - if stored.ContentHTML != content { - t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, content) - } - if stored.Content != content { - t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content) +// adminMeta returns a response's meta object by key. +func adminMeta(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage { + t.Helper() + var body struct { + Meta map[string]json.RawMessage `json:"meta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("admin body %q: %v", rec.Body.String(), err) + } + return body.Meta +} + +// assertHostContent compares the host columns byte for byte (no trimming). +func assertHostContent(t *testing.T, env *adminEnv, id uint, stage, content, contentHTML string) { + t.Helper() + var stored models.Post + if err := env.gdb.First(&stored, id).Error; err != nil { + t.Fatal(err) + } + if strings.Contains(stored.ContentHTML, "raw HTML omitted") { + t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML) + } + if stored.Content != content { + t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content) + } + if stored.ContentHTML != contentHTML { + t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, contentHTML) + } +} + +// servedForm returns the record data the admin API serves the form with. +func servedForm(t *testing.T, env *adminEnv, token string, id uint) map[string]json.RawMessage { + t.Helper() + rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, nil) + var data map[string]json.RawMessage + if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil { + t.Fatalf("show data %s: %v", rec.Body.String(), err) + } + return data +} + +// servedLocales decodes one served locale map. +func servedLocales(t *testing.T, served map[string]json.RawMessage, field string) map[string]string { + t.Helper() + var out map[string]string + if err := json.Unmarshal(served[field], &out); err != nil { + t.Fatalf("served %s %s: %v", field, served[field], err) + } + return out +} + +// servedBody is a save body of the named served fields, unchanged. +func servedBody(served map[string]json.RawMessage, fields ...string) map[string]any { + out := map[string]any{} + for _, field := range fields { + if raw, ok := served[field]; ok { + out[field] = raw } } - assertHTML("create") + return out +} - env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, created.ID)), token, body) - assertHTML("re-save") +// translatedAttrs returns a post's attribute_data in locale, empty when it +// has no row. +func translatedAttrs(t *testing.T, env *adminEnv, id uint, locale string) map[string]any { + t.Helper() + var raw *string + if err := env.gdb.Table("golem15_translate_attributes"). + Select("attribute_data"). + Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale). + Limit(1). + Scan(&raw).Error; err != nil { + t.Fatal(err) + } + out := map[string]any{} + if raw == nil || *raw == "" { + return out + } + if err := json.Unmarshal([]byte(*raw), &out); err != nil { + t.Fatalf("attribute_data %q: %v", *raw, err) + } + return out +} + +// translatedRows counts a post's translation rows. +func translatedRows(t *testing.T, env *adminEnv, id uint) int64 { + t.Helper() + var n int64 + if err := env.gdb.Table("golem15_translate_attributes"). + Where("model_id = ? AND model_type = ?", fmt.Sprint(id), models.Post{}.MorphName()). + Count(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// setTranslatedAttr sets one key of a post's attribute_data in locale, as an +// import does. +func setTranslatedAttr(t *testing.T, env *adminEnv, id uint, locale, key, value string) { + t.Helper() + attrs := translatedAttrs(t, env, id, locale) + attrs[key] = value + raw, err := json.Marshal(attrs) + if err != nil { + t.Fatal(err) + } + res := env.gdb.Table("golem15_translate_attributes"). + Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale). + Update("attribute_data", string(raw)) + if res.Error != nil || res.RowsAffected != 1 { + t.Fatalf("set %s %s: rows=%d err=%v", locale, key, res.RowsAffected, res.Error) + } } // TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and diff --git a/tests/blogeditor/element.test.ts b/tests/blogeditor/element.test.ts new file mode 100644 index 0000000..063e246 --- /dev/null +++ b/tests/blogeditor/element.test.ts @@ -0,0 +1,110 @@ +// The imported-post resave contract at the element layer (Phase 16.2 D-03, +// D-07; todo journal-imported-html-admin-resave): a value the administrator +// does not touch comes back as the exact served string, even after the +// Source tab and the preview were opened and closed, and the element +// announces nothing. +import { afterEach, describe, expect, it, vi } from 'vitest' +import fixture from './fixtures/imported-post.html?raw' +import { button, click, mount } from './helpers' + +afterEach(() => { + vi.useRealTimers() + document.body.replaceChildren() +}) + +// What the server serves for a markdown post (D-07): its content_html, as +// goldmark rendered it at import, with the raw author footer and a trailing +// newline. +const markdownContentHTML = + '

Nagłówek

\n' + + '

Tekst z kursywą i pogrubieniem.

\n' + + '\n' + + '
\n

Cytat

\n
\n' + + '

źródło

\n' + + '\n' + +const samples: [string, string][] = [ + ['the imported HTML post', fixture], + ['a markdown post served as content_html', markdownContentHTML], + ['HTML with leading white space and a comment', '\n \n

a b

\n'], + ['an empty value', ''], +] + +function tab(el: HTMLElement, name: 'wysiwyg' | 'source'): HTMLButtonElement { + const found = el.querySelector(`button[data-tab="${name}"]`) + if (!found) throw new Error(`no ${name} tab`) + return found +} + +describe('untouched value (D-03)', () => { + it.each(samples)('returns %s unchanged after a Source tab round trip', (_name, served) => { + const { el, changes } = mount(served) + expect(el.value).toBe(served) + + el.showSource(true) + expect(el.querySelector('textarea')!.value).toBe(served) + expect(el.value).toBe(served) + el.showSource(false) + + expect(el.value).toBe(served) + expect(changes).toEqual([]) + }) + + it.each(samples)('returns %s unchanged after the tab buttons are clicked', (_name, served) => { + const { el, changes } = mount(served) + click(tab(el, 'source')) + expect(tab(el, 'source').getAttribute('aria-selected')).toBe('true') + click(tab(el, 'wysiwyg')) + click(tab(el, 'source')) + click(tab(el, 'wysiwyg')) + expect(tab(el, 'wysiwyg').getAttribute('aria-selected')).toBe('true') + expect(el.value).toBe(served) + expect(changes).toEqual([]) + }) + + it.each(samples)('returns %s unchanged when the value is set after connect and echoed', (_name, served) => { + const { el, changes } = mount() + el.value = served + el.showSource(true) + el.showSource(false) + // The SPA writes the form value back only when it differs; an echo of + // the same string must change nothing. + el.value = el.value + expect(el.value).toBe(served) + expect(changes).toEqual([]) + }) + + it('returns the imported post unchanged after the preview is opened and closed', () => { + vi.useFakeTimers() + const { el, changes } = mount(fixture) + click(button(el, 'preview')) + vi.advanceTimersByTime(1000) + click(button(el, 'preview')) + el.showSource(true) + el.showSource(false) + expect(el.value).toBe(fixture) + expect(changes).toEqual([]) + }) + + it('returns the imported post unchanged after a detach and a new attach', () => { + const { el, changes } = mount(fixture) + el.remove() + expect(el.value).toBe(fixture) + document.body.append(el) + expect(el.value).toBe(fixture) + expect(changes).toEqual([]) + }) + + it('reports an edit in the Source tab once and keeps the edited text exactly', () => { + const { el, changes } = mount(fixture) + el.showSource(true) + const textarea = el.querySelector('textarea')! + const edited = fixture.replace('', ' (edytowane)') + expect(edited).not.toBe(fixture) + textarea.value = edited + textarea.dispatchEvent(new Event('input')) + el.showSource(false) + expect(el.value).toBe(edited) + expect(changes).toEqual([edited]) + }) +})