fix: image alt on HTML-only translations and on markdown that starts with a tag

Blank content means content_html is the body (no 422 for
content_html-only translations, a regression from the markdown alt fix);
any other content is handled as markdown, so a body opening with a tag
gets its inline image label and keeps its code samples.

quick-261104-xae, review WR-02.
This commit is contained in:
Jakub Zych
2026-11-05 00:19:28 +01:00
parent a41d537e52
commit cf13c606f8
3 changed files with 104 additions and 16 deletions

View File

@@ -723,6 +723,86 @@ func TestSetImageAlt(t *testing.T) {
}
}
// TestSetImageAltBodyShapes pins the body rule of images/alt
// (33-REVIEW-GAPFIX WR-02): a locale whose content is blank (a translation
// that carries only content_html) gets the alt in content_html alone, and
// any other content is read as markdown, also when it starts with a tag.
func TestSetImageAltBodyShapes(t *testing.T) {
env := newPostsEnv(t)
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
const a = "https://cdn.example.test/a.jpg"
path := journalAPIPrefix + "/images/alt"
decode := func(rec *httptest.ResponseRecorder) []map[string]any {
var out struct {
Changes []map[string]any `json:"changes"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
}
return out.Changes
}
t.Run("a translation that holds only content_html", func(t *testing.T) {
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
"title": "HTML translation", "content": "Intro\n\n![](" + a + ")",
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content_html": `<p><img src="` + a + `"></p>`}},
}))
changes := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
"changes": []any{
map[string]any{"post_id": post, "src": a, "alt": "Grzyb"},
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
},
}))
if len(changes) != 2 || changes[1]["locale"] != "pl" || changes[1]["changed"] != true || changes[1]["old_alt"] != nil {
t.Fatalf("answer = %#v", changes)
}
pl := translatedAttrs(t, env, post, "pl")
if html, _ := pl["content_html"].(string); !strings.Contains(html, `alt="Lew"`) {
t.Fatalf("pl = %#v", pl)
}
if c, has := pl["content"]; has {
if s, _ := c.(string); strings.TrimSpace(s) != "" {
t.Fatalf("pl content was written: %#v", pl)
}
}
if got := storedPost(t, env, post).Content; got != "Intro\n\n![Grzyb]("+a+")" {
t.Fatalf("default content = %q", got)
}
})
t.Run("markdown that starts with a tag", func(t *testing.T) {
md := "<p class=\"lead\">Intro</p>\n\n![](" + a + ")\n\n```html\n<img src=\"" + a + "\">\n```\n"
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Tag lead", "content": md}))
env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": "Grzyb"}},
})
want := "<p class=\"lead\">Intro</p>\n\n![Grzyb](" + a + ")\n\n```html\n<img src=\"" + a + "\">\n```\n"
stored := storedPost(t, env, post)
if stored.Content != want || !strings.Contains(stored.ContentHTML, `alt="Grzyb"`) {
t.Fatalf("after the alt = %q / %q", stored.Content, stored.ContentHTML)
}
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/posts/%d", journalAPIPrefix, post), editor, map[string]any{"content": stored.Content})
if again := storedPost(t, env, post); !strings.Contains(again.ContentHTML, `alt="Grzyb"`) {
t.Fatalf("the next content save lost the alt: %q", again.ContentHTML)
}
})
t.Run("markdown that starts with a tag and holds the image by reference", func(t *testing.T) {
ref := "<p>Lead</p>\n\n![old][r]\n\n[r]: " + a + "\n"
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Tag lead reference", "content": ref}))
before := storedPost(t, env, post)
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
"changes": []any{map[string]any{"post_id": post, "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 answer %s", rec.Body.String())
}
if after := storedPost(t, env, post); after.Content != before.Content || after.ContentHTML != before.ContentHTML {
t.Fatalf("a refused 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.