package seo_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"sort"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// The test site the crawler fetches: a fake http.RoundTripper handed to
// the crawler through fetchguard.WithTransport, so the crawler's real
// requests (URL checks, User-Agent, no redirects followed) are asserted
// offline. Paths it does not know answer 404.
// sitePage is one answer of the test site.
type sitePage struct {
status int
body string
header map[string]string
}
// fakeSite serves the test site and records every request URL.
type fakeSite struct {
mu sync.Mutex
pages map[string]sitePage
requests []string
agents []string
}
func newFakeSite() *fakeSite { return &fakeSite{pages: map[string]sitePage{}} }
// set serves body with status at path (path with query).
func (s *fakeSite) set(path string, status int, body string, header ...string) {
s.mu.Lock()
defer s.mu.Unlock()
h := map[string]string{"Content-Type": "text/html; charset=utf-8"}
for i := 0; i+1 < len(header); i += 2 {
h[header[i]] = header[i+1]
}
s.pages[path] = sitePage{status: status, body: body, header: h}
}
// html serves a 200 HTML page.
func (s *fakeSite) html(path, body string) { s.set(path, http.StatusOK, body) }
func (s *fakeSite) RoundTrip(r *http.Request) (*http.Response, error) {
s.mu.Lock()
s.requests = append(s.requests, r.URL.String())
s.agents = append(s.agents, r.Header.Get("User-Agent"))
key := r.URL.EscapedPath()
if r.URL.RawQuery != "" {
key += "?" + r.URL.RawQuery
}
p, ok := s.pages[key]
s.mu.Unlock()
if !ok || !strings.EqualFold(r.URL.Hostname(), "www.example.test") {
p = sitePage{status: http.StatusNotFound, body: "
Not found", header: map[string]string{"Content-Type": "text/html"}}
}
header := http.Header{}
for k, v := range p.header {
header.Set(k, v)
}
return &http.Response{StatusCode: p.status, Header: header, Body: io.NopCloser(strings.NewReader(p.body)), Request: r}, nil
}
// requested reports whether any request went to an URL containing part.
func (s *fakeSite) requested(part string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.requests {
if strings.Contains(u, part) {
return true
}
}
return false
}
// sitemap serves /sitemap.xml as an index of one file listing paths.
func (s *fakeSite) sitemap(paths ...string) {
s.set("/sitemap.xml", http.StatusOK, ``+siteURL+`/sitemap-pages-1.xml`, "Content-Type", "application/xml")
var b strings.Builder
b.WriteString(``)
for _, p := range paths {
b.WriteString("" + siteURL + html.EscapeString(p) + "")
}
b.WriteString("")
s.set("/sitemap-pages-1.xml", http.StatusOK, b.String(), "Content-Type", "application/xml")
}
// doc is one planted page: a complete, clean document (unique title and
// description, a self canonical, one H1 matching the title) that a test
// mutates for the kind it plants.
type doc struct {
path string
lang string
title string
noTitle bool
description string
noDesc bool
canonical string
noCanonical bool
robots string
head string
h1 []string
body string
links []string
}
// render writes the document's HTML.
func (d doc) render() string {
var b strings.Builder
lang := d.lang
if lang == "" {
lang = "en"
}
b.WriteString(``)
title := d.title
if title == "" {
title = "Page " + d.path
}
if !d.noTitle {
b.WriteString("" + html.EscapeString(title) + "")
}
if !d.noDesc {
desc := d.description
if desc == "" {
desc = "About " + d.path
}
b.WriteString(``)
}
if !d.noCanonical {
c := d.canonical
if c == "" {
c = siteURL + d.path
}
b.WriteString(``)
}
if d.robots != "" {
b.WriteString(``)
}
b.WriteString(d.head + "")
h1 := d.h1
if h1 == nil {
h1 = []string{title}
}
for _, h := range h1 {
b.WriteString("
" + html.EscapeString(h) + "
")
}
b.WriteString(d.body)
for _, l := range d.links {
b.WriteString(`
`}
site.plant(home)
site.sitemap(listed...)
site.set("/robots.txt", http.StatusOK, "User-agent: *\nDisallow: /p/blocked\n", "Content-Type", "text/plain")
sort.Strings(want)
return want
}
// fakeRenderer is the test renderer of the comparison: the DOM planted
// for a path, else the raw page (an identical rendering).
type fakeRenderer struct {
site *fakeSite
dom map[string]string
fail error
mu sync.Mutex
calls int
closed bool
}
func (r *fakeRenderer) Render(_ context.Context, pageURL string) (string, error) {
r.mu.Lock()
r.calls++
r.mu.Unlock()
if r.fail != nil {
return "", r.fail
}
u, err := url.Parse(pageURL)
if err != nil {
return "", err
}
if d, ok := r.dom[u.Path]; ok {
return d, nil
}
r.site.mu.Lock()
defer r.site.mu.Unlock()
return r.site.pages[u.Path].body, nil
}
func (r *fakeRenderer) Close() error {
r.closed = true
return nil
}
// auditRenderer renders /p/render with a link and 80 more words, and
// /p/render-h1 with an H1.
func auditRenderer(site *fakeSite) *fakeRenderer {
words := strings.Repeat("rendered word ", 40)
return &fakeRenderer{site: site, dom: map[string]string{
"/p/render": doc{path: "/p/render", body: `
Short raw text.
` + words + `
Only after JavaScript`}.render(),
"/p/render-h1": doc{path: "/p/render-h1"}.render(),
}}
}
// callCtx runs one request with ctx (a crawler transport for a recheck).
func (e *testEnv) callCtx(t *testing.T, ctx context.Context, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader).WithContext(ctx)
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}