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

1662 lines
61 KiB
Go

package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
comatprototypes "github.com/bluesky-social/indigo/api/atproto"
appbsky "github.com/bluesky-social/indigo/api/bsky"
lexutil "github.com/bluesky-social/indigo/lex/util"
)
// Pointer helpers for optional appbsky fields.
func strPtr(s string) *string { return &s }
func intPtr(i int64) *int64 { return &i }
// newProfileViewDetailed returns a populated profile for tests.
func newProfileViewDetailed() *appbsky.ActorDefs_ProfileViewDetailed {
return &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Handle: "alice.bsky.social",
DisplayName: strPtr("Alice"),
Description: strPtr("just a person"),
Avatar: strPtr("https://cdn.bsky.app/img/avatar/plain/a@jpeg"),
FollowersCount: intPtr(100),
FollowsCount: intPtr(50),
PostsCount: intPtr(200),
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
}
}
// makePostView builds a minimal valid post view for tests.
func makePostView(handle, did, rkey, text string, opts ...func(*appbsky.FeedDefs_PostView)) *appbsky.FeedDefs_PostView {
uri := "at://" + did + "/app.bsky.feed.post/" + rkey
pv := &appbsky.FeedDefs_PostView{
Uri: uri,
Cid: "bafy-test",
IndexedAt: "2024-01-02T03:04:05Z",
LikeCount: intPtr(7),
ReplyCount: intPtr(3),
RepostCount: intPtr(2),
QuoteCount: intPtr(1),
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: did,
Handle: handle,
DisplayName: strPtr("Test User"),
},
Record: &lexutil.LexiconTypeDecoder{
Val: &appbsky.FeedPost{
Text: text,
CreatedAt: "2024-01-02T03:04:05Z",
},
},
}
for _, opt := range opts {
opt(pv)
}
return pv
}
// withImages adds a simple images embed.
func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var images []*appbsky.EmbedImages_ViewImage
for _, t := range thumbs {
images = append(images, &appbsky.EmbedImages_ViewImage{Thumb: t, Fullsize: t + "_full"})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedImages_View: &appbsky.EmbedImages_View{Images: images},
}
}
}
// withGallery adds an app.bsky.embed.gallery view with image items.
func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
}
}
}
// withRecordWithMediaGallery adds a record-with-media embed whose media slot
// is an app.bsky.embed.gallery view.
func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: qDid,
Handle: qHandle,
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
},
},
}
}
}
// withVideo adds a video embed with a thumbnail.
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{Thumbnail: strPtr(thumb)},
}
}
}
// withExternalEmbed adds an external link embed.
func withExternalEmbed(uri, title string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedExternal_View: &appbsky.EmbedExternal_View{
External: &appbsky.EmbedExternal_ViewExternal{Uri: uri, Title: title, Description: "desc"},
},
}
}
}
// withQuotePost adds a record (quote-post) embed of the given target post.
func withQuotePost(qHandle, qDid, qRkey string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecord_View: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: qDid,
Handle: qHandle,
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
}
}
}
// withQuotePostBlocked adds a blocked-record embed (should NOT produce isBasedOn).
func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecord_View: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewBlocked: &appbsky.EmbedRecord_ViewBlocked{
Uri: "at://did:plc:blocked/app.bsky.feed.post/x",
Blocked: true,
},
},
},
}
}
}
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
// strong-ref to the given thread-root post.
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
rec.Reply = &appbsky.FeedPost_ReplyRef{
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
}
}
}
// withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
rec.Labels = &appbsky.FeedPost_Labels{
LabelDefs_SelfLabels: &comatprototypes.LabelDefs_SelfLabels{
Values: []*comatprototypes.LabelDefs_SelfLabel{{Val: val}},
},
}
}
}
// withPostLabel adds a post-view label (as if applied by a labeler) with an
// optional negation flag.
func withPostLabel(val string, neg bool) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
label := &comatprototypes.LabelDefs_Label{Val: val, Src: "did:plc:labeler"}
if neg {
n := true
label.Neg = &n
}
pv.Labels = append(pv.Labels, label)
}
}
// verifierSpec is a compact specifier for a verification entry in fixtures.
type verifierSpec struct {
issuer, handle, displayName string
isValid bool
}
// makeVerificationState builds a VerificationState from the supplied specs.
func makeVerificationState(specs ...verifierSpec) *appbsky.ActorDefs_VerificationState {
state := &appbsky.ActorDefs_VerificationState{
VerifiedStatus: "valid",
TrustedVerifierStatus: "none",
}
for _, s := range specs {
v := &appbsky.ActorDefs_VerificationView{
Issuer: s.issuer,
IsValid: s.isValid,
CreatedAt: "2024-01-01T00:00:00Z",
Uri: "at://" + s.issuer + "/app.bsky.graph.verification/" + s.issuer,
}
if s.handle != "" {
h := s.handle
v.IssuerHandle = &h
}
if s.displayName != "" {
d := s.displayName
v.IssuerDisplayName = &d
}
state.Verifications = append(state.Verifications, v)
}
return state
}
// withVerifications sets pv.Author.Verification.
func withVerifications(state *appbsky.ActorDefs_VerificationState) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
if pv.Author == nil {
return
}
pv.Author.Verification = state
}
}
// unmarshalLD parses the JSON-LD blob produced by buildPostJSONLD.
func unmarshalLD(t *testing.T, s string) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal([]byte(s), &out); err != nil {
t.Fatalf("invalid JSON-LD: %v\n%s", err, s)
}
return out
}
func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
envelope := unmarshalLD(t, out)
if envelope["@context"] != "https://schema.org" {
t.Errorf("@context wrong: %v", envelope["@context"])
}
if envelope["@type"] != "WebPage" {
t.Errorf("@type should be WebPage, got %v", envelope["@type"])
}
if envelope["url"] != canonical {
t.Errorf("url should be canonical, got %v", envelope["url"])
}
main, ok := envelope["mainEntity"].(map[string]any)
if !ok {
t.Fatalf("mainEntity missing")
}
if main["@type"] != "DiscussionForumPosting" {
t.Errorf("mainEntity @type wrong: %v", main["@type"])
}
// nested entity should NOT have @context (Google's preferred shape).
if _, present := main["@context"]; present {
t.Errorf("nested mainEntity should not have @context")
}
if main["url"] != canonical {
t.Errorf("post url wrong: %v", main["url"])
}
if main["identifier"] != pv.Uri {
t.Errorf("identifier should be at-uri: %v", main["identifier"])
}
if main["text"] != "hello" {
t.Errorf("text wrong: %v", main["text"])
}
if main["datePublished"] != "2024-01-02T03:04:05Z" {
t.Errorf("datePublished wrong: %v", main["datePublished"])
}
// commentCount should always be emitted, even at zero.
cc, ok := main["commentCount"].(float64)
if !ok || int64(cc) != 3 {
t.Errorf("commentCount wrong: %v", main["commentCount"])
}
// Author identifier should be the author's DID so a handle change
// doesn't break identity.
bareAuthor, _ := main["author"].(map[string]any)
if bareAuthor == nil {
t.Fatalf("author missing")
}
if bareAuthor["identifier"] != "did:plc:alice" {
t.Errorf("author identifier should be DID, got %v", bareAuthor["identifier"])
}
// no images on bare post
if _, present := main["image"]; present {
t.Errorf("bare post should not have image")
}
if _, present := main["thumbnailUrl"]; present {
t.Errorf("bare post should not have thumbnailUrl")
}
if _, present := main["isBasedOn"]; present {
t.Errorf("bare post should not have isBasedOn")
}
if _, present := main["sharedContent"]; present {
t.Errorf("bare post should not have sharedContent")
}
}
func TestBuildPostJSONLD_WithImages(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))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok {
t.Fatalf("image should be array, got %T", main["image"])
}
if len(imgs) != 2 {
t.Errorf("expected 2 images, got %d", len(imgs))
}
if imgs[0] != thumb1 {
t.Errorf("image[0] should equal first thumb, got %v", imgs[0])
}
if main["thumbnailUrl"] != thumb1 {
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
}
}
func TestBuildPostJSONLD_WithGallery(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"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok {
t.Fatalf("image should be array, got %T", main["image"])
}
if len(imgs) != 3 {
t.Errorf("expected 3 gallery images, got %d", len(imgs))
}
if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
t.Errorf("gallery image[] order wrong: %v", imgs)
}
if main["thumbnailUrl"] != thumb1 {
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
}
}
// Gallery in the media slot of a record-with-media embed should still
// produce og:image / JSON-LD image[]. Quote-post URL still emits
// alongside via isBasedOn.
func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok || len(imgs) != 1 || imgs[0] != thumb {
t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
}
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
}
}
// Forward-compat: nil items, unknown-variant union elements, and empty
// Thumbnail strings must be skipped, not panic or leak as <meta
// property="og:image" content="">. Unknown variants are dropped silently
// so older deploys keep working when new gallery item types ship.
func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{
nil,
{}, // empty union, no variant set
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
},
},
}
got := extractPostMedia(pv, false)
if len(got) != 1 || got[0] != thumb {
t.Errorf("expected single thumb, got %v", got)
}
// All-nil / all-unknown gallery should produce no thumbs (not [""]).
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
},
}
if got := extractPostMedia(pv, false); got != nil {
t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
}
}
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
}
imgs := main["image"].([]any)
if len(imgs) != 1 || imgs[0] != thumb {
t.Errorf("image[0] should be video thumb, got %v", imgs)
}
}
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
}
}
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn")
}
}
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
// it is emitted on the main post; when empty, no isPartOf is present.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isPartOf"] != isPartOf {
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
}
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isPartOf"]; present {
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
}
}
func TestThreadRootURI(t *testing.T) {
// A reply returns its root AT-URI; a non-reply returns "".
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
withReplyRoot("did:plc:root", "rootrkey"))
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
if got := threadRootURI(reply); got != want {
t.Errorf("threadRootURI = %q, want %q", got, want)
}
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
if got := threadRootURI(post); got != "" {
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
}
}
func TestFindRootPostInParents(t *testing.T) {
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
rootURI := rootPost.Uri
// tvp wraps a PostView, optionally chaining to a parent thread node.
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
if parent != nil {
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
}
return node
}
t.Run("direct reply, parent is root", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post, got %v", got)
}
})
t.Run("multi-level chain", func(t *testing.T) {
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post in chain, got %v", got)
}
})
t.Run("root absent, chain truncated", func(t *testing.T) {
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when root absent, got %v", got)
}
})
t.Run("chain broken by blocked parent", func(t *testing.T) {
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
// breaking the walk before the root.
leaf := &appbsky.FeedDefs_ThreadViewPost{
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
},
}
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
}
})
t.Run("empty rootURI", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, ""); got != nil {
t.Errorf("expected nil for empty rootURI, got %v", got)
}
})
}
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
// Replies surfaced under the main post as comment[] are Comment nodes and
// never carry isPartOf, even when the main post has one.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
if _, present := c["isPartOf"]; present {
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
}
}
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any)
if !ok {
t.Fatalf("sharedContent missing")
}
if sc["@type"] != "WebPage" {
t.Errorf("sharedContent type wrong: %v", sc["@type"])
}
if sc["url"] != "https://www.spiegel.de/article" {
t.Errorf("sharedContent url wrong: %v", sc["url"])
}
}
func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image")
}
if _, present := main["thumbnailUrl"]; present {
t.Errorf("hidden-embed post should not emit thumbnailUrl")
}
}
// Symmetric guard for the gallery extraction path. Functionally redundant
// with the early-return at the top of extractPostMedia, but exists so the
// hide-embed contract is asserted directly against the gallery branch -
// catches anyone who later moves the embedHidden check inside an
// embed-shape branch.
func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
}
if _, present := main["thumbnailUrl"]; present {
t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
}
}
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["text"] != tricky {
t.Errorf("text round-trip failed: got %q want %q", main["text"], tricky)
}
// Literal </script> would break out of the script tag.
if strings.Contains(out, "</script>") {
t.Errorf("output contains literal </script>, would break HTML embedding")
}
}
func TestBuildPostJSONLD_Comments(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 14
// 12 valid replies + 1 not-found + 1 blocked. Cap is maxComments=10.
const validReplies = 12
var replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem
for i := 0; i < validReplies; i++ {
rkey := fmt.Sprintf("reply%02d", i)
reply := makePostView(fmt.Sprintf("rep%02d.bsky.social", i),
fmt.Sprintf("did:plc:rep%02d", i), rkey, "reply text")
replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply},
})
}
replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
FeedDefs_NotFoundPost: &appbsky.FeedDefs_NotFoundPost{Uri: "at://x/y/z"},
})
replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
})
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 {
t.Errorf("commentCount should reflect ReplyCount, got %v", cc)
}
comments, ok := main["comment"].([]any)
if !ok {
t.Fatalf("comment array missing")
}
if len(comments) != maxComments {
t.Errorf("expected %d comments (capped from %d valid), got %d", maxComments, validReplies, len(comments))
}
// FIFO order: first valid reply, not the not-found at the end.
first := comments[0].(map[string]any)
if first["identifier"] != "at://did:plc:rep00/app.bsky.feed.post/reply00" {
t.Errorf("first comment should be reply00, got %v", first["identifier"])
}
// Comment type, no nested comment[] / isBasedOn / sharedContent.
for i, c := range comments {
cm := c.(map[string]any)
if cm["@type"] != "Comment" {
t.Errorf("reply %d wrong type: got %v, want Comment", i, cm["@type"])
}
if _, present := cm["comment"]; present {
t.Errorf("reply %d should not have nested comment[]", i)
}
if _, present := cm["isBasedOn"]; present {
t.Errorf("reply %d should not have isBasedOn", i)
}
if _, present := cm["sharedContent"]; present {
t.Errorf("reply %d should not have sharedContent", i)
}
if cm["url"] == nil || cm["identifier"] == nil {
t.Errorf("reply %d missing url/identifier", i)
}
}
}
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope
// and post URLs always agree.
if main["url"] != fallback {
t.Errorf("mainEntity.url should fall back to canonical URL, got %v", main["url"])
}
if envelope["url"] != fallback {
t.Errorf("envelope.url should equal canonical URL, got %v", envelope["url"])
}
if main["url"] != envelope["url"] {
t.Errorf("envelope.url and mainEntity.url disagree: %v vs %v",
envelope["url"], main["url"])
}
// identifier (AT-URI) stays stable across handle changes.
if main["identifier"] != pv.Uri {
t.Errorf("identifier should still be the AT-URI, got %v", main["identifier"])
}
// Author URL omitted (no usable handle).
author := main["author"].(map[string]any)
if _, present := author["url"]; present {
t.Errorf("handle.invalid author should not produce author.url")
}
}
// envelope.url and mainEntity.url must always agree.
func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
cases := []struct {
name, handle, did, rkey, canonical string
}{
{
name: "handle form",
handle: "alice.bsky.social",
did: "did:plc:alice",
rkey: "abc",
canonical: "https://bsky.app/profile/alice.bsky.social/post/abc",
},
{
name: "handle.invalid falls back to canonical",
handle: "handle.invalid",
did: "did:plc:alice",
rkey: "abc",
canonical: "https://bsky.app/profile/did:plc:alice/post/abc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical {
t.Errorf("envelope.url = %v, want %v", env["url"], tc.canonical)
}
if main["url"] != tc.canonical {
t.Errorf("mainEntity.url = %v, want %v", main["url"], tc.canonical)
}
})
}
}
func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
t.Errorf("expected error for nil-author post, got nil")
}
}
func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
// Reply with nil Author should be dropped, not emitted with empty @type.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 2
goodReply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good")
badReply := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "bad")
badReply.Author = nil
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
}
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
comments, _ := main["comment"].([]any)
if len(comments) != 1 {
t.Errorf("expected 1 comment (nil-Author dropped), got %d", len(comments))
}
}
func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
// Comments with media embeds should expose image[] and thumbnailUrl.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:bob/x@jpg"
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "with image",
withImages(thumb))
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
}
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any)
if !ok || len(imgs) != 1 || imgs[0] != thumb {
t.Errorf("comment image[] wrong: %v", c["image"])
}
if c["thumbnailUrl"] != thumb {
t.Errorf("comment thumbnailUrl wrong: %v", c["thumbnailUrl"])
}
}
func TestBuildProfileJSONLD_Basic(t *testing.T) {
pv := &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Handle: "alice.bsky.social",
DisplayName: strPtr("Alice"),
Description: strPtr("just a person"),
Avatar: strPtr("https://cdn.bsky.app/img/avatar/plain/a@jpeg"),
FollowersCount: intPtr(100),
FollowsCount: intPtr(50),
PostsCount: intPtr(200),
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
}
out, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
page := unmarshalLD(t, out)
if page["@context"] != "https://schema.org" {
t.Errorf("@context wrong")
}
if page["@type"] != "ProfilePage" {
t.Errorf("@type wrong: %v", page["@type"])
}
main := page["mainEntity"].(map[string]any)
if main["@type"] != "Person" {
t.Errorf("mainEntity @type wrong: %v", main["@type"])
}
if main["name"] != "Alice" {
t.Errorf("name wrong: %v", main["name"])
}
if main["alternateName"] != "@alice.bsky.social" {
t.Errorf("alternateName wrong: %v", main["alternateName"])
}
if main["identifier"] != "did:plc:alice" {
t.Errorf("identifier wrong: %v", main["identifier"])
}
if _, present := page["hasPart"]; present {
t.Errorf("empty recentPosts should not produce hasPart")
}
}
func TestBuildProfileJSONLD_HasPart(t *testing.T) {
pv := &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice", Handle: "alice.bsky.social",
DisplayName: strPtr("Alice"),
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
}
var posts []*appbsky.FeedDefs_PostView
for i := 0; i < 12; i++ {
posts = append(posts, makePostView("alice.bsky.social", "did:plc:alice",
"r"+string(rune('0'+i)), "post"))
}
out, _ := buildProfileJSONLD(pv, posts, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp, ok := page["hasPart"].([]any)
if !ok {
t.Fatalf("hasPart missing")
}
if len(hp) != maxRecentPosts {
t.Errorf("hasPart should be capped at %d, got %d", maxRecentPosts, len(hp))
}
first := hp[0].(map[string]any)
if first["@type"] != "DiscussionForumPosting" {
t.Errorf("hasPart entries should be DiscussionForumPosting, got %v", first["@type"])
}
if _, present := first["@context"]; present {
t.Errorf("nested hasPart entries should not have @context")
}
}
func TestBuildProfileJSONLD_HasPartLabelFiltered(t *testing.T) {
// Recent posts carrying hideReplyLabels or hideEmbedLabels are dropped
// from hasPart entirely. Negation is honored on post-view labels.
pv := &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice", Handle: "alice.bsky.social",
DisplayName: strPtr("Alice"),
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
}
good := makePostView("alice.bsky.social", "did:plc:alice", "good", "ok")
hidden := makePostView("alice.bsky.social", "did:plc:alice", "hide", "hidden",
withPostLabel("!hide", false))
spam := makePostView("alice.bsky.social", "did:plc:alice", "spam", "spam",
withSelfLabel("spam"))
embedHide := makePostView("alice.bsky.social", "did:plc:alice", "harm", "embed-only",
withPostLabel("self-harm", false))
negated := makePostView("alice.bsky.social", "did:plc:alice", "neg", "negated",
withPostLabel("!hide", true))
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{
good, hidden, spam, embedHide, negated,
}, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp, _ := page["hasPart"].([]any)
got := make(map[string]bool, len(hp))
for _, e := range hp {
got[e.(map[string]any)["identifier"].(string)] = true
}
if !got[good.Uri] {
t.Errorf("expected unlabeled post in hasPart")
}
if !got[negated.Uri] {
t.Errorf("negated hide label should not gate; expected post in hasPart")
}
if got[hidden.Uri] {
t.Errorf("post with !hide label should be dropped from hasPart")
}
if got[spam.Uri] {
t.Errorf("self-labeled spam post should be dropped from hasPart")
}
if got[embedHide.Uri] {
t.Errorf("post with hideEmbedLabels label should be dropped from hasPart")
}
}
func TestBskyPostURL(t *testing.T) {
tests := []struct {
name, handle, rkey, want string
}{
{"valid", "alice.bsky.social", "abc", "https://bsky.app/profile/alice.bsky.social/post/abc"},
{"empty handle", "", "abc", ""},
{"handle.invalid", "handle.invalid", "abc", ""},
{"empty rkey", "alice.bsky.social", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := bskyPostURL(tt.handle, tt.rkey); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestBskyPostURLFromATURI(t *testing.T) {
tests := []struct {
name, handle, atURI, want string
}{
{"valid", "alice.bsky.social", "at://did:plc:alice/app.bsky.feed.post/abc", "https://bsky.app/profile/alice.bsky.social/post/abc"},
{"empty handle", "", "at://did:plc:alice/app.bsky.feed.post/abc", ""},
{"handle.invalid", "handle.invalid", "at://did:plc:alice/app.bsky.feed.post/abc", ""},
{"bad uri", "alice.bsky.social", "not-an-aturi", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := bskyPostURLFromATURI(tt.handle, tt.atURI); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestBskyProfileURL(t *testing.T) {
if bskyProfileURL("alice.bsky.social") != "https://bsky.app/profile/alice.bsky.social" {
t.Errorf("valid handle wrong")
}
if bskyProfileURL("handle.invalid") != "" {
t.Errorf("handle.invalid should produce empty")
}
if bskyProfileURL("") != "" {
t.Errorf("empty handle should produce empty")
}
}
// buildReplies wraps a slice of post views into ThreadViewPost reply elements.
func buildReplies(posts ...*appbsky.FeedDefs_PostView) []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem {
out := make([]*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, 0, len(posts))
for _, p := range posts {
out = append(out, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: p},
})
}
return out
}
// commentIdentifiers extracts the identifier of each entry in mainEntity.comment.
func commentIdentifiers(t *testing.T, out string) []string {
t.Helper()
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
raw, _ := main["comment"].([]any)
ids := make([]string, 0, len(raw))
for _, c := range raw {
cm := c.(map[string]any)
if id, ok := cm["identifier"].(string); ok {
ids = append(ids, id)
}
}
return ids
}
func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
// A reply carrying a hideReplyLabels post-view label is dropped from comment[].
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
}
}
func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
// A reply self-labeling itself with a hideReplyLabels value is dropped.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids)
}
}
func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// A reply with a hideEmbedLabels label (e.g., porn) is also dropped.
// hideEmbedLabels is consulted in addition to hideReplyLabels for replies.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
// "self-harm" is in hideEmbedLabels but not hideReplyLabels — verifies the
// union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
}
}
func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
// A negated post-view label should not gate the reply.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids)
}
}
func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any)
if !ok {
t.Fatalf("comment author missing")
}
if auth["identifier"] != "did:plc:bob" {
t.Errorf("reply author identifier should be DID, got %v", auth["identifier"])
}
}
func TestBuildReviewedBy_NilAndEmpty(t *testing.T) {
if got := buildReviewedBy(nil); got != nil {
t.Errorf("nil state should yield nil, got %v", got)
}
empty := &appbsky.ActorDefs_VerificationState{}
if got := buildReviewedBy(empty); got != nil {
t.Errorf("empty Verifications should yield nil, got %v", got)
}
allInvalid := makeVerificationState(
verifierSpec{issuer: "did:plc:v1", handle: "v1.example.com", displayName: "V One", isValid: false},
verifierSpec{issuer: "did:plc:v2", handle: "v2.example.com", displayName: "V Two", isValid: false},
)
if got := buildReviewedBy(allInvalid); got != nil {
t.Errorf("all-invalid state should yield nil, got %v", got)
}
}
func TestBuildReviewedBy_FiltersInvalid(t *testing.T) {
state := makeVerificationState(
verifierSpec{issuer: "did:plc:v1", handle: "v1.example.com", displayName: "V One", isValid: true},
verifierSpec{issuer: "did:plc:v2", handle: "v2.example.com", displayName: "V Two", isValid: false},
verifierSpec{issuer: "did:plc:v3", handle: "v3.example.com", displayName: "V Three", isValid: true},
verifierSpec{issuer: "", handle: "noid.example.com", displayName: "No Issuer", isValid: true},
)
got := buildReviewedBy(state)
if len(got) != 2 {
t.Fatalf("expected 2 valid verifiers, got %d: %v", len(got), got)
}
if got[0].Identifier != "did:plc:v1" {
t.Errorf("FIFO order broken; first identifier = %q", got[0].Identifier)
}
if got[1].Identifier != "did:plc:v3" {
t.Errorf("expected v3 at index 1, got %q", got[1].Identifier)
}
}
func TestBuildReviewedBy_NameFallbacks(t *testing.T) {
cases := []struct {
name string
spec verifierSpec
wantName, wantAlternateName, wantURL, wantIdentif string
}{
{
name: "DisplayName + handle",
spec: verifierSpec{issuer: "did:plc:v1", handle: "alice.example.com", displayName: "Alice Verifier", isValid: true},
wantName: "Alice Verifier",
wantAlternateName: "@alice.example.com",
wantURL: "https://bsky.app/profile/alice.example.com",
wantIdentif: "did:plc:v1",
},
{
name: "handle only",
spec: verifierSpec{issuer: "did:plc:v2", handle: "bob.example.com", isValid: true},
wantName: "@bob.example.com",
wantAlternateName: "",
wantURL: "https://bsky.app/profile/bob.example.com",
wantIdentif: "did:plc:v2",
},
{
name: "DisplayName only (no handle)",
spec: verifierSpec{issuer: "did:plc:v3", displayName: "Carol Verifier", isValid: true},
wantName: "Carol Verifier",
wantAlternateName: "",
wantURL: "https://bsky.app/profile/did:plc:v3",
wantIdentif: "did:plc:v3",
},
{
name: "DisplayName + handle.invalid",
spec: verifierSpec{issuer: "did:plc:v4", handle: "handle.invalid", displayName: "Dave Verifier", isValid: true},
wantName: "Dave Verifier",
wantAlternateName: "",
wantURL: "https://bsky.app/profile/did:plc:v4",
wantIdentif: "did:plc:v4",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := buildReviewedBy(makeVerificationState(tc.spec))
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
v := got[0]
if v.Type != "Person" {
t.Errorf("@type = %q, want Person", v.Type)
}
if v.Name != tc.wantName {
t.Errorf("name = %q, want %q", v.Name, tc.wantName)
}
if v.AlternateName != tc.wantAlternateName {
t.Errorf("alternateName = %q, want %q", v.AlternateName, tc.wantAlternateName)
}
if v.URL != tc.wantURL {
t.Errorf("url = %q, want %q", v.URL, tc.wantURL)
}
if v.Identifier != tc.wantIdentif {
t.Errorf("identifier = %q, want %q", v.Identifier, tc.wantIdentif)
}
})
}
}
func TestBuildReviewedBy_Cap(t *testing.T) {
specs := make([]verifierSpec, 0, 12)
for i := 0; i < 12; i++ {
specs = append(specs, verifierSpec{
issuer: fmt.Sprintf("did:plc:v%02d", i),
handle: fmt.Sprintf("v%02d.example.com", i),
displayName: fmt.Sprintf("V%02d", i),
isValid: true,
})
}
got := buildReviewedBy(makeVerificationState(specs...))
if len(got) != maxReviewedBy {
t.Errorf("expected cap at %d, got %d", maxReviewedBy, len(got))
}
if got[0].Identifier != "did:plc:v00" {
t.Errorf("first kept entry should be v00, got %q", got[0].Identifier)
}
}
func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
state := makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
auth := main["author"].(map[string]any)
rb, ok := auth["reviewedBy"].([]any)
if !ok {
t.Fatalf("post author should have reviewedBy, got %v", auth["reviewedBy"])
}
if len(rb) != 1 {
t.Fatalf("expected 1 verifier, got %d", len(rb))
}
v := rb[0].(map[string]any)
if v["@type"] != "Person" {
t.Errorf("verifier @type = %v, want Person", v["@type"])
}
if v["name"] != "Trusted Verifier" {
t.Errorf("verifier name = %v", v["name"])
}
if v["identifier"] != "did:plc:verifier1" {
t.Errorf("verifier identifier = %v", v["identifier"])
}
if v["url"] != "https://bsky.app/profile/verifier.example.com" {
t.Errorf("verifier url = %v", v["url"])
}
}
func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
// Replies do not surface verifications even when the reply author
// carries Verification.
state := makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any)
if _, present := auth["reviewedBy"]; present {
t.Errorf("reply author must not carry reviewedBy, got %v", auth["reviewedBy"])
}
}
func TestBuildProfileJSONLD_MainEntityReviewedBy(t *testing.T) {
pv := newProfileViewDetailed()
pv.Verification = makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
out, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
rb, ok := main["reviewedBy"].([]any)
if !ok {
t.Fatalf("profile mainEntity should have reviewedBy, got %v", main["reviewedBy"])
}
v := rb[0].(map[string]any)
if v["identifier"] != "did:plc:verifier1" {
t.Errorf("verifier identifier = %v", v["identifier"])
}
if v["url"] != "https://bsky.app/profile/verifier.example.com" {
t.Errorf("verifier url = %v", v["url"])
}
}
func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
// Recent posts inherit verifications via their post-view Author.
pv := newProfileViewDetailed()
state := makeVerificationState(verifierSpec{
issuer: "did:plc:verifier1",
handle: "verifier.example.com",
displayName: "Trusted Verifier",
isValid: true,
})
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "hi",
withVerifications(state))
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp := page["hasPart"].([]any)
if len(hp) != 1 {
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
}
auth := hp[0].(map[string]any)["author"].(map[string]any)
rb, ok := auth["reviewedBy"].([]any)
if !ok || len(rb) != 1 {
t.Fatalf("hasPart author should carry reviewedBy, got %v", auth["reviewedBy"])
}
if rb[0].(map[string]any)["identifier"] != "did:plc:verifier1" {
t.Errorf("verifier identifier wrong: %v", rb[0])
}
}
// videoEmbedOpts configures withVideoFull. Zero values mean "not set".
type videoEmbedOpts struct {
thumbnail string
playlist string
alt string
width int64
height int64
hasAspect bool
recordMedia bool // nest under EmbedRecordWithMedia_View.Media
}
// withVideoFull installs a fully-specified video embed for VideoObject tests.
func withVideoFull(o videoEmbedOpts) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
v := &appbsky.EmbedVideo_View{Playlist: o.playlist}
if o.thumbnail != "" {
v.Thumbnail = strPtr(o.thumbnail)
}
if o.alt != "" {
v.Alt = strPtr(o.alt)
}
if o.hasAspect {
v.AspectRatio = &appbsky.EmbedDefs_AspectRatio{Width: o.width, Height: o.height}
}
if o.recordMedia {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://did:plc:quoted/app.bsky.feed.post/q",
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:quoted",
Handle: "quoted.bsky.social",
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedVideo_View: v,
},
},
}
} else {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: v,
}
}
}
}
func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
playlist := "https://video.bsky.app/v/did:plc:alice/v/playlist.m3u8"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch this",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "A trip to the park",
hasAspect: true, width: 16, height: 9,
}))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing from mainEntity")
}
if video["@type"] != "VideoObject" {
t.Errorf("@type = %v, want VideoObject", video["@type"])
}
if video["name"] != "A trip to the park" {
t.Errorf("name = %v, want alt text", video["name"])
}
if video["description"] != "watch this" {
t.Errorf("description = %v, want post text", video["description"])
}
if video["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
}
if video["uploadDate"] != pv.IndexedAt {
t.Errorf("uploadDate = %v, want %v", video["uploadDate"], pv.IndexedAt)
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
if video["embedUrl"] != canonical {
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
}
if w, _ := video["width"].(float64); int64(w) != 16 {
t.Errorf("width = %v, want 16", video["width"])
}
if h, _ := video["height"].(float64); int64(h) != 9 {
t.Errorf("height = %v, want 9", video["height"])
}
// post thumbnailUrl should equal the video thumb (byte-equal og:image).
if main["thumbnailUrl"] != thumb {
t.Errorf("post thumbnailUrl = %v, want %v", main["thumbnailUrl"], thumb)
}
}
func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
// No alt text -> "Video by @<handle>".
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no alt here",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing")
}
if video["name"] != "Video by @alice.bsky.social" {
t.Errorf("name fallback = %v", video["name"])
}
}
func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
// handle.invalid + no alt -> generic fallback.
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "x",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" {
t.Errorf("name fallback = %v, want generic", video["name"])
}
}
func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
// Empty post text -> description falls back to name so Google's video
// rich-result requirement (description present) is satisfied.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["description"] != "scenic clip" {
t.Errorf("description fallback = %v, want name", video["description"])
}
}
func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if _, present := video["width"]; present {
t.Errorf("width should be omitted when AspectRatio missing")
}
if _, present := video["height"]; present {
t.Errorf("height should be omitted when AspectRatio missing")
}
}
func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
// No playlist -> VideoObject suppressed; image[]/thumbnailUrl still set
// from the video thumb (existing behavior).
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject")
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl should still be set from video thumb, got %v", main["thumbnailUrl"])
}
}
func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
// Self-labeled hide drops the video, parallel to the image-hide test.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withVideoFull(videoEmbedOpts{
thumbnail: "https://cdn.bsky.app/img/x@jpeg",
playlist: "https://video.bsky.app/p.m3u8",
alt: "should be dropped",
}),
withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video")
}
}
func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
playlist := "https://video.bsky.app/p.m3u8"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+video",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3,
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing on record-with-media post")
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
if video["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
}
// quote-post still surfaces via isBasedOn alongside the video.
if main["isBasedOn"] != "https://bsky.app/profile/quoted.bsky.social/post/q" {
t.Errorf("isBasedOn = %v", main["isBasedOn"])
}
}
func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:bob/v@jpeg"
playlist := "https://video.bsky.app/bob.m3u8"
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "watch",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
if !ok {
t.Fatalf("reply video missing")
}
if video["@type"] != "VideoObject" {
t.Errorf("reply video @type = %v", video["@type"])
}
if video["name"] != "bob's clip" {
t.Errorf("reply video name = %v", video["name"])
}
if video["contentUrl"] != playlist {
t.Errorf("reply video contentUrl = %v", video["contentUrl"])
}
if video["embedUrl"] != "https://bsky.app/profile/bob.bsky.social/post/rep1" {
t.Errorf("reply video embedUrl = %v", video["embedUrl"])
}
}
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("post without video should not include video field")
}
}
func TestBuildProfileJSONLD_HasPartVideo(t *testing.T) {
pv := newProfileViewDetailed()
playlist := "https://video.bsky.app/p.m3u8"
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "see",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "alt",
}))
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp := page["hasPart"].([]any)
if len(hp) != 1 {
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
}
video, ok := hp[0].(map[string]any)["video"].(map[string]any)
if !ok {
t.Fatalf("hasPart entry should carry video, got %v", hp[0])
}
if video["contentUrl"] != playlist {
t.Errorf("hasPart video contentUrl = %v", video["contentUrl"])
}
if video["embedUrl"] != "https://bsky.app/profile/alice.bsky.social/post/rp1" {
t.Errorf("hasPart video embedUrl = %v", video["embedUrl"])
}
}
// handle.invalid authors must still produce a non-empty embedUrl on the
// VideoObject. The handle-form URL is unusable, so we fall back to the
// DID-form URL derived from the AT-URI authority. Without this fallback,
// omitempty drops embedUrl and Google's video indexer loses the canonical
// page reference.
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist := "https://video.bsky.app/p.m3u8"
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "watch",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "scenic clip",
}))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing on handle.invalid post")
}
if video["embedUrl"] != canonical {
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
}
// Same fallback applies to videos on replies whose author is handle.invalid.
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
playlist := "https://video.bsky.app/bob.m3u8"
reply := makePostView("handle.invalid", "did:plc:bob", "rep1", "watch",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
if !ok {
t.Fatalf("reply video missing")
}
want := "https://bsky.app/profile/did:plc:bob/post/rep1"
if video["embedUrl"] != want {
t.Errorf("reply video embedUrl = %v, want %v", video["embedUrl"], want)
}
}