- TTL-cache DID/actor lookups (24h) under a dedicated mutex, with a separate refresh lock so auth refreshes never fight the cache - clamp zero/excessive limits (maxBskyFeedLimit, maxAuthorFeedPages) and stop timeline pagination early once a bound or applimit is hit - restrict feed URIs to http(s) and validate AT-URIs in GetPost - derive the profile URL from the handle instead of the raw URI
945 lines
28 KiB
Go
945 lines
28 KiB
Go
package bluesky
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/api/atproto"
|
|
"github.com/bluesky-social/indigo/api/bsky"
|
|
"github.com/bluesky-social/indigo/lex/util"
|
|
"github.com/bluesky-social/indigo/xrpc"
|
|
)
|
|
|
|
// setupMockServer creates and returns a new httptest.Server that mocks the Bluesky API.
|
|
func setupMockServer() *httptest.Server {
|
|
mux := http.NewServeMux()
|
|
|
|
// Mock for atproto.ServerCreateSession
|
|
mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) {
|
|
// Simulate an error if the handle is "fail.test"
|
|
var input atproto.ServerCreateSession_Input
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if input.Identifier == "fail.test" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`)
|
|
return
|
|
}
|
|
|
|
// Successful response
|
|
resp := atproto.ServerCreateSession_Output{
|
|
AccessJwt: "test_access_jwt",
|
|
RefreshJwt: "test_refresh_jwt",
|
|
Handle: "test.handle",
|
|
Did: "did:plc:test",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for bsky.FeedGetAuthorFeed
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
|
actor := r.URL.Query().Get("actor")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit, _ := strconv.ParseInt(limitStr, 10, 64)
|
|
|
|
if actor == "" {
|
|
http.Error(w, `{"error": "MissingParameter", "message": "actor parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create a mock feed
|
|
feed := []*bsky.FeedDefs_FeedViewPost{}
|
|
for i := 0; i < int(limit); i++ {
|
|
post := &bsky.FeedDefs_PostView{
|
|
Uri: fmt.Sprintf("at://%s/app.bsky.feed.post/%d", actor, i),
|
|
Cid: fmt.Sprintf("bafy...%d", i),
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{
|
|
Did: "did:plc:test",
|
|
Handle: actor,
|
|
},
|
|
Record: &util.LexiconTypeDecoder{
|
|
Val: &bsky.FeedPost{
|
|
Text: fmt.Sprintf("This is post number %d", i),
|
|
CreatedAt: "2023-10-27T12:00:00Z",
|
|
},
|
|
},
|
|
}
|
|
feed = append(feed, &bsky.FeedDefs_FeedViewPost{Post: post})
|
|
}
|
|
|
|
resp := bsky.FeedGetAuthorFeed_Output{
|
|
Feed: feed,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for bsky.FeedGetFeed
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getFeed", func(w http.ResponseWriter, r *http.Request) {
|
|
feedURI := r.URL.Query().Get("feed")
|
|
|
|
if feedURI == "" {
|
|
http.Error(w, `{"error": "MissingParameter", "message": "feed parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
resp := bsky.FeedGetFeed_Output{
|
|
Feed: []*bsky.FeedDefs_FeedViewPost{
|
|
{
|
|
Post: &bsky.FeedDefs_PostView{
|
|
Uri: "at://did:plc:test/app.bsky.feed.post/1",
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{
|
|
Did: "did:plc:test",
|
|
Handle: "someuser.test",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for bsky.FeedGetPosts (used by GetPost)
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
|
|
uris := r.URL.Query()["uris"]
|
|
if len(uris) == 0 {
|
|
http.Error(w, `{"error": "MissingParameter", "message": "uris parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
posts := make([]*bsky.FeedDefs_PostView, 0, len(uris))
|
|
for _, uri := range uris {
|
|
posts = append(posts, &bsky.FeedDefs_PostView{
|
|
Uri: uri,
|
|
Cid: "bafy...",
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{
|
|
Did: "did:plc:test",
|
|
Handle: "test.handle",
|
|
},
|
|
Record: &util.LexiconTypeDecoder{
|
|
Val: &bsky.FeedPost{
|
|
Text: "Test post content",
|
|
CreatedAt: "2023-10-27T12:00:00Z",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
resp := bsky.FeedGetPosts_Output{
|
|
Posts: posts,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for app.bsky.actor.getProfile (used by GetActor)
|
|
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
|
|
actor := r.URL.Query().Get("actor")
|
|
if actor == "" {
|
|
http.Error(w, `{"error": "MissingParameter", "message": "actor parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"did": "did:plc:test",
|
|
"handle": actor,
|
|
"displayName": "Test User",
|
|
"avatar": "https://example.com/avatar.jpg",
|
|
"description": "Test bio",
|
|
"followersCount": 100,
|
|
"followsCount": 50,
|
|
"postsCount": 200,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for app.bsky.actor.searchActors (used by SearchActors)
|
|
mux.HandleFunc("/xrpc/app.bsky.actor.searchActors", func(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("q")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 10
|
|
if limitStr != "" {
|
|
limit, _ = strconv.Atoi(limitStr)
|
|
}
|
|
|
|
if query == "" {
|
|
http.Error(w, `{"error": "MissingParameter", "message": "q parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
actors := make([]map[string]any, 0, limit)
|
|
for i := 0; i < limit; i++ {
|
|
actors = append(actors, map[string]any{
|
|
"did": fmt.Sprintf("did:plc:test%d", i),
|
|
"handle": fmt.Sprintf("%s%d.test", query, i),
|
|
"displayName": fmt.Sprintf("Test User %d", i),
|
|
"avatar": "https://example.com/avatar.jpg",
|
|
"description": fmt.Sprintf("Bio for user %d", i),
|
|
})
|
|
}
|
|
|
|
resp := map[string]any{"actors": actors}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for com.atproto.identity.resolveHandle (used by ResolveUser)
|
|
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
|
|
handle := r.URL.Query().Get("handle")
|
|
if handle == "" {
|
|
http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
resp := atproto.IdentityResolveHandle_Output{
|
|
Did: "did:plc:resolved",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
// Mock for com.atproto.server.refreshSession (used by withRefresh)
|
|
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
|
|
// Check for Authorization header
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"})
|
|
return
|
|
}
|
|
|
|
resp := atproto.ServerRefreshSession_Output{
|
|
AccessJwt: "refreshed_access_jwt",
|
|
RefreshJwt: "refreshed_refresh_jwt",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
func TestNewBlueskyClient(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
t.Run("SuccessfulClientCreation", func(t *testing.T) {
|
|
client, err := NewBlueskyClient(ctx, "test.user", "password", server.URL)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if client == nil {
|
|
t.Fatal("Expected client not to be nil")
|
|
}
|
|
|
|
if client.client.Auth == nil {
|
|
t.Fatal("Expected client.Auth not to be nil")
|
|
}
|
|
|
|
if client.client.Auth.AccessJwt != "test_access_jwt" {
|
|
t.Errorf("Expected AccessJwt to be 'test_access_jwt', got %s", client.client.Auth.AccessJwt)
|
|
}
|
|
if client.client.Auth.Did != "did:plc:test" {
|
|
t.Errorf("Expected Did to be 'did:plc:test', got %s", client.client.Auth.Did)
|
|
}
|
|
})
|
|
|
|
t.Run("FailedClientCreation", func(t *testing.T) {
|
|
client, err := NewBlueskyClient(ctx, "fail.test", "wrongpassword", server.URL)
|
|
if err == nil {
|
|
t.Fatal("Expected an error, got nil")
|
|
}
|
|
|
|
if client != nil {
|
|
t.Fatal("Expected client to be nil on failure")
|
|
}
|
|
|
|
expectedErrorMsg := "failed to create bluesky session"
|
|
if !contains(err.Error(), expectedErrorMsg) {
|
|
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
|
}
|
|
})
|
|
}
|
|
|
|
// A helper function for checking substrings.
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && s[:len(substr)] == substr
|
|
}
|
|
|
|
// newTestClient is a helper to quickly create a client for other tests.
|
|
func newTestClient(t *testing.T, serverURL string) *BlueskyClient {
|
|
return &BlueskyClient{
|
|
client: &xrpc.Client{
|
|
Host: serverURL,
|
|
Auth: &xrpc.AuthInfo{
|
|
AccessJwt: "test_access_jwt",
|
|
},
|
|
},
|
|
didCache: make(map[string]didCacheEntry),
|
|
}
|
|
}
|
|
|
|
func TestGetLatestNPosts(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
actor := "did:plc:testactor"
|
|
limit := 5
|
|
|
|
output, err := client.GetLatestNPosts(ctx, actor, limit)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if output == nil {
|
|
t.Fatal("Expected output not to be nil")
|
|
}
|
|
|
|
if len(output) != int(limit) {
|
|
t.Errorf("Expected feed length to be %d, got %d", limit, len(output))
|
|
}
|
|
|
|
if output[0].Post.Author.Handle != actor {
|
|
t.Errorf("Expected post author handle to be '%s', got '%s'", actor, output[0].Post.Author.Handle)
|
|
}
|
|
}
|
|
|
|
func TestGetFeed(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
feedURI := "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends"
|
|
limit := 10
|
|
|
|
output, err := client.GetFeed(ctx, feedURI, limit)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if output == nil {
|
|
t.Fatal("Expected output not to be nil")
|
|
}
|
|
|
|
if len(output.Feed) == 0 {
|
|
t.Fatal("Expected at least one post in the feed")
|
|
}
|
|
|
|
if output.Feed[0].Post.Uri != "at://did:plc:test/app.bsky.feed.post/1" {
|
|
t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri)
|
|
}
|
|
}
|
|
|
|
func TestGetPost(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
postURI := "at://did:plc:testactor/app.bsky.feed.post/123"
|
|
post, err := client.GetPost(ctx, "did:plc:testactor", "", "123")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if post == nil {
|
|
t.Fatal("Expected post not to be nil")
|
|
}
|
|
|
|
if post.Uri != postURI {
|
|
t.Errorf("Expected post URI %q, got %q", postURI, post.Uri)
|
|
}
|
|
|
|
if post.Author.Handle != "test.handle" {
|
|
t.Errorf("Expected author handle 'test.handle', got %q", post.Author.Handle)
|
|
}
|
|
|
|
// Test with custom collection
|
|
post2, err := client.GetPost(ctx, "did:plc:testactor", "app.bsky.feed.post", "456")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error with custom collection, got %v", err)
|
|
}
|
|
if post2 == nil {
|
|
t.Fatal("Expected post not to be nil")
|
|
}
|
|
}
|
|
|
|
func TestGetPostsInTimeframe(t *testing.T) {
|
|
// The mock author-feed is reverse-chronological: page 1 has posts newer than
|
|
// the window end (skipped, cursor advances), page 2 has the in-range posts
|
|
// followed by one older than start (which terminates the scan).
|
|
requests := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(atproto.ServerCreateSession_Output{Handle: "test.handle", Did: "did:plc:test"})
|
|
})
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
|
return &bsky.FeedDefs_FeedViewPost{
|
|
Post: &bsky.FeedDefs_PostView{
|
|
Uri: uri,
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{
|
|
Did: "did:plc:testactor",
|
|
Handle: "testactor.test",
|
|
},
|
|
Record: &util.LexiconTypeDecoder{
|
|
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
var feed []*bsky.FeedDefs_FeedViewPost
|
|
var cursor string
|
|
if requests == 1 {
|
|
// Page 1: one post after the window end (skipped), with a cursor.
|
|
feed = []*bsky.FeedDefs_FeedViewPost{
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/4", "2024-01-01T00:00:00Z"),
|
|
}
|
|
cursor = "page-2-cursor"
|
|
} else {
|
|
// Page 2: two in-range posts, then one older than start (terminates).
|
|
feed = []*bsky.FeedDefs_FeedViewPost{
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/2", "2023-08-01T00:00:00Z"),
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"),
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed, Cursor: &cursor})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
actor := "did:plc:testactor"
|
|
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
|
limit := 10
|
|
|
|
posts, err := client.GetPostsInTimeframe(ctx, actor, start, end, limit)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
// Only the two in-range posts must survive filtering; cursor pagination must
|
|
// advance into page 2 to reach them.
|
|
if len(posts) != 2 {
|
|
t.Fatalf("expected 2 in-range posts, got %d", len(posts))
|
|
}
|
|
for _, post := range posts {
|
|
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
|
|
if !ok {
|
|
t.Fatalf("unexpected record type")
|
|
}
|
|
createdAt, _ := time.Parse(time.RFC3339, rec.CreatedAt)
|
|
if createdAt.Before(start) || createdAt.After(end) {
|
|
t.Errorf("Post %s returned outside time range", post.Post.Uri)
|
|
}
|
|
}
|
|
if requests != 2 {
|
|
t.Errorf("expected pagination to make 2 feed requests, got %d", requests)
|
|
}
|
|
}
|
|
|
|
func TestGetActor(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
actor := "test.handle"
|
|
profile, err := client.GetActor(ctx, actor)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if profile == nil {
|
|
t.Fatal("Expected profile not to be nil")
|
|
}
|
|
|
|
if profile.Handle != actor {
|
|
t.Errorf("Expected handle %q, got %q", actor, profile.Handle)
|
|
}
|
|
if profile.DisplayName != "Test User" {
|
|
t.Errorf("Expected display name 'Test User', got %q", profile.DisplayName)
|
|
}
|
|
if profile.Platform != "Bluesky" {
|
|
t.Errorf("Expected platform 'Bluesky', got %q", profile.Platform)
|
|
}
|
|
if profile.URL != "https://bsky.app/profile/"+actor {
|
|
t.Errorf("Expected URL %q, got %q", "https://bsky.app/profile/"+actor, profile.URL)
|
|
}
|
|
if profile.FollowersCount != 100 || profile.FollowingCount != 50 || profile.PostCount != 200 {
|
|
t.Errorf("Unexpected counts: followers=%d, following=%d, posts=%d", profile.FollowersCount, profile.FollowingCount, profile.PostCount)
|
|
}
|
|
}
|
|
|
|
func TestSearchActors(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
query := "alice"
|
|
limit := 5
|
|
|
|
results, err := client.SearchActors(ctx, query, limit)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if results == nil {
|
|
t.Fatal("Expected results not to be nil")
|
|
}
|
|
|
|
if len(results) != limit {
|
|
t.Errorf("Expected %d results, got %d", limit, len(results))
|
|
}
|
|
|
|
for i, result := range results {
|
|
expectedHandle := fmt.Sprintf("%s%d.test", query, i)
|
|
if result.Handle != expectedHandle {
|
|
t.Errorf("Result %d: expected handle %q, got %q", i, expectedHandle, result.Handle)
|
|
}
|
|
if result.Platform != "Bluesky" {
|
|
t.Errorf("Result %d: expected platform 'Bluesky', got %q", i, result.Platform)
|
|
}
|
|
expectedURL := "https://bsky.app/profile/" + expectedHandle
|
|
if result.URL != expectedURL {
|
|
t.Errorf("Result %d: expected URL %q, got %q", i, expectedURL, result.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveUser(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
// Test with handle
|
|
did, err := client.ResolveUser(ctx, "test.handle")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error resolving handle, got %v", err)
|
|
}
|
|
if did.String() != "did:plc:resolved" {
|
|
t.Errorf("Expected resolved DID 'did:plc:resolved', got %q", did.String())
|
|
}
|
|
|
|
// Test with DID directly
|
|
did2, err := client.ResolveUser(ctx, "did:plc:direct")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error with direct DID, got %v", err)
|
|
}
|
|
if did2.String() != "did:plc:direct" {
|
|
t.Errorf("Expected same DID 'did:plc:direct', got %q", did2.String())
|
|
}
|
|
}
|
|
|
|
func TestSanitizeFeedURI(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "AT URI",
|
|
input: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
|
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "Web URI with profile/feed",
|
|
input: "https://bsky.app/profile/did:plc:abc/feed/xyz",
|
|
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "Non-HTTP scheme",
|
|
input: "gopher://bsky.app/profile/did:plc:abc/feed/xyz",
|
|
expected: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "Invalid URI",
|
|
input: "not-a-uri",
|
|
expected: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result, err := client.sanitizeFeedURI(ctx, tc.input)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("Expected error for input %q, got nil", tc.input)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error for input %q: %v", tc.input, err)
|
|
}
|
|
if result != tc.expected {
|
|
t.Errorf("Input %q: expected %q, got %q", tc.input, tc.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetLatestNPostsLimitClamp(t *testing.T) {
|
|
server := setupMockServer()
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
actor := "did:plc:testactor"
|
|
|
|
t.Run("OverMaxLimitClampedTo100", func(t *testing.T) {
|
|
output, err := client.GetLatestNPosts(ctx, actor, 250)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(output) != maxBskyFeedLimit {
|
|
t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output))
|
|
}
|
|
})
|
|
|
|
t.Run("ZeroLimitReturnsNil", func(t *testing.T) {
|
|
output, err := client.GetLatestNPosts(ctx, actor, 0)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error with zero limit, got %v", err)
|
|
}
|
|
if output != nil {
|
|
t.Errorf("Expected nil output for zero limit, got %d posts", len(output))
|
|
}
|
|
})
|
|
|
|
t.Run("NegativeLimitReturnsNil", func(t *testing.T) {
|
|
output, err := client.GetLatestNPosts(ctx, actor, -5)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error with negative limit, got %v", err)
|
|
}
|
|
if output != nil {
|
|
t.Errorf("Expected nil output for negative limit, got %d posts", len(output))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetFeedHonorsLimit(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getFeed", func(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
feed := make([]*bsky.FeedDefs_FeedViewPost, 0, limit)
|
|
for i := 0; i < limit; i++ {
|
|
feed = append(feed, &bsky.FeedDefs_FeedViewPost{
|
|
Post: &bsky.FeedDefs_PostView{
|
|
Uri: fmt.Sprintf("at://did:plc:test/app.bsky.feed.post/%d", i),
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:test", Handle: "test.handle"},
|
|
},
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bsky.FeedGetFeed_Output{Feed: feed})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
feedURI := "at://did:plc:abc/app.bsky.feed.generator/xyz"
|
|
|
|
t.Run("ExplicitLimitPassedThrough", func(t *testing.T) {
|
|
output, err := client.GetFeed(ctx, feedURI, 75)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(output.Feed) != 75 {
|
|
t.Errorf("Expected 75 posts, got %d", len(output.Feed))
|
|
}
|
|
})
|
|
|
|
t.Run("ZeroLimitDefaultsTo50", func(t *testing.T) {
|
|
output, err := client.GetFeed(ctx, feedURI, 0)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(output.Feed) != 50 {
|
|
t.Errorf("Expected 50 posts (default), got %d", len(output.Feed))
|
|
}
|
|
})
|
|
|
|
t.Run("OverMaxLimitClampedTo100", func(t *testing.T) {
|
|
output, err := client.GetFeed(ctx, feedURI, 1000)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(output.Feed) != maxBskyFeedLimit {
|
|
t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output.Feed))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetPostInvalidRecordKey(t *testing.T) {
|
|
requests := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
http.Error(w, `{"error":"InternalServerError"}`, http.StatusInternalServerError)
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
post, err := client.GetPost(ctx, "did:plc:testactor", "", "bad/key")
|
|
if post != nil {
|
|
t.Fatalf("Expected nil post, got %v", post)
|
|
}
|
|
if err == nil {
|
|
t.Fatal("Expected error for invalid record key, got nil")
|
|
}
|
|
if requests != 0 {
|
|
t.Errorf("Expected no network request for invalid record key, got %d", requests)
|
|
}
|
|
}
|
|
|
|
func TestWithRefreshConcurrent(t *testing.T) {
|
|
var refreshCount atomic.Int32
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer refreshed_access_jwt" {
|
|
// The initial access token is expired; the session must be refreshed
|
|
// before the retry succeeds.
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "ExpiredToken", "message": "Token expired, refresh required"})
|
|
return
|
|
}
|
|
resp := map[string]any{
|
|
"did": "did:plc:test",
|
|
"handle": "test.handle",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
|
|
refreshCount.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(atproto.ServerRefreshSession_Output{
|
|
AccessJwt: "refreshed_access_jwt",
|
|
RefreshJwt: "refreshed_refresh_jwt",
|
|
})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, err := client.GetActor(ctx, "test.handle"); err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if refreshCount.Load() != 1 {
|
|
t.Errorf("expected exactly 1 session refresh under concurrency, got %d", refreshCount.Load())
|
|
}
|
|
}
|
|
|
|
func TestGetPostsInTimeframeStopsAtStart(t *testing.T) {
|
|
requests := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
|
return &bsky.FeedDefs_FeedViewPost{
|
|
Post: &bsky.FeedDefs_PostView{
|
|
Uri: uri,
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"},
|
|
Record: &util.LexiconTypeDecoder{
|
|
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
feed := []*bsky.FeedDefs_FeedViewPost{
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/0", "2022-01-01T00:00:00Z"),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
|
|
|
posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(posts) != 1 {
|
|
t.Fatalf("expected 1 in-range post before early exit, got %d", len(posts))
|
|
}
|
|
if requests != 1 {
|
|
t.Errorf("expected scan to stop after the first page, made %d requests", requests)
|
|
}
|
|
}
|
|
|
|
func TestGetPostsInTimeframeIncludesBoundaries(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
|
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
|
return &bsky.FeedDefs_FeedViewPost{
|
|
Post: &bsky.FeedDefs_PostView{
|
|
Uri: uri,
|
|
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"},
|
|
Record: &util.LexiconTypeDecoder{
|
|
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
feed := []*bsky.FeedDefs_FeedViewPost{
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/end", "2023-12-31T23:59:59Z"),
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/start", "2023-01-01T00:00:00Z"),
|
|
mk("at://did:plc:testactor/app.bsky.feed.post/older", "2022-12-31T23:59:59Z"),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
|
|
|
posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
// Both boundary posts are inclusive; the post one second before start is not.
|
|
if len(posts) != 2 {
|
|
t.Fatalf("expected boundary posts to be included (got %d)", len(posts))
|
|
}
|
|
got := map[string]bool{}
|
|
for _, p := range posts {
|
|
got[p.Post.Uri] = true
|
|
}
|
|
if !got["at://did:plc:testactor/app.bsky.feed.post/start"] {
|
|
t.Error("expected post exactly at start to be included")
|
|
}
|
|
if !got["at://did:plc:testactor/app.bsky.feed.post/end"] {
|
|
t.Error("expected post exactly at end to be included")
|
|
}
|
|
}
|
|
|
|
func TestResolveUserCache(t *testing.T) {
|
|
var resolveRequests atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
|
|
resolveRequests.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(atproto.IdentityResolveHandle_Output{Did: "did:plc:resolved"})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
defer server.Close()
|
|
|
|
client := newTestClient(t, server.URL)
|
|
ctx := context.Background()
|
|
|
|
// First resolution hits the network and populates the cache.
|
|
did, err := client.ResolveUser(ctx, "test.handle")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if did.String() != "did:plc:resolved" {
|
|
t.Errorf("Unexpected DID %q", did.String())
|
|
}
|
|
if resolveRequests.Load() != 1 {
|
|
t.Errorf("expected 1 resolve request, got %d", resolveRequests.Load())
|
|
}
|
|
|
|
// Second resolution is served from cache.
|
|
if _, err := client.ResolveUser(ctx, "test.handle"); err != nil {
|
|
t.Fatalf("Expected no error on cached resolution, got %v", err)
|
|
}
|
|
if resolveRequests.Load() != 1 {
|
|
t.Errorf("expected cached resolution to skip the network, got %d requests", resolveRequests.Load())
|
|
}
|
|
|
|
// An expired cache entry is refetched.
|
|
client.cacheMu.Lock()
|
|
entry := client.didCache["test.handle"]
|
|
entry.expiresAt = time.Now().Add(-time.Minute)
|
|
client.didCache["test.handle"] = entry
|
|
client.cacheMu.Unlock()
|
|
|
|
if _, err := client.ResolveUser(ctx, "test.handle"); err != nil {
|
|
t.Fatalf("Expected no error after expiry, got %v", err)
|
|
}
|
|
if resolveRequests.Load() != 2 {
|
|
t.Errorf("expected a fresh resolve after TTL expiry, got %d requests", resolveRequests.Load())
|
|
}
|
|
}
|