naco-api/internal/bluesky/client_test.go
Nacorid f7b3cd886b
fix: harden Bluesky client caching, auth refresh, and limits
- 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
2026-09-09 23:17:51 +00:00

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