300 lines
12 KiB
Go
300 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/bluesky-social/social-app/bskyweb"
|
|
"github.com/flosch/pongo2/v6"
|
|
)
|
|
|
|
// renderTemplate executes a template using the live renderer and returns
|
|
// the output. base.html includes templates/scripts.html, which is generated
|
|
// by `yarn build-web`; skip the test if it's missing.
|
|
func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
|
|
t.Helper()
|
|
if _, err := bskyweb.TemplateFS.ReadFile("templates/scripts.html"); err != nil {
|
|
t.Skip("templates/scripts.html not present (run yarn build-web first)")
|
|
}
|
|
r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
|
|
tmpl, err := r.TemplateSet.FromCache(name)
|
|
if err != nil {
|
|
t.Fatalf("template load %q: %v", name, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteWriter(ctx, &buf); err != nil {
|
|
t.Fatalf("template execute %q: %v", name, err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
var jsonLDRe = regexp.MustCompile(`(?s)<script type="application/ld\+json">(.*?)</script>`)
|
|
|
|
// extractJSONLD pulls out the body of the application/ld+json script tag.
|
|
func extractJSONLD(t *testing.T, html string) string {
|
|
t.Helper()
|
|
m := jsonLDRe.FindStringSubmatch(html)
|
|
if m == nil {
|
|
t.Fatalf("no application/ld+json script tag in rendered HTML:\n%s", html)
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
|
|
func TestRenderBase_NoindexMeta(t *testing.T) {
|
|
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
|
|
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
|
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
|
|
}
|
|
}
|
|
|
|
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
|
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"postJSONLD": ld,
|
|
})
|
|
|
|
body := extractJSONLD(t, html)
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
|
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
|
|
}
|
|
if parsed["@type"] != "WebPage" {
|
|
t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
|
|
}
|
|
// Canonical link should be the handle-form URL.
|
|
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
|
|
t.Errorf("canonical link missing or wrong:\n%s", html)
|
|
}
|
|
}
|
|
|
|
func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
|
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
|
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
|
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"postJSONLD": ld,
|
|
"imgThumbUrls": []string{thumb1, thumb2},
|
|
})
|
|
|
|
// og:image and JSON-LD image[] must be byte-identical.
|
|
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
|
t.Errorf("og:image[0] not found in rendered HTML")
|
|
}
|
|
body := extractJSONLD(t, html)
|
|
var parsed map[string]any
|
|
_ = json.Unmarshal([]byte(body), &parsed)
|
|
main := parsed["mainEntity"].(map[string]any)
|
|
imgs := main["image"].([]any)
|
|
if imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
|
|
t.Errorf("JSON-LD image strings drifted from og:image; image[0]=%v thumbnailUrl=%v",
|
|
imgs[0], main["thumbnailUrl"])
|
|
}
|
|
}
|
|
|
|
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
|
|
// contract that legacy images posts do. Regression guard for the
|
|
// app.bsky.embed.gallery extraction path.
|
|
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
|
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
|
|
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
|
thumbs := extractPostMedia(pv, false)
|
|
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"postJSONLD": ld,
|
|
"imgThumbUrls": thumbs,
|
|
})
|
|
|
|
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
|
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
|
|
}
|
|
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
|
|
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
|
|
}
|
|
body := extractJSONLD(t, html)
|
|
var parsed map[string]any
|
|
_ = json.Unmarshal([]byte(body), &parsed)
|
|
main := parsed["mainEntity"].(map[string]any)
|
|
imgs := main["image"].([]any)
|
|
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
|
|
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
|
|
imgs, main["thumbnailUrl"])
|
|
}
|
|
}
|
|
|
|
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
|
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
|
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
|
"postJSONLD": ld,
|
|
})
|
|
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
|
|
t.Errorf("expected canonicalize_url filter to strip query; got:\n%s", html)
|
|
}
|
|
}
|
|
|
|
func TestRenderProfile_EmitsJSONLD(t *testing.T) {
|
|
pv := newProfileViewDetailed()
|
|
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
html := renderTemplate(t, "profile.html", pongo2.Context{
|
|
"profileView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social",
|
|
"profileJSONLD": ld,
|
|
})
|
|
|
|
body := extractJSONLD(t, html)
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
|
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
|
|
}
|
|
if parsed["@type"] != "ProfilePage" {
|
|
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
|
|
}
|
|
}
|
|
|
|
// Regression: auth-required profiles must still emit ProfilePage JSON-LD
|
|
// (without hasPart). Previously regressed when WebProfile short-circuited
|
|
// before buildProfileJSONLD.
|
|
func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
|
pv := newProfileViewDetailed()
|
|
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
html := renderTemplate(t, "profile.html", pongo2.Context{
|
|
"profileView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
|
"requiresAuth": true,
|
|
"profileJSONLD": ld,
|
|
})
|
|
|
|
body := extractJSONLD(t, html)
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
|
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
|
|
}
|
|
if parsed["@type"] != "ProfilePage" {
|
|
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
|
|
}
|
|
if _, present := parsed["hasPart"]; present {
|
|
t.Errorf("auth-required profile should not have hasPart")
|
|
}
|
|
}
|
|
|
|
// og:url and <link rel="canonical"> must emit the same URL.
|
|
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
|
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
|
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/did:plc:alice/post/abc123",
|
|
"canonicalURL": canonical,
|
|
"postJSONLD": ld,
|
|
})
|
|
if !strings.Contains(html, `<meta property="og:url" content="`+canonical+`">`) {
|
|
t.Errorf("og:url should equal canonical URL when set; got:\n%s", html)
|
|
}
|
|
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
|
|
t.Errorf("canonical link missing or wrong:\n%s", html)
|
|
}
|
|
// DID-form request URI must not leak into og:url.
|
|
if strings.Contains(html, `<meta property="og:url" content="https://bsky.app/profile/did:plc:alice/post/abc123">`) {
|
|
t.Errorf("og:url should not echo DID-form request URI when canonical is set")
|
|
}
|
|
}
|
|
|
|
// og:video must emit even when there is no thumbnail. Previously the
|
|
// {% if videoUrl %} block was nested inside {% if imgThumbUrls %}, so a
|
|
// video without a thumbnail dropped og:video entirely.
|
|
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
|
|
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
|
videoURL := "https://video.bsky.app/v.m3u8"
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"postJSONLD": ld,
|
|
"videoUrl": videoURL,
|
|
"videoType": "application/x-mpegURL",
|
|
})
|
|
if !strings.Contains(html, `<meta property="og:video" content="`+videoURL+`">`) {
|
|
t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html)
|
|
}
|
|
if !strings.Contains(html, `<meta property="og:video:type" content="application/x-mpegURL">`) {
|
|
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
|
|
}
|
|
}
|
|
|
|
// Auth-required posts must emit noindex,nofollow so the stub page (no body
|
|
// text, no comments) is not indexed.
|
|
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"requiresAuth": true,
|
|
"profileHandle": "alice.bsky.social",
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"noindex": true,
|
|
"nofollow": true,
|
|
})
|
|
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
|
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
|
|
}
|
|
}
|
|
|
|
// Auth-required profiles must emit noindex,nofollow.
|
|
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
|
|
pv := newProfileViewDetailed()
|
|
html := renderTemplate(t, "profile.html", pongo2.Context{
|
|
"profileView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
|
"requiresAuth": true,
|
|
"noindex": true,
|
|
"nofollow": true,
|
|
})
|
|
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
|
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
|
|
}
|
|
}
|
|
|
|
// Public posts must NOT emit a robots meta tag. Guards against an accidental
|
|
// flip of the noindex flag for indexable pages.
|
|
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
|
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
|
"postView": pv,
|
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
|
"postJSONLD": ld,
|
|
})
|
|
if strings.Contains(html, `<meta name="robots"`) {
|
|
t.Errorf("public post should not emit robots meta; got:\n%s", html)
|
|
}
|
|
}
|