feat: SEO field types and the complete SEO tab
This commit is contained in:
296
seo_tab_test.go
296
seo_tab_test.go
@@ -1,16 +1,25 @@
|
||||
package seo_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
seo "git.golem15.com/golem15/sm-seo-plugin"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// seoTabLabel is the English label of seo.TabSEO.
|
||||
@@ -307,3 +316,290 @@ func TestGenerateEntries(t *testing.T) {
|
||||
t.Fatal("unknown type: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// pngBytes is a 1x1 PNG.
|
||||
var pngBytes = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// upload posts one file to a plugin field's upload route.
|
||||
func (e *testEnv) upload(t *testing.T, token, path, filename string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req.RemoteAddr = "198.51.100.7:4000"
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestSEOTab checks the complete SEO tab (UI-SPEC C3): the field order in
|
||||
// the tab placed last on a tabbed form and after the default tab on a
|
||||
// tab-less one, the robots and canonical refusals, the social image upload
|
||||
// and its one value for every locale, the result-preview widget action and
|
||||
// the P1/P2 partials.
|
||||
func TestSEOTab(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
|
||||
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_effective"
|
||||
fields := env.formSchema(t, token, "acme/pages/pages")
|
||||
var seoFields []string
|
||||
for _, f := range fields {
|
||||
if strings.HasPrefix(f.Name, "seo_") {
|
||||
seoFields = append(seoFields, f.Name)
|
||||
if f.Tab != seoTabLabel {
|
||||
t.Fatalf("%s is in tab %q", f.Name, f.Tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(seoFields, " ") != want {
|
||||
t.Fatalf("SEO tab fields = %v, want %s", seoFields, want)
|
||||
}
|
||||
if tabs := tabsOf(fields); tabs[len(tabs)-1] != seoTabLabel || len(tabs) != 4 {
|
||||
t.Fatalf("tabs = %q, want the owner's three tabs then SEO", tabs)
|
||||
}
|
||||
if tabs := tabsOf(env.formSchema(t, token, "acme/pages/notes")); len(tabs) != 2 || tabs[1] != seoTabLabel {
|
||||
t.Fatalf("tab-less form tabs = %q, want default then SEO", tabs)
|
||||
}
|
||||
|
||||
id := env.createPage(t, token, map[string]any{"title": "Grzyby leśne", "slug": "grzyby", "excerpt": "O grzybach", "published": true})
|
||||
other := env.createPage(t, token, map[string]any{"title": "Other", "slug": "other", "published": true, "seo_title": map[string]any{"en": "Duplicate title"}})
|
||||
path := adminAPI("/acme/pages/pages/" + id)
|
||||
base := map[string]any{"title": "Grzyby leśne", "slug": "grzyby", "excerpt": "O grzybach", "published": true}
|
||||
with := func(extra map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
rec := env.call(t, http.MethodPut, path, token, with(map[string]any{"seo_robots": map[string]any{"en": "index,banana"}}))
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "seo_robots") {
|
||||
t.Fatalf("malformed robots = %d %s, want 422 on seo_robots", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = env.call(t, http.MethodPut, path, token, with(map[string]any{"seo_canonical": map[string]any{"en": "javascript:alert(1)"}}))
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "seo_canonical") {
|
||||
t.Fatalf("bad canonical = %d %s, want 422 on seo_canonical", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
uploadPath := adminAPI("/acme/pages/pages/" + id + "/fields/seo_og_image/upload")
|
||||
if rec := env.upload(t, token, uploadPath, "evil.png", []byte("<svg onload=alert(1)>")); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("non-image upload = %d %s, want 422", rec.Code, rec.Body.String())
|
||||
}
|
||||
editor := env.admin(t, false, pagesPermission)
|
||||
if rec := env.upload(t, editor, uploadPath, "ok.png", pngBytes); rec.Code == http.StatusCreated {
|
||||
t.Fatalf("upload without golem15.seo.manage = %d, want a refusal", rec.Code)
|
||||
}
|
||||
rec = env.upload(t, token, uploadPath, "Social Image.PNG", pngBytes)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("image upload = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var uploaded struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil || !strings.Contains(uploaded.Data.URL, "/media/seo/") || !strings.HasSuffix(uploaded.Data.URL, "-social-image.png") {
|
||||
t.Fatalf("upload answer %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, path, token, with(map[string]any{
|
||||
"seo_title": map[string]any{"en": "Grzyby leśne z Podlasia"},
|
||||
"seo_description": map[string]any{"en": "Duplicate description", "pl": "Opis"},
|
||||
"seo_robots": map[string]any{"en": "NOINDEX, max-snippet:50"},
|
||||
"seo_canonical": map[string]any{"en": "/canonical"},
|
||||
"seo_og_image": uploaded.Data.URL,
|
||||
}))
|
||||
var images []string
|
||||
if err := env.db.Raw(`SELECT og_image FROM golem15_seo_entries WHERE record_id = ? ORDER BY locale`, id).Scan(&images).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(images) != 2 || images[0] != uploaded.Data.URL || images[1] != uploaded.Data.URL {
|
||||
t.Fatalf("og_image per locale = %v, want the one image in en and pl", images)
|
||||
}
|
||||
var robots string
|
||||
if err := env.db.Raw(`SELECT robots FROM golem15_seo_entries WHERE record_id = ? AND locale = 'en'`, id).Scan(&robots).Error; err != nil || robots != "noindex,max-snippet:50" {
|
||||
t.Fatalf("stored robots = %q err=%v, want the normalised value", robots, err)
|
||||
}
|
||||
show := env.expect(t, http.StatusOK, http.MethodGet, path, token, nil)
|
||||
if !strings.Contains(show.Body.String(), `"seo_og_image":"`+uploaded.Data.URL+`"`) {
|
||||
t.Fatalf("show lacks the social image: %s", show.Body.String())
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+other), token, map[string]any{
|
||||
"title": "Other", "slug": "other", "published": true, "seo_description": map[string]any{"en": "Duplicate description"},
|
||||
})
|
||||
numericID := func(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||
widget := func(payload map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/acme/pages/pages/widgets/seo_snippet"), token, map[string]any{"record_id": numericID(id), "payload": payload})
|
||||
var out struct {
|
||||
Data struct {
|
||||
Data map[string]any `json:"data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Data.Data == nil {
|
||||
t.Fatalf("widget answer %s", rec.Body.String())
|
||||
}
|
||||
return out.Data.Data
|
||||
}
|
||||
ctxData := widget(map[string]any{"op": "context"})
|
||||
locales, _ := ctxData["locales"].([]any)
|
||||
first, _ := locales[0].(map[string]any)
|
||||
if ctxData["host"] != "www.example.test" || len(locales) != 2 || first["locale"] != "en" || first["url"] != "/pages/grzyby" || first["titleTemplate"] != "%title% | %site%" || first["descriptionSource"] != "content" {
|
||||
t.Fatalf("context = %v", ctxData)
|
||||
}
|
||||
dups := widget(map[string]any{"op": "duplicates", "locale": "en", "title": "Grzyby leśne z Podlasia", "description": "Duplicate description"})
|
||||
if d, _ := dups["description"].([]any); len(d) != 1 || !strings.Contains(fmtJSON(d), "/pages/other") || !strings.Contains(fmtJSON(d), "/backend/acme/pages/pages/update/"+other) {
|
||||
t.Fatalf("duplicates = %v, want the other page", dups)
|
||||
}
|
||||
if d, _ := dups["title"].([]any); len(d) != 0 {
|
||||
t.Fatalf("title duplicates = %v, want none", d)
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/pages/grzyby", "match": "exact", "title": "Rule title", "robots": "index"})
|
||||
status := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_status?id="+id), token, nil)
|
||||
if !strings.Contains(status.Body.String(), "A URL rule overrides part of this tab") || !strings.Contains(status.Body.String(), "SEO title, Search engine indexing") {
|
||||
t.Fatalf("seo_status partial = %s", status.Body.String())
|
||||
}
|
||||
effective := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_effective?id="+id), token, nil)
|
||||
for _, part := range []string{"Rule title", "URL rule", "/canonical", "This record"} {
|
||||
if !strings.Contains(effective.Body.String(), part) {
|
||||
t.Fatalf("seo_effective partial lacks %q: %s", part, effective.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleFormPreview checks the URL rule form's partials and preview
|
||||
// widget: an exact rule resolves at its address, a pattern rule lists its
|
||||
// own values, and the preview context carries the saved rule.
|
||||
func TestRuleFormPreview(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
create := func(body map[string]any) string {
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, body)
|
||||
var out struct {
|
||||
Data struct {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Data.ID.String()
|
||||
}
|
||||
exact := create(map[string]any{"url": "/", "match": "exact", "title": "Home rule", "robots": "noindex,follow"})
|
||||
pattern := create(map[string]any{"url": "/blog?tag=*", "match": "pattern", "description": "Tag pages"})
|
||||
rec := env.call(t, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/x", "match": "exact", "robots": "index,banana"})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "robots") {
|
||||
t.Fatalf("malformed rule robots = %d %s, want 422", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/rules/partials/seo_effective?id="+exact), token, nil).Body.String()
|
||||
if !strings.Contains(body, "Home rule") || !strings.Contains(body, "noindex, follow") {
|
||||
t.Fatalf("exact rule P2 = %s", body)
|
||||
}
|
||||
body = env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/rules/partials/seo_effective?id="+pattern), token, nil).Body.String()
|
||||
if !strings.Contains(body, "Tag pages") || !strings.Contains(body, "URL rule") {
|
||||
t.Fatalf("pattern rule P2 = %s", body)
|
||||
}
|
||||
n, _ := strconv.Atoi(exact)
|
||||
body = env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/golem15/seo/rules/widgets/_seo_snippet"), token, map[string]any{"record_id": n, "payload": map[string]any{"op": "context"}}).Body.String()
|
||||
if !strings.Contains(body, `"defaultTitle":"Home rule"`) || !strings.Contains(body, `"url":"/"`) {
|
||||
t.Fatalf("rule preview context = %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// langKeyPattern finds golem15.seo::lang.<key> references.
|
||||
var langKeyPattern = regexp.MustCompile(`golem15\.seo::lang\.([a-z0-9_.]+[a-z0-9_])`)
|
||||
|
||||
// TestLangKeysComplete checks that every golem15.seo::lang key the plugin's
|
||||
// Go, YAML and template files name exists in both phrasebooks.
|
||||
func TestLangKeysComplete(t *testing.T) {
|
||||
books := map[string]map[string]any{}
|
||||
for _, locale := range []string{"en", "pl"} {
|
||||
raw, err := os.ReadFile(filepath.Join("lang", locale, "lang.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var book map[string]any
|
||||
if err := yaml.Unmarshal(raw, &book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books[locale] = book
|
||||
}
|
||||
has := func(book map[string]any, key string) bool {
|
||||
var cur any = book
|
||||
for _, part := range strings.Split(key, ".") {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if cur, ok = m[part]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return cur != nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && (path == "node_modules" || path == ".git" || path == "assets") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
ext := filepath.Ext(path)
|
||||
if d.IsDir() || (ext != ".go" && ext != ".yaml" && ext != ".htm") || strings.HasPrefix(path, "lang") {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range langKeyPattern.FindAllStringSubmatch(string(raw), -1) {
|
||||
key := m[1]
|
||||
if strings.HasSuffix(key, "via_") || strings.HasSuffix(key, "source_") {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
for locale, book := range books {
|
||||
if !has(book, key) {
|
||||
t.Errorf("%s: golem15.seo::lang.%s is missing from lang/%s/lang.yaml", path, key, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) < 50 {
|
||||
t.Fatalf("found only %d keys; the scan is broken", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user