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