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)`) // 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, ``) { 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, ``) { 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, ``) { 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, ``) { t.Errorf("og:image[0] not found in rendered HTML for gallery post") } if !strings.Contains(html, ``) { 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, ``) { 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 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, ``) { t.Errorf("og:url should equal canonical URL when set; got:\n%s", html) } if !strings.Contains(html, ``) { t.Errorf("canonical link missing or wrong:\n%s", html) } // DID-form request URI must not leak into og:url. if strings.Contains(html, ``) { 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, ``) { t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html) } if !strings.Contains(html, ``) { 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, ``) { 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, ``) { 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, `