- Normalize: https, www or bare host, double slashes and index files, trailing slash add/remove, optional ASCII lowercasing (defaults change nothing but scheme, slashes and index files) - strict URLs through signpost MatchPath: a path that names a record without being its address 301s to the record's address - resolve runs normalisation, redirects, strict URLs, then SEO; the location is the final address, so a visitor makes one hop - P1 warns when a record's canonical URL redirects
204 lines
8.8 KiB
Go
204 lines
8.8 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
)
|
|
|
|
// blogPlugin is acme.blog: posts under /blog/:category/:slug with a
|
|
// translated slug in pl (/pl/blog/:category/:slug), kept in memory. It
|
|
// stands in for a content type whose URL has more segments than the
|
|
// record's own slug (D-20c).
|
|
type blogPlugin struct{}
|
|
|
|
type blogPost struct {
|
|
id, category, slug, plSlug string
|
|
}
|
|
|
|
var blogPosts = []blogPost{{id: "1", category: "news", slug: "hello", plSlug: "witaj"}}
|
|
|
|
func (blogPlugin) ID() string { return "acme.blog" }
|
|
func (blogPlugin) Requires() []string { return nil }
|
|
func (blogPlugin) Register(*backpack.App) error { return nil }
|
|
func (blogPlugin) Boot(*backpack.App) error { return nil }
|
|
|
|
func (blogPlugin) MenuItemTypes() []pact.MenuItemType {
|
|
return []pact.MenuItemType{{
|
|
Key: "acme-post",
|
|
Label: "Posts",
|
|
DefaultPattern: "/blog/:category/:slug",
|
|
Placeholders: []string{"category", "slug"},
|
|
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return nil, nil
|
|
},
|
|
Content: &pact.ContentDescriptor{
|
|
Model: "acme_blog_posts",
|
|
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
for _, p := range blogPosts {
|
|
if p.id == in.ID {
|
|
return &pact.ContentRecord{ID: p.id, Published: true, Locales: []pact.ContentLocale{
|
|
{Locale: "en", URL: "/blog/" + p.category + "/" + p.slug, Title: "Hello"},
|
|
{Locale: "pl", URL: "/pl/blog/" + p.category + "/" + p.plSlug, Title: "Witaj"},
|
|
}}, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
},
|
|
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
path, locale := in.Path, "en"
|
|
if rest, ok := strings.CutPrefix(path, "/pl/"); ok {
|
|
path, locale = "/"+rest, "pl"
|
|
}
|
|
values, ok := signpost.MatchPattern(in.Pattern, path)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
for _, p := range blogPosts {
|
|
own := p.slug
|
|
if locale == "pl" {
|
|
own = p.plSlug
|
|
}
|
|
if values["slug"] == p.slug || values["slug"] == p.plSlug {
|
|
return &pact.ContentMatchResult{ID: p.id, Locale: locale, Exact: values["category"] == p.category && values["slug"] == own}, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
},
|
|
},
|
|
}}
|
|
}
|
|
|
|
var _ pact.HasMenuItemTypes = blogPlugin{}
|
|
|
|
func init() {
|
|
party.Register(blogPlugin{})
|
|
}
|
|
|
|
// TestNormalize checks the address rules (D-20b, RESEARCH Pitfall 13):
|
|
// pure, no database.
|
|
func TestNormalize(t *testing.T) {
|
|
keep := classes.NormalizeOptions{Host: models.HostKeep, Slash: models.SlashKeep}
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
opts classes.NormalizeOptions
|
|
want string
|
|
changed bool
|
|
}{
|
|
{"http to https", "http://www.example.test/a", keep, "https://www.example.test/a", true},
|
|
{"clean", "https://www.example.test/a", keep, "https://www.example.test/a", false},
|
|
{"clean path", "/pages/a?page=2", keep, "/pages/a?page=2", false},
|
|
{"double slashes", "/a//b///c", keep, "/a/b/c", true},
|
|
{"index file", "/blog/index.html", keep, "/blog", true},
|
|
{"root index", "/index.php", keep, "/", true},
|
|
{"fragment", "/a#top", keep, "/a", false},
|
|
{"www added", "https://example.test/a", classes.NormalizeOptions{Host: models.HostWWW}, "https://www.example.test/a", true},
|
|
{"www kept", "https://www.example.test/a", classes.NormalizeOptions{Host: models.HostWWW}, "https://www.example.test/a", false},
|
|
{"www dropped", "https://www.example.test/a", classes.NormalizeOptions{Host: models.HostBare}, "https://example.test/a", true},
|
|
{"slash added", "/a", classes.NormalizeOptions{Slash: models.SlashAdd}, "/a/", true},
|
|
{"slash not added to a file", "/sitemap.xml", classes.NormalizeOptions{Slash: models.SlashAdd}, "/sitemap.xml", false},
|
|
{"slash removed", "/a/", classes.NormalizeOptions{Slash: models.SlashDrop}, "/a", true},
|
|
{"root kept (add)", "/", classes.NormalizeOptions{Slash: models.SlashAdd}, "/", false},
|
|
{"root kept (remove)", "/", classes.NormalizeOptions{Slash: models.SlashDrop}, "/", false},
|
|
{"lowercase", "/Blog/Post", classes.NormalizeOptions{Lowercase: true}, "/blog/post", true},
|
|
{"lowercase off", "/Blog/Post", keep, "/Blog/Post", false},
|
|
{"polish letters kept", "/%C5%81%C3%B3d%C5%BA/Abc", classes.NormalizeOptions{Lowercase: true}, "/%C5%81%C3%B3d%C5%BA/abc", true},
|
|
{"percent case is the same address", "/%c5%81odz", classes.NormalizeOptions{Lowercase: true}, "/%C5%81odz", false},
|
|
{"all at once", "http://example.test//Blog//index.html", classes.NormalizeOptions{Host: models.HostWWW, Slash: models.SlashAdd, Lowercase: true}, "https://www.example.test/blog/", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
u, err := url.Parse(c.in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, changed := classes.Normalize(u, c.opts)
|
|
if got.String() != c.want || changed != c.changed {
|
|
t.Fatalf("Normalize(%s) = %s, %v; want %s, %v", c.in, got, changed, c.want, c.changed)
|
|
}
|
|
if u.String() != c.in && !strings.Contains(c.in, "#") {
|
|
t.Fatalf("Normalize modified its input: %s", u)
|
|
}
|
|
})
|
|
}
|
|
// The Polish case decodes to the same letters it started with.
|
|
u, _ := url.Parse("/%C5%81%C3%B3d%C5%BA")
|
|
if got, _ := classes.Normalize(u, classes.NormalizeOptions{Lowercase: true}); got.Path != "/Łódź" {
|
|
t.Fatalf("decoded Polish path = %q, want /Łódź", got.Path)
|
|
}
|
|
}
|
|
|
|
// TestStrictURL checks strict URLs (D-20c): a path that names a record
|
|
// with a wrong segment or another locale's slug 301s to the record's own
|
|
// address, and only its own address answers ok.
|
|
func TestStrictURL(t *testing.T) {
|
|
env := bootPlugins(t, []string{"acme.pages", "acme.blog", "golem15.seo"})
|
|
env.expectAction(t, "/blog/news/hello", "ok")
|
|
env.expectRedirect(t, "/blog/wrong-category/hello", 301, siteURL+"/blog/news/hello")
|
|
env.expectRedirect(t, "/blog/wrong-category/hello?page=2", 301, siteURL+"/blog/news/hello?page=2")
|
|
env.expectRedirect(t, "/pl/blog/news/hello", 301, siteURL+"/pl/blog/news/witaj")
|
|
env.expectRedirect(t, "/blog/news/witaj", 301, siteURL+"/blog/news/hello")
|
|
got := env.expectAction(t, "/pl/blog/news/witaj", "ok")
|
|
if got["locale"] != "pl" {
|
|
t.Fatalf("pl address = %v, want locale pl", got)
|
|
}
|
|
env.expectAction(t, "/blog/news/missing", "not_found")
|
|
|
|
token := env.admin(t, true)
|
|
env.createPage(t, token, map[string]any{"title": "Alpha", "slug": "a", "published": true})
|
|
env.expectAction(t, "/pages/a", "ok")
|
|
env.expectRedirect(t, "/pages/a/", 301, siteURL+"/pages/a")
|
|
}
|
|
|
|
// TestOneHop checks that the resolve answer is the redirect decision
|
|
// (D-20a, D-20b, P-21, D-06): normalisation, redirects and strict URLs
|
|
// reach the final address in one hop, a runtime cycle stops with
|
|
// not_found, and P1 warns about a canonical that redirects.
|
|
func TestOneHop(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
id := env.createPage(t, token, map[string]any{"title": "Alpha", "slug": "a", "published": true})
|
|
if err := env.db.Exec(`UPDATE golem15_seo_settings SET url_slash = 'add', url_lowercase = TRUE WHERE id = 1`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
env.mustRedirect(t, "/old-page", "exact", "/pages/a", 301)
|
|
env.expectRedirect(t, "http://www.example.test//OLD-PAGE", 301, siteURL+"/pages/a/")
|
|
env.expectRedirect(t, "/Old-Page/index.html", 301, siteURL+"/pages/a/")
|
|
env.mustRedirect(t, "/x2", "exact", "/old-page", 301)
|
|
env.expectRedirect(t, "/X2", 301, siteURL+"/pages/a/")
|
|
env.expectAction(t, "/pages/a/", "ok")
|
|
env.expectRedirect(t, "/pages/a", 301, siteURL+"/pages/a/")
|
|
env.expectRedirect(t, "/Pages/A", 301, siteURL+"/pages/a/")
|
|
env.expectAction(t, "/", "ok")
|
|
got := env.resolve(t, "https://attacker.example/pages/a/")
|
|
if got["action"] != "ok" {
|
|
t.Fatalf("a foreign host = %v, want its path resolved on the site", got)
|
|
}
|
|
|
|
// A cycle seeded past the save-time check stops after the hop cap.
|
|
env.seedRedirect(t, "/c1", "exact", "/c2", 301)
|
|
env.seedRedirect(t, "/c2", "exact", "/c1", 301)
|
|
if got := env.resolve(t, "/c1/"); got["action"] != "not_found" || got["status"] != float64(404) {
|
|
t.Fatalf("runtime cycle = %v, want not_found", got)
|
|
}
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
|
|
"title": "Alpha", "slug": "a", "published": true, "seo_canonical": map[string]any{"en": "/old-page"},
|
|
})
|
|
status := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_status?id="+id), token, nil).Body.String()
|
|
if !strings.Contains(status, "The canonical URL “/old-page” redirects to “"+siteURL+"/pages/a/”") {
|
|
t.Fatalf("P1 = %s, want status.canonical_redirected", status)
|
|
}
|
|
}
|