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