Files
bsky-social-app/bskyweb/cmd/bskyweb/render_test.go
T
2026-06-23 12:07:00 -05:00

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