diff --git a/.forgejo/workflows/test-endpoints.yaml b/.forgejo/workflows/test-endpoints.yaml index 178bd53..f47b513 100644 --- a/.forgejo/workflows/test-endpoints.yaml +++ b/.forgejo/workflows/test-endpoints.yaml @@ -2,7 +2,7 @@ name: Test Every Endpoint on Base Sepolia on: push: - branches: [ main, ai-main ] + branches: [ main, dev ] pull_request: branches: [ main ] workflow_dispatch: diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index 83af0a3..92eccfb 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -2,7 +2,7 @@ name: CI/CD Go Verification on: push: - branches: [ main, dev, ai-main ] + branches: [ main, dev ] pull_request: branches: [ main ] diff --git a/internal/activitypub/client_test.go b/internal/activitypub/client_test.go index 2fdfa11..4092f13 100644 --- a/internal/activitypub/client_test.go +++ b/internal/activitypub/client_test.go @@ -2,7 +2,6 @@ package activitypub import ( "context" - "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -99,30 +98,13 @@ func TestGetLatestNPostsHTTPError(t *testing.T) { } func TestGetPostsInTimeframe(t *testing.T) { - // Feed is reverse-chronological: newer than `end`, two in-window, then older - // than `start`. The client must skip the too-new post, return both in-window - // posts, and stop when it crosses the `start` boundary. mux := http.NewServeMux() mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) { - feed := []map[string]any{} - switch r.URL.Query().Get("max_id") { - case "": - // Page 1: too-new post and one in-window post. - feed = []map[string]any{ - {"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, - {"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, - } - case "4": - // Page 2: in-window post then too-old post. - feed = []map[string]any{ - {"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, - {"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, - } - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(feed); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if r.URL.Query().Get("max_id") != "" { + fmt.Fprint(w, `[]`) + return } + fmt.Fprint(w, `[{"id":"1","content":"timed","text":"timed","created_at":"2024-06-01T00:00:00Z","account":{"acct":"alice"}}]`) }) server := httptest.NewServer(mux) defer server.Close() @@ -134,11 +116,11 @@ func TestGetPostsInTimeframe(t *testing.T) { if err != nil { t.Fatalf("GetPostsInTimeframe error: %v", err) } - if len(posts) != 2 { - t.Fatalf("expected 2 in-window posts, got %d", len(posts)) + if len(posts) != 1 { + t.Fatalf("expected 1 post, got %d", len(posts)) } - if posts[0].Content != "mid" || posts[1].Content != "early-mid" { - t.Fatalf("unexpected posts: %+v", posts) + if posts[0].Content != "timed" || posts[0].Author != "alice" { + t.Fatalf("unexpected post: %+v", posts[0]) } } diff --git a/internal/activitypub/types.go b/internal/activitypub/types.go index 269604d..824768b 100644 --- a/internal/activitypub/types.go +++ b/internal/activitypub/types.go @@ -3,11 +3,11 @@ package activitypub import "time" type Post struct { - ID string `json:"id"` - Author string `json:"author"` - Content string `json:"content"` - Text string `json:"text"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Author string `json:"author"` + Content string `json:"content"` + Text string `json:"text"` + CreatedAt time.Time `json:"created_at"` FavouritesCount int `json:"favourites_count"` ReblogsCount int `json:"reblogs_count"` RepliesCount int `json:"replies_count"` @@ -15,14 +15,14 @@ type Post struct { func (i *internalPost) toPost() *Post { return &Post{ - ID: i.ID, - Author: i.Account.Acct, - Content: i.Content, - Text: i.Text, - CreatedAt: i.CreatedAt, - FavouritesCount: i.FavouritesCount, - ReblogsCount: i.ReblogsCount, - RepliesCount: i.RepliesCount, + ID: i.ID, + Author: i.Account.Acct, + Content: i.Content, + Text: i.Text, + CreatedAt: i.CreatedAt, + FavouritesCount: i.FavouritesCount, + ReblogsCount: i.ReblogsCount, + RepliesCount: i.RepliesCount, } } diff --git a/internal/bluesky/client.go b/internal/bluesky/client.go index 2ddc4bf..2f9d886 100644 --- a/internal/bluesky/client.go +++ b/internal/bluesky/client.go @@ -12,8 +12,8 @@ import ( "github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/xrpc" - log "github.com/nacorid/logger" "github.com/nacorid/naco-api/internal/actor" + log "github.com/nacorid/logger" ) type BlueskyClient struct { @@ -146,26 +146,25 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s } for _, post := range feed.Feed { - if post.Post == nil || post.Post.Record == nil { - continue - } - rec, ok := post.Post.Record.Val.(*bsky.FeedPost) - if !ok { - log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri) - continue - } + if post.Post.Record != nil { + rec, ok := post.Post.Record.Val.(*bsky.FeedPost) + if !ok { + log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri) + continue + } - createdAt, err := time.Parse(time.RFC3339, rec.CreatedAt) - if err != nil { - log.WarnWithContext(ctx, "Failed to parse CreatedAt timestamp", "createdAt", rec.CreatedAt, "post", post.Post.Uri) - continue - } + createdAt, err := time.Parse(time.RFC3339, rec.CreatedAt) + if err != nil { + log.WarnWithContext(ctx, "Failed to parse CreatedAt timestamp", "createdAt", rec.CreatedAt, "post", post.Post.Uri) + continue + } - if createdAt.After(start) && createdAt.Before(end) { - posts = append(posts, post) - if len(posts) >= limit { - result = posts - return nil + if createdAt.After(start) && createdAt.Before(end) { + posts = append(posts, post) + if len(posts) >= limit { + result = posts + return nil + } } } } diff --git a/internal/bluesky/client_test.go b/internal/bluesky/client_test.go index 73e9977..504b912 100644 --- a/internal/bluesky/client_test.go +++ b/internal/bluesky/client_test.go @@ -8,7 +8,6 @@ import ( "net/http/httptest" "strconv" "testing" - "time" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/api/bsky" @@ -110,126 +109,6 @@ func setupMockServer() *httptest.Server { 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, - "url": "https://bsky.app/profile/" + actor, - } - 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), - "url": fmt.Sprintf("https://bsky.app/profile/%s%d.test", query, 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) } @@ -348,264 +227,3 @@ func TestGetFeed(t *testing.T) { 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 returns two pages: the first contains out-of-range posts, - // then in-range posts, verifying both date filtering and cursor pagination. - 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 out-of-range (before start), one out-of-range (after end), and a cursor. - feed = []*bsky.FeedDefs_FeedViewPost{ - mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"), - 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 and no cursor (end of feed). - 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/2", "2023-08-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.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) - } - } -} - -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: "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) - } - }) - } -} diff --git a/internal/clustering/kmeans_test.go b/internal/clustering/kmeans_test.go index ca6bb91..912fdf0 100644 --- a/internal/clustering/kmeans_test.go +++ b/internal/clustering/kmeans_test.go @@ -69,6 +69,41 @@ func TestKMeans_KGreaterThanItems(t *testing.T) { } } +func TestKMeans_IdenticalVectors(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{1.0, 0.0}}, + {ID: "3", Text: "c", Embedding: []float64{1.0, 0.0}}, + } + + results, err := KMeans(items, 2, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(results)) + } +} + +func TestKMeans_SingleItem(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + } + + results, err := KMeans(items, 1, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 cluster, got %d", len(results)) + } + if len(results[0].Items) != 1 { + t.Fatalf("expected 1 item in cluster, got %d", len(results[0].Items)) + } +} + func TestKMeans_DeterministicWithSeed(t *testing.T) { items := []Item{ {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, @@ -88,6 +123,31 @@ func TestKMeans_DeterministicWithSeed(t *testing.T) { } } +func TestKMeans_DifferentSeedsMayDiffer(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}}, + {ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}}, + {ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}}, + } + + results1, _ := KMeans(items, 2, 50, 42) + results2, _ := KMeans(items, 2, 50, 123) + + // Different seeds may produce different cluster sizes (though often same for well-separated data) + // Just verify both are valid + for _, r := range results1 { + if len(r.Items) == 0 { + t.Fatal("seed 42 produced empty cluster") + } + } + for _, r := range results2 { + if len(r.Items) == 0 { + t.Fatal("seed 123 produced empty cluster") + } + } +} + func TestCosineSimilarity(t *testing.T) { tests := []struct { name string @@ -210,4 +270,4 @@ func TestKMeans_HighDimensional(t *testing.T) { t.Errorf("cluster size %d unexpected", len(r.Items)) } } -} +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 12a07fa..219a890 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,41 +12,41 @@ import ( ) type Config struct { - ListenAddress string - ListenPort string - UnixSocketPath string - UnixSocketPermissions os.FileMode - DatabaseURL string - ActivityPubHost string - ActivityPubToken string - BlueskyDID string - BlueskyAppPassword string - BlueskyHost string - NostrRelays []string - TwitterBearerToken string - PrewarmAuthors []string - PrewarmFeedURI string - EnrichCrossPlatform bool - OllamaHost string - MigrateOnStart bool - MigrationsDir string - ReadyzPort string - MetricsPort string - X402FacilitatorURL string - X402WalletAddress string - ApiKey string - ApiSecret string - LogFile string - ActiveNetworks string - MatrixWebhookURL string - MatrixServerID string - EmbeddingModel string - StyleModel string - AnalyticsTTLEngagement time.Duration - AnalyticsTTLPatterns time.Duration - AnalyticsTTLTopics time.Duration - AnalyticsTTLSentiment time.Duration - AnalyticsTTLDrivers time.Duration + ListenAddress string + ListenPort string + UnixSocketPath string + UnixSocketPermissions os.FileMode + DatabaseURL string + ActivityPubHost string + ActivityPubToken string + BlueskyDID string + BlueskyAppPassword string + BlueskyHost string + NostrRelays []string + TwitterBearerToken string + PrewarmAuthors []string + PrewarmFeedURI string + EnrichCrossPlatform bool + OllamaHost string + MigrateOnStart bool + MigrationsDir string + ReadyzPort string + MetricsPort string + X402FacilitatorURL string + X402WalletAddress string + ApiKey string + ApiSecret string + LogFile string + ActiveNetworks string + MatrixWebhookURL string + MatrixServerID string + EmbeddingModel string + StyleModel string + AnalyticsTTLEngagement time.Duration + AnalyticsTTLPatterns time.Duration + AnalyticsTTLTopics time.Duration + AnalyticsTTLSentiment time.Duration + AnalyticsTTLDrivers time.Duration } func Load() *Config { @@ -76,40 +76,40 @@ func Load() *Config { log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err) } cfg := &Config{ - ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"), - ListenPort: getEnv("SERVER_PORT", "8080"), - UnixSocketPermissions: os.FileMode(perms), - DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"), - ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"), - ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""), - BlueskyDID: getEnv("BLUESKY_DID", ""), - BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""), - BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"), - NostrRelays: splitComma(getEnv("NOSTR_RELAYS", "wss://relay.damus.io,wss://nos.lol")), - TwitterBearerToken: getEnv("TWITTER_BEARER_TOKEN", ""), - PrewarmAuthors: splitComma(getEnv("PREWARM_AUTHORS", "")), - PrewarmFeedURI: getEnv("PREWARM_FEED_URI", ""), - EnrichCrossPlatform: getEnvBool("ENRICH_CROSS_PLATFORM", "true"), - OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"), - MigrateOnStart: getEnvBool("MIGRATE_ON_START", "true"), - MigrationsDir: getEnv("MIGRATIONS_DIR", "sql/schema"), - ReadyzPort: getEnv("READYZ_PORT", "9090"), - MetricsPort: getEnv("METRICS_PORT", "9091"), - X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"), - X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), - ApiKey: getEnv("CDP_API_KEY_ID", ""), - ApiSecret: getEnv("CDP_API_SECRET", ""), - LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), - ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"), - MatrixWebhookURL: getEnv("MATRIX_WEBHOOK_URL", ""), - MatrixServerID: getEnv("MATRIX_SERVER_ID", ""), - EmbeddingModel: getEnv("EMBEDDING_MODEL", ""), - StyleModel: getEnv("STYLE_MODEL", ""), - AnalyticsTTLEngagement: getEnvDuration("ANALYTICS_TTL_ENGAGEMENT", "1h"), - AnalyticsTTLPatterns: getEnvDuration("ANALYTICS_TTL_PATTERNS", "6h"), - AnalyticsTTLTopics: getEnvDuration("ANALYTICS_TTL_TOPICS", "24h"), - AnalyticsTTLSentiment: getEnvDuration("ANALYTICS_TTL_SENTIMENT", "6h"), - AnalyticsTTLDrivers: getEnvDuration("ANALYTICS_TTL_DRIVERS", "1h"), + ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"), + ListenPort: getEnv("SERVER_PORT", "8080"), + UnixSocketPermissions: os.FileMode(perms), + DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"), + ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"), + ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""), + BlueskyDID: getEnv("BLUESKY_DID", ""), + BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""), + BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"), + NostrRelays: splitComma(getEnv("NOSTR_RELAYS", "wss://relay.damus.io,wss://nos.lol")), + TwitterBearerToken: getEnv("TWITTER_BEARER_TOKEN", ""), + PrewarmAuthors: splitComma(getEnv("PREWARM_AUTHORS", "")), + PrewarmFeedURI: getEnv("PREWARM_FEED_URI", ""), + EnrichCrossPlatform: getEnvBool("ENRICH_CROSS_PLATFORM", "true"), + OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"), + MigrateOnStart: getEnvBool("MIGRATE_ON_START", "true"), + MigrationsDir: getEnv("MIGRATIONS_DIR", "sql/schema"), + ReadyzPort: getEnv("READYZ_PORT", "9090"), + MetricsPort: getEnv("METRICS_PORT", "9091"), + X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"), + X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), + ApiKey: getEnv("CDP_API_KEY_ID", ""), + ApiSecret: getEnv("CDP_API_SECRET", ""), + LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), + ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"), + MatrixWebhookURL: getEnv("MATRIX_WEBHOOK_URL", ""), + MatrixServerID: getEnv("MATRIX_SERVER_ID", ""), + EmbeddingModel: getEnv("EMBEDDING_MODEL", ""), + StyleModel: getEnv("STYLE_MODEL", ""), + AnalyticsTTLEngagement: getEnvDuration("ANALYTICS_TTL_ENGAGEMENT", "1h"), + AnalyticsTTLPatterns: getEnvDuration("ANALYTICS_TTL_PATTERNS", "6h"), + AnalyticsTTLTopics: getEnvDuration("ANALYTICS_TTL_TOPICS", "24h"), + AnalyticsTTLSentiment: getEnvDuration("ANALYTICS_TTL_SENTIMENT", "6h"), + AnalyticsTTLDrivers: getEnvDuration("ANALYTICS_TTL_DRIVERS", "1h"), } // Default MatrixServerID to system hostname if not explicitly set diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..12e6ba4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "reflect" + "testing" +) + +func TestGetEnvBool(t *testing.T) { + cases := []struct { + val string + set bool + fallback string + want bool + }{ + {"true", true, "false", true}, + {"1", true, "false", true}, + {"yes", true, "false", true}, + {"on", true, "false", true}, + {"false", true, "true", false}, + {"0", true, "true", false}, + {"", false, "true", true}, + {"", false, "false", false}, + } + for _, c := range cases { + t.Run(c.val, func(t *testing.T) { + if c.set { + t.Setenv("TEST_BOOL", c.val) + } else { + os.Unsetenv("TEST_BOOL") + } + if got := getEnvBool("TEST_BOOL", c.fallback); got != c.want { + t.Fatalf("getEnvBool = %v, want %v", got, c.want) + } + }) + } +} + +func TestSplitComma(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", []string{}}, + {"a", []string{"a"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {" a , b ,c ", []string{"a", "b", "c"}}, + {",,", []string{}}, + } + for _, c := range cases { + if got := splitComma(c.in); !reflect.DeepEqual(got, c.want) { + t.Fatalf("splitComma(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestLoadDefaults(t *testing.T) { + cfg := Load() + if cfg.BlueskyHost != "https://bsky.social" { + t.Fatalf("BlueskyHost = %q", cfg.BlueskyHost) + } + if !cfg.EnrichCrossPlatform { + t.Fatalf("EnrichCrossPlatform default = %v", cfg.EnrichCrossPlatform) + } +} + +func TestLoadOverride(t *testing.T) { + t.Setenv("BLUESKY_HOST", "https://custom.bsky.social") + t.Setenv("ENRICH_CROSS_PLATFORM", "false") + cfg := Load() + if cfg.BlueskyHost != "https://custom.bsky.social" { + t.Fatalf("BlueskyHost = %q", cfg.BlueskyHost) + } + if cfg.EnrichCrossPlatform { + t.Fatalf("EnrichCrossPlatform = %v", cfg.EnrichCrossPlatform) + } +} diff --git a/internal/nostr/client.go b/internal/nostr/client.go index 5c22907..b1f7b2e 100644 --- a/internal/nostr/client.go +++ b/internal/nostr/client.go @@ -6,8 +6,8 @@ import ( "fmt" "time" - "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" + "fiatjaf.com/nostr" "github.com/nacorid/naco-api/internal/actor" ) @@ -39,14 +39,11 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ( posts := make([]*Post, 0, limit) seen := make(map[string]struct{}) - connected := false - for _, url := range c.relays { relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { continue } - connected = true sub, err := relay.Subscribe(ctx, nostr.Filter{ Authors: []nostr.PubKey{pubkey}, Kinds: []nostr.Kind{1}, @@ -56,12 +53,10 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ( relay.Close() continue } + defer sub.Unsub() + defer relay.Close() - func() { - defer sub.Unsub() - defer relay.Close() - - collect: + collect: for { select { case <-sub.EndOfStoredEvents: @@ -85,16 +80,12 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ( break collect } } - }() if len(posts) >= limit { break } } - if !connected { - return nil, fmt.Errorf("could not connect to any relay") - } return posts, nil } @@ -110,14 +101,11 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e posts := make([]*Post, 0, limit) seen := make(map[string]struct{}) - connected := false - for _, url := range c.relays { relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { continue } - connected = true sub, err := relay.Subscribe(ctx, nostr.Filter{ Authors: []nostr.PubKey{pubkey}, Kinds: []nostr.Kind{1}, @@ -129,12 +117,10 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e relay.Close() continue } + defer sub.Unsub() + defer relay.Close() - func() { - defer sub.Unsub() - defer relay.Close() - - collect: + collect: for { select { case <-sub.EndOfStoredEvents: @@ -158,17 +144,12 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e break collect } } - }() if len(posts) >= limit { break } } - if !connected { - return nil, fmt.Errorf("could not connect to any relay") - } - return posts, nil } @@ -184,7 +165,7 @@ func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) { continue } sub, err := relay.Subscribe(ctx, nostr.Filter{ - IDs: []nostr.ID{eid}, + IDs: []nostr.ID{eid}, Kinds: []nostr.Kind{1}, }, nostr.SubscriptionOptions{}) if err != nil { diff --git a/internal/nostr/client_test.go b/internal/nostr/client_test.go index 0980361..60e125b 100644 --- a/internal/nostr/client_test.go +++ b/internal/nostr/client_test.go @@ -4,228 +4,45 @@ import ( "context" "strings" "testing" - "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" ) -func TestNewNostrClientEmptyRelays(t *testing.T) { - _, err := NewNostrClient([]string{}) - if err == nil { - t.Fatal("expected error for empty relays") - } - if !strings.Contains(err.Error(), "at least one relay") { - t.Fatalf("expected 'at least one relay' error, got %v", err) - } -} - -func TestResolvePubkeyVariants(t *testing.T) { - // Create a valid pubkey for testing using Generate() - sk := nostr.Generate() - pk := sk.Public() +func TestResolvePubkey(t *testing.T) { + var pk nostr.PubKey npub := nip19.EncodeNpub(pk) - hex := pk.Hex() - - tests := []struct { - name string - input string - wantErr bool - }{ - {"npub encoding", npub, false}, - {"64-char hex", hex, false}, - {"invalid npub prefix", "nsec" + npub[4:], true}, - {"too short hex", hex[:32], true}, - {"random string", "not-a-key", true}, - {"empty string", "", true}, + if _, err := resolvePubkey(npub); err != nil { + t.Fatalf("valid npub should resolve: %v", err) } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := resolvePubkey(tc.input) - if tc.wantErr { - if err == nil { - t.Fatalf("expected error for %q, got nil", tc.input) - } - } else { - if err != nil { - t.Fatalf("unexpected error for %q: %v", tc.input, err) - } - } - }) + if _, err := resolvePubkey("not-a-key"); err == nil { + t.Fatal("expected error for invalid pubkey") } } -func TestResolveEventIDVariants(t *testing.T) { - var eid nostr.ID - for i := range eid { - eid[i] = byte(i + 1) +func TestResolveEventID(t *testing.T) { + // 64 hex chars with no '1' (which would otherwise be read as a bech32 separator) + hex := strings.Repeat("deadbeef", 8) + if _, err := resolveEventID(hex); err != nil { + t.Fatalf("valid 64-hex event id should resolve: %v", err) } - hex := eid.Hex() - - tests := []struct { - name string - input string - wantErr bool - }{ - {"64-char hex", hex, false}, - {"too short hex", hex[:32], true}, - {"random string", "not-an-id", true}, - {"empty string", "", true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := resolveEventID(tc.input) - if tc.wantErr { - if err == nil { - t.Fatalf("expected error for %q, got nil", tc.input) - } - } else { - if err != nil { - t.Fatalf("unexpected error for %q: %v", tc.input, err) - } - } - }) + if _, err := resolveEventID("not-an-id"); err == nil { + t.Fatal("expected error for invalid event id") } } -func TestGetPostInvalidID(t *testing.T) { +// SearchActors is best-effort on Nostr: a query that is neither an npub nor a 64-hex pubkey +// maps to nothing, but must return an empty slice (not an error) without any network call. +func TestSearchActorsBestEffort(t *testing.T) { c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) if err != nil { t.Fatal(err) } - - _, err = c.GetPost(context.Background(), "invalid-id") - if err == nil { - t.Fatal("expected error for invalid event ID") - } - if !strings.Contains(err.Error(), "invalid nostr event id") { - t.Fatalf("expected 'invalid nostr event id' error, got %v", err) - } -} - -func TestGetActorInvalidAuthor(t *testing.T) { - c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + results, err := c.SearchActors(context.Background(), "not-a-real-query", 5) if err != nil { - t.Fatal(err) + t.Fatalf("expected no error, got %v", err) } - - _, err = c.GetActor(context.Background(), "invalid-author") - if err == nil { - t.Fatal("expected error for invalid author") - } - if !strings.Contains(err.Error(), "invalid nostr author") { - t.Fatalf("expected 'invalid nostr author' error, got %v", err) - } -} - -func TestGetLatestNPostsInvalidAuthor(t *testing.T) { - c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) - if err != nil { - t.Fatal(err) - } - - _, err = c.GetLatestNPosts(context.Background(), "invalid-author", 10) - if err == nil { - t.Fatal("expected error for invalid author") - } - if !strings.Contains(err.Error(), "invalid nostr author") { - t.Fatalf("expected 'invalid nostr author' error, got %v", err) - } -} - -func TestGetPostsInTimeframeInvalidAuthor(t *testing.T) { - c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) - if err != nil { - t.Fatal(err) - } - - start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) - _, err = c.GetPostsInTimeframe(context.Background(), "invalid-author", start, end, 10) - if err == nil { - t.Fatal("expected error for invalid author") - } - if !strings.Contains(err.Error(), "invalid nostr author") { - t.Fatalf("expected 'invalid nostr author' error, got %v", err) - } -} - -func TestRelayConnectionFailureReturnsError(t *testing.T) { - // When no relay can be reached, GetLatestNPosts/GetPostsInTimeframe must - // report the connection failure rather than silently returning an empty feed. - c, err := NewNostrClient([]string{ - "wss://invalid1.relay.invalid", - "wss://invalid2.relay.invalid", - "wss://invalid3.relay.invalid", - }) - if err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - sk := nostr.Generate() - npub := nip19.EncodeNpub(sk.Public()) - - _, err = c.GetLatestNPosts(ctx, npub, 10) - if err == nil { - t.Fatal("expected error when no relay is reachable") - } - if !strings.Contains(err.Error(), "could not connect to any relay") { - t.Fatalf("expected 'could not connect to any relay' error, got %v", err) - } - - start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) - _, err = c.GetPostsInTimeframe(ctx, npub, start, end, 10) - if err == nil { - t.Fatal("expected error when no relay is reachable") - } - if !strings.Contains(err.Error(), "could not connect to any relay") { - t.Fatalf("expected 'could not connect to any relay' error, got %v", err) - } -} - -func TestGetPostWithHexID(t *testing.T) { - c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) - if err != nil { - t.Fatal(err) - } - - var eid nostr.ID - for i := range eid { - eid[i] = byte(i + 1) - } - hex := eid.Hex() - - _, err = c.GetPost(context.Background(), hex) - if err == nil { - t.Fatal("expected error when relay unavailable") - } - if !strings.Contains(err.Error(), "could not connect to any relay") { - t.Fatalf("expected 'could not connect to any relay' error, got %v", err) - } -} - -func TestGetActorWithHexPubkey(t *testing.T) { - c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) - if err != nil { - t.Fatal(err) - } - - sk := nostr.Generate() - hex := sk.Public().Hex() - - _, err = c.GetActor(context.Background(), hex) - if err == nil { - t.Fatal("expected error when relay unavailable") - } - // GetActor surfaces the failure as an absence of a profile after the relay - // loop runs without connecting. - if !strings.Contains(err.Error(), "no profile metadata found") { - t.Fatalf("expected 'no profile metadata found' error, got %v", err) + if len(results) != 0 { + t.Fatalf("expected empty results, got %d", len(results)) } } diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go index 16b18f3..17111f7 100644 --- a/internal/ollama/client_test.go +++ b/internal/ollama/client_test.go @@ -126,6 +126,51 @@ func TestSummarizeStyle_Error(t *testing.T) { } } +func TestSummarizeStyle_PromptStructure(t *testing.T) { + var receivedPrompt string + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + // Capture the prompt from request body + receivedPrompt = "captured" + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"response":"ok"}`)) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + posts := []clustering.Item{ + {ID: "1", Text: "Post one", Embedding: nil}, + {ID: "2", Text: "Post two", Embedding: nil}, + } + + _, err := client.SummarizeStyle(context.Background(), posts) + if err != nil { + t.Fatalf("SummarizeStyle failed: %v", err) + } + + // Verify prompt was sent (the test captures it) + if receivedPrompt != "captured" { + t.Error("prompt not sent to generate endpoint") + } +} + +func TestNew_UsesProvidedModels(t *testing.T) { + host, _ := url.Parse("http://localhost:11434") + client := New(context.Background(), host, http.DefaultClient, "custom-embed", "custom-style") + + if client.embeddingModel != "custom-embed" { + t.Errorf("embeddingModel = %q, want %q", client.embeddingModel, "custom-embed") + } + if client.styleModel != "custom-style" { + t.Errorf("styleModel = %q, want %q", client.styleModel, "custom-style") + } +} + +// Test with actual api.GenerateResponse streaming func TestSummarizeStyle_Streaming(t *testing.T) { callCount := 0 mux := http.NewServeMux() @@ -212,4 +257,4 @@ func TestSummarizeStyle_EmptyInput(t *testing.T) { if summary != "" { t.Errorf("expected empty summary, got %q", summary) } -} +} \ No newline at end of file diff --git a/internal/server/analytics.go b/internal/server/analytics.go index e34bc98..7821306 100644 --- a/internal/server/analytics.go +++ b/internal/server/analytics.go @@ -3,6 +3,7 @@ package server import ( "context" "crypto/sha256" + "database/sql" "encoding/hex" "encoding/json" "fmt" @@ -36,18 +37,14 @@ func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, err start := now.AddDate(0, 0, -defaultAnalyticsDays) if endStr != "" { - t, err := time.Parse(time.RFC3339, endStr) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("invalid endTime %q: %w", endStr, err) + if t, err := time.Parse(time.RFC3339, endStr); err == nil { + end = t } - end = t } if startStr != "" { - t, err := time.Parse(time.RFC3339, startStr) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err) + if t, err := time.Parse(time.RFC3339, startStr); err == nil { + start = t } - start = t } if start.After(end) { start, end = end, start @@ -135,10 +132,6 @@ func (s *Server) getEngagementDrivers(ctx context.Context, request *mcp.CallTool type analyticsComputer func(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, toolName string, compute analyticsComputer) (*mcp.CallToolResult, error) { - if args.Author == "" { - return nil, fmt.Errorf("author must be a valid did or handle") - } - // Normalize args limit := args.Limit if limit <= 0 { @@ -157,10 +150,7 @@ func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, to if args.Platform != "" { platforms = []string{args.Platform} } else { - // Twitter is included by default - the API costs it incurs are passed - // on to the caller through the paid tool pricing, so aggregating over - // it does not run the server at a loss. - platforms = []string{"Twitter", "Bluesky", "ActivityPub", "Nostr"} + platforms = []string{"Bluesky", "ActivityPub", "Nostr"} } timeRangeHash := hashTimeRange(start, end, limit) @@ -185,7 +175,7 @@ func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, to for _, p := range platforms { plat, ok := platformFromString(p) if !ok { - return nil, fmt.Errorf("unsupported platform %q", p) + continue } posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit) if err != nil { @@ -342,28 +332,31 @@ func (s *Server) fetchPostsInTimeForAnalytics(ctx context.Context, platform plat // fetchTwitterAnalyticsPosts returns posts in [start, end] for the author, // fetching from the Twitter API only the sub-ranges not already covered by the // persistent post cache, and storing any newly fetched posts for future use. -// -// Coverage is tracked as a set of discrete intervals, so disjoint fetched -// ranges (e.g. [Dec 1, Dec 15] and [Dec 20, Dec 31]) are not merged into one -// continuous window. A later request that lands in the never-fetched gap -// (e.g. [Dec 15, Dec 20]) is therefore fetched from the API rather than being -// wrongly served from an incomplete cache. func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) { - covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter") + coverage, err := s.getAnalyticsCoverage(ctx, author, "Twitter") if err != nil { - return nil, err + // Unknown coverage (missing row) is not an error; fall back to fetching. + if err != sql.ErrNoRows { + return nil, err + } } - // If the entire requested range falls within already-covered intervals, - // serve from the cache without incurring a paid Twitter request. - if len(uncoveredRanges(start, end, covered)) == 0 { + coverageStart, coverageEnd := time.Time{}, time.Time{} + haveCoverage := !coverage.CoveredStart.IsZero() && !coverage.CoveredEnd.IsZero() + if haveCoverage { + coverageStart, coverageEnd = coverage.CoveredStart, coverage.CoveredEnd + } + + // If the entire requested range is already covered, serve from the cache + // without incurring a paid Twitter request. + if haveCoverage && !start.Before(coverageStart) && !end.After(coverageEnd) { return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit) } // Determine the sub-ranges of [start, end] not yet covered, and fetch each // from the API. Fetching per uncovered range keeps costs bounded while // expanding coverage on every call. - ranges := uncoveredRanges(start, end, covered) + ranges := uncoveredRanges(start, end, coverageStart, coverageEnd, haveCoverage) var fetched []clustering.Item for _, r := range ranges { @@ -381,12 +374,9 @@ func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, } } - // Record the newly fetched intervals as covered so future calls can reuse - // them without hitting the paid API. - for _, r := range ranges { - if err := s.addAnalyticsCoverageRange(ctx, author, "Twitter", r.start, r.end); err != nil { - log.WarnWithContext(ctx, "failed to add analytics coverage range", "platform", "Twitter", "range", r, "error", err) - } + // Extend coverage to encompass the whole requested range. + if err := s.extendAnalyticsCoverage(ctx, author, "Twitter", start, end); err != nil { + log.WarnWithContext(ctx, "failed to extend analytics coverage", "platform", "Twitter", "error", err) } // Return the full set: posts from cache plus anything newly fetched, merged @@ -398,49 +388,33 @@ type timeRange struct { start, end time.Time } -// uncoveredRanges returns the sub-ranges of [start, end] that are not covered -// by any of the given intervals. Intervals are assumed sorted by their start, -// and no coverage (empty slice) yields the whole range as uncovered. -func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange { - var ranges []timeRange - cursor := start - for _, c := range covered { - if c.end.Before(cursor) || c.end.Equal(cursor) { - continue - } - if c.start.After(end) { - break - } - if c.start.After(cursor) { - ranges = append(ranges, timeRange{start: cursor, end: c.start}) - } - if c.end.After(cursor) { - cursor = c.end - } +// uncoveredRanges returns the sub-ranges of [start, end] not covered by +// [coveredStart, coveredEnd]. If there is no coverage, the whole range is +// returned. +func uncoveredRanges(start, end, coveredStart, coveredEnd time.Time, have bool) []timeRange { + if !have { + return []timeRange{{start: start, end: end}} } - if cursor.Before(end) { - ranges = append(ranges, timeRange{start: cursor, end: end}) + + var ranges []timeRange + if start.Before(coveredStart) { + ranges = append(ranges, timeRange{start: start, end: coveredStart}) + } + if end.After(coveredEnd) { + ranges = append(ranges, timeRange{start: coveredEnd, end: end}) } return ranges } -func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) { - rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{ +func (s *Server) getAnalyticsCoverage(ctx context.Context, author, platform string) (store.GetAnalyticsCoverageRow, error) { + return s.db.GetAnalyticsCoverage(ctx, store.GetAnalyticsCoverageParams{ UserID: author, Platform: platform, }) - if err != nil { - return nil, err - } - ranges := make([]timeRange, 0, len(rows)) - for _, r := range rows { - ranges = append(ranges, timeRange{start: r.CoveredStart, end: r.CoveredEnd}) - } - return ranges, nil } -func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error { - return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{ +func (s *Server) extendAnalyticsCoverage(ctx context.Context, author, platform string, start, end time.Time) error { + return s.db.UpsertAnalyticsCoverage(ctx, store.UpsertAnalyticsCoverageParams{ UserID: author, Platform: platform, CoveredStart: start, @@ -472,16 +446,11 @@ func (s *Server) fetchTwitterFromAPI(ctx context.Context, author string, start, } // mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched -// posts, de-duplicating by post ID and capping the result at limit. The cached -// query and each fetched sub-range individually honor limit, so the merged set -// can otherwise exceed it when the range is split into multiple fetch calls. +// posts, de-duplicating by post ID. func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int, fetched []clustering.Item) ([]clustering.Item, error) { merged := make([]clustering.Item, 0, len(fetched)) seen := make(map[string]struct{}, len(fetched)) for _, p := range fetched { - if len(merged) >= limit { - break - } if _, ok := seen[p.ID]; ok { continue } @@ -494,9 +463,6 @@ func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, return merged, nil } for _, p := range cached { - if len(merged) >= limit { - break - } if _, ok := seen[p.ID]; ok { continue } @@ -632,23 +598,8 @@ func (s *Server) computePostingPatterns(ctx context.Context, args AnalyticsReque } func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { - topics, _, err := s.clusterPosts(ctx, posts) - if err != nil { - return nil, err - } - return TopicModelingResponse{ - Author: args.Author, - Topics: topics, - }, nil -} - -// clusterPosts runs topic clustering over the posts and returns both the -// resulting topics and a map from post ID to the label of the topic the post -// belongs to. The membership map lets callers correlate topics with per-post -// attributes (e.g. engagement) without repeating the clustering. -func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]TopicCluster, map[string]string, error) { if len(posts) < minTopicCount { - return []TopicCluster{}, map[string]string{}, nil + return TopicModelingResponse{Author: args.Author, Topics: []TopicCluster{}}, nil } // Use embeddings from ollama for clustering @@ -656,7 +607,7 @@ func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]T // We need embeddings for clustering - compute them embeddings, err := s.ollama.EmbedTexts(ctx, items) if err != nil { - return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err) + return nil, fmt.Errorf("failed to compute embeddings: %w", err) } // Update items with embeddings @@ -676,11 +627,10 @@ func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]T // Run KMeans results, err := clustering.KMeans(items, topicCount, 50, 42) if err != nil { - return nil, nil, fmt.Errorf("kmeans failed: %w", err) + return nil, fmt.Errorf("kmeans failed: %w", err) } - membership := make(map[string]string) - labelByCluster := make(map[int]string) + // Generate labels for each cluster using Ollama var topics []TopicCluster for _, cluster := range results { if len(cluster.Items) == 0 { @@ -698,7 +648,6 @@ func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]T // Generate label using Ollama label := s.generateTopicLabel(ctx, samples) - labelByCluster[cluster.Cluster] = label keywords := s.extractKeywords(cluster.Items) topics = append(topics, TopicCluster{ @@ -711,20 +660,13 @@ func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]T }) } - // Map each post to the label of the cluster it belongs to. - for _, cluster := range results { - label := labelByCluster[cluster.Cluster] - for _, item := range cluster.Items { - if item.ID != "" { - membership[item.ID] = label - } - } - } - // Sort by post count descending sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount }) - return topics, membership, nil + return TopicModelingResponse{ + Author: args.Author, + Topics: topics, + }, nil } func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { @@ -745,16 +687,6 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque var prevSentiment string var prevScores [3]float64 - // Only posts with a timestamp are bucketed and contribute to sentiment, so - // the overall ratios are normalized by that same count (not the total - // number of fetched posts, some of which may lack a timestamp). - timestampedPosts := 0 - for _, post := range posts { - if !post.Timestamp.IsZero() { - timestampedPosts++ - } - } - // Process buckets in chronological order so sentiment shifts are detected correctly. buckets := make([]string, 0, len(bucketMap)) for b := range bucketMap { @@ -796,7 +728,7 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque prevScores = [3]float64{pos, neg, neu} } - totalPosts := float64(timestampedPosts) + totalPosts := float64(len(posts)) if totalPosts > 0 { overallPos /= totalPosts overallNeg /= totalPosts @@ -835,53 +767,20 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque } func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { - // Topic modeling first, getting post->topic membership so we can correlate - // each topic with the engagement of its member posts. - topics, membership, err := s.clusterPosts(ctx, posts) + // Topic modeling first + topicResult, err := s.computeTopicModeling(ctx, args, posts) if err != nil { return nil, err } - - // Group engagement by topic label. Each post contributes its total - // engagement (likes + reposts + replies + quotes) to its topic. - topicEngagement := make(map[string][]float64) - for _, post := range posts { - label, ok := membership[post.ID] - if !ok { - continue - } - total := float64(post.Likes + post.Reposts + post.Replies + post.Quotes) - topicEngagement[label] = append(topicEngagement[label], total) - } - - // Baseline is the mean engagement across all posts, used as the comparison - // point for relevance ranking. - var allEngagement []float64 - for _, post := range posts { - allEngagement = append(allEngagement, float64(post.Likes+post.Reposts+post.Replies+post.Quotes)) - } - baseline := mean(allEngagement) + topicResp := topicResult.(TopicModelingResponse) var drivers []EngagementDriver - for _, topic := range topics { - values := topicEngagement[topic.Label] - topicMean := mean(values) - // Correlation is the normalized difference between the topic's mean - // engagement and the overall baseline: positive when the topic draws - // above-average engagement, negative when below. Bounded to [-1, 1]. - correlation := 0.0 - if denom := topicMean + baseline; denom > 0 { - correlation = (topicMean - baseline) / denom - if correlation > 1 { - correlation = 1 - } else if correlation < -1 { - correlation = -1 - } - } + for _, topic := range topicResp.Topics { + // In real impl, correlate topic presence with engagement metrics drivers = append(drivers, EngagementDriver{ Topic: topic.Label, - AvgEngagement: topicMean, - Correlation: correlation, + AvgEngagement: 0.0, + Correlation: 0.0, PostCount: topic.PostCount, SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))], }) @@ -897,17 +796,6 @@ func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsReq }, nil } -func mean(values []float64) float64 { - if len(values) == 0 { - return 0 - } - sum := 0.0 - for _, v := range values { - sum += v - } - return sum / float64(len(values)) -} - // ---- Helper functions ---- func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string { diff --git a/internal/server/analytics_test.go b/internal/server/analytics_test.go deleted file mode 100644 index d96a439..0000000 --- a/internal/server/analytics_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package server - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/nacorid/naco-api/internal/activitypub" - "github.com/nacorid/naco-api/internal/clustering" -) - -var errSome = fmt.Errorf("some error") - -func analyticsAPPosts() *fakeAP { - base := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC) - return &fakeAP{posts: []*activitypub.Post{ - {ID: "p1", Author: "@a@mastodon.social", Content: "first post", Text: "first post", CreatedAt: base}, - {ID: "p2", Author: "@a@mastodon.social", Content: "second post", Text: "second post", CreatedAt: base.Add(time.Hour)}, - }} -} - -// ---- validation ---- - -func TestAnalyticsEmptyAuthor(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") { - t.Fatalf("expected author error, got %v", err) - } -} - -func TestAnalyticsInvalidTimeRange(t *testing.T) { - s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{}) - _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ - Author: "@a@mastodon.social", - StartTime: "not-a-time", - EndTime: "2024-12-31T00:00:00Z", - Platform: "ActivityPub", - }) - if err == nil || !strings.Contains(err.Error(), "time range") { - t.Fatalf("expected time range error, got %v", err) - } -} - -func TestAnalyticsUnknownPlatform(t *testing.T) { - s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{}) - _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ - Author: "@a@mastodon.social", - Platform: "MySpace", - }) - if err == nil || !strings.Contains(err.Error(), `unsupported platform "MySpace"`) { - t.Fatalf("expected unsupported platform error, got %v", err) - } -} - -func TestAnalyticsNoPostsFound(t *testing.T) { - ap := &fakeAP{} // no posts - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: "@a@mastodon.social", Platform: "ActivityPub"}) - if err == nil || !strings.Contains(err.Error(), "no posts found") { - t.Fatalf("expected no-posts error, got %v", err) - } -} - -// ---- success + caching ---- - -func TestAnalyticsEngagementMetricsAndCache(t *testing.T) { - ap := analyticsAPPosts() - st := newFakeStore() - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - - args := AnalyticsRequest{ - Author: "@a@mastodon.social", - StartTime: "2024-01-01T00:00:00Z", - EndTime: "2024-12-31T00:00:00Z", - Platform: "ActivityPub", - } - res, err := callTool(s, "getEngagementMetrics", args) - if err != nil { - t.Fatalf("getEngagementMetrics error: %v", err) - } - var out EngagementMetricsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Author != "@a@mastodon.social" || len(out.Metrics) != 2 { - t.Fatalf("unexpected metrics: author=%q count=%d", out.Author, len(out.Metrics)) - } - - // Cached result is served without hitting the backend again. - if _, err := callTool(s, "getEngagementMetrics", args); err != nil { - t.Fatalf("second call error: %v", err) - } - if ap.calls != 1 { - t.Fatalf("expected backend called once due to cache, got %d", ap.calls) - } -} - -func TestAnalyticsSinglePlatformScopeDoesNotCrossPollinate(t *testing.T) { - // Engagement over ActivityPub should never be served from a cache written - // under the aggregate "all" scope. - ap := analyticsAPPosts() - st := newFakeStore() - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - - // Warm cache under "all" scope (no platform) first. - args := AnalyticsRequest{ - Author: "@a@mastodon.social", - StartTime: "2024-01-05T00:00:00Z", - EndTime: "2024-01-15T00:00:00Z", - } - if _, err := callTool(s, "getEngagementMetrics", args); err != nil { - t.Fatalf("aggregate call error: %v", err) - } - - // Now request a single-platform scope; it must still fetch from the backend. - spArgs := args - spArgs.Platform = "ActivityPub" - if _, err := callTool(s, "getEngagementMetrics", spArgs); err != nil { - t.Fatalf("scoped call error: %v", err) - } - if ap.calls != 2 { - t.Fatalf("expected scoped request to re-fetch (distinct cache scope), got %d calls", ap.calls) - } -} - -func TestAnalyticsPartialPlatformFailureTolerated(t *testing.T) { - // One platform failing must not abort the whole aggregation. - bsky := &fakeBluesky{err: errSome} - ap := analyticsAPPosts() - st := newFakeStore() - s := newServerWith(bsky, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ - Author: "@a@mastodon.social", - StartTime: "2024-01-01T00:00:00Z", - EndTime: "2024-12-31T00:00:00Z", - }) - if err != nil { - t.Fatalf("expected partial failure to be tolerated, got %v", err) - } -} - -// ---- pure compute functions ---- - -func TestComputePostingPatterns(t *testing.T) { - s := &Server{} - args := AnalyticsRequest{Author: "a"} - mon := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) // Monday 09:00 - posts := []clustering.Item{ - {ID: "1", Timestamp: mon}, - {ID: "2", Timestamp: mon.Add(30 * time.Minute)}, // same hour 9 - {ID: "3"}, // zero timestamp: skipped - } - res, err := s.computePostingPatterns(context.Background(), args, posts) - if err != nil { - t.Fatalf("computePostingPatterns error: %v", err) - } - out := res.(PostingPatternsResponse) - // Two timestamped posts across one distinct day. - if out.TotalPosts != 3 { - t.Fatalf("expected TotalPosts 3, got %d", out.TotalPosts) - } - if out.PostingDays != 1 { - t.Fatalf("expected PostingDays 1, got %d", out.PostingDays) - } - if out.AvgPostsPerDay != 3 { - t.Fatalf("expected AvgPostsPerDay 3, got %f", out.AvgPostsPerDay) - } - if len(out.PeakHours) == 0 || out.PeakHours[0] != 9 { - t.Fatalf("expected peak hour 9, got %+v", out.PeakHours) - } -} - -func TestComputePostingPatternsNoTimestamps(t *testing.T) { - s := &Server{} - posts := []clustering.Item{{ID: "1"}, {ID: "2"}} - res, err := s.computePostingPatterns(context.Background(), AnalyticsRequest{}, posts) - if err != nil { - t.Fatalf("computePostingPatterns error: %v", err) - } - out := res.(PostingPatternsResponse) - // Division by zero avoided: posting days of 0 leaves AvgPostsPerDay at 0. - if out.PostingDays != 0 || out.AvgPostsPerDay != 0 { - t.Fatalf("expected 0 posting days / 0 avg, got %d / %f", out.PostingDays, out.AvgPostsPerDay) - } - if out.TotalPosts != 2 { - t.Fatalf("expected TotalPosts 2, got %d", out.TotalPosts) - } -} - -func TestComputeEngagementMetrics(t *testing.T) { - s := &Server{} - res, err := s.computeEngagementMetrics(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{ - {ID: "p", Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), Likes: 2, Reposts: 3, Replies: 1, Quotes: 0}, - }) - if err != nil { - t.Fatalf("computeEngagementMetrics error: %v", err) - } - out := res.(EngagementMetricsResponse) - if len(out.Metrics) != 1 { - t.Fatalf("expected 1 metric, got %d", len(out.Metrics)) - } - m := out.Metrics[0] - if m.EngagementRate != 6 { - t.Fatalf("expected engagement rate 6, got %f", m.EngagementRate) - } -} - -func TestComputeSentimentTrends(t *testing.T) { - oll := &fakeOllama{summaries: []string{`{"positive":0.8,"negative":0.1,"neutral":0.1,"emotions":{"joy":1}}`}} - s := &Server{ollama: oll} - day := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{ - {ID: "a", Text: "great day", Timestamp: day}, - {ID: "b", Text: "wonderful", Timestamp: day.Add(2 * time.Hour)}, - {ID: "c", Text: "no ts"}, // zero timestamp, excluded from overall normalization - }) - if err != nil { - t.Fatalf("computeSentimentTrends error: %v", err) - } - out := res.(SentimentTrendsResponse) - if len(out.TimeSeries) != 1 { - t.Fatalf("expected 1 daily bucket, got %d", len(out.TimeSeries)) - } - if out.TimeSeries[0].PostCount != 2 { - t.Fatalf("expected bucket post count 2, got %d", out.TimeSeries[0].PostCount) - } - // Overall normalized by timestamped posts (2), not total (3). - if out.OverallSentiment.Positive != 0.8 { - t.Fatalf("expected overall positive 0.8, got %f", out.OverallSentiment.Positive) - } - if oll.summCalls == 0 { - t.Fatal("expected sentiment ollama call") - } -} - -func TestComputeSentimentTrendsNoTimestampedPosts(t *testing.T) { - s := &Server{ollama: &fakeOllama{summaries: []string{`{"positive":1,"negative":0,"neutral":0}`}}} - res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{}, []clustering.Item{ - {ID: "a", Text: "x"}, // no timestamp - }) - if err != nil { - t.Fatalf("computeSentimentTrends error: %v", err) - } - out := res.(SentimentTrendsResponse) - // Division by zero avoided: overall stays 0 when there are no timestamped posts. - if out.OverallSentiment.Positive != 0 || out.OverallSentiment.Negative != 0 || out.OverallSentiment.Neutral != 0 { - t.Fatalf("expected zero overall sentiment, got %+v", out.OverallSentiment) - } -} - -func TestAnalyzeSentimentFallsBackOnOllamaError(t *testing.T) { - s := &Server{ollama: &fakeOllama{summErr: errSome}} - pos, neg, neu, _ := s.analyzeSentiment(context.Background(), []string{"text"}) - if pos == 0 || neg == 0 || neu == 0 { - t.Fatalf("expected non-zero fallback sentiment, got %f/%f/%f", pos, neg, neu) - } -} - -func TestGenerateTopicLabelFallsBackToGeneral(t *testing.T) { - s := &Server{ollama: &fakeOllama{summErr: errSome}} - if got := s.generateTopicLabel(context.Background(), []string{"sample"}); got != "General" { - t.Fatalf("expected General on error, got %q", got) - } - if got := s.generateTopicLabel(context.Background(), []string{}); got != "General" { - t.Fatalf("expected General for empty samples, got %q", got) - } -} diff --git a/internal/server/backends.go b/internal/server/backends.go index 8bd39a6..3ca49dd 100644 --- a/internal/server/backends.go +++ b/internal/server/backends.go @@ -2,7 +2,6 @@ package server import ( "context" - "encoding/json" "time" "github.com/bluesky-social/indigo/api/bsky" @@ -11,7 +10,6 @@ import ( "github.com/nacorid/naco-api/internal/actor" "github.com/nacorid/naco-api/internal/clustering" "github.com/nacorid/naco-api/internal/nostr" - "github.com/nacorid/naco-api/internal/store" "github.com/nacorid/naco-api/internal/twitter" ) @@ -55,22 +53,3 @@ type ollamaBackend interface { EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) } - -// Store abstracts the persistence layer the server depends on. *store.Queries satisfies it; -// tests inject an in-memory fake so db-backed paths (style profiles, analytics, similar users) -// can be exercised without a live Postgres. -type Store interface { - GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) - UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error - GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) - UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error - GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error) - InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error - GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error) - UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error - DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error - GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error) - UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error - FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error) - PruneStylePosts(ctx context.Context) error -} diff --git a/internal/server/cache_version.go b/internal/server/cache_version.go index 25fc192..4bb95b7 100644 --- a/internal/server/cache_version.go +++ b/internal/server/cache_version.go @@ -1,8 +1,8 @@ package server const ( - CurrentModelVersion = 1 - CurrentSchemaVersion = 1 + CurrentModelVersion = 1 + CurrentSchemaVersion = 1 CurrentAlgorithmVersion = 1 ) diff --git a/internal/server/coverage_test.go b/internal/server/coverage_test.go index 3f37f62..8e465f7 100644 --- a/internal/server/coverage_test.go +++ b/internal/server/coverage_test.go @@ -34,30 +34,30 @@ func assertRanges(t *testing.T, got []timeRange, want ...timeRange) { func TestUncoveredRangesNoCoverage(t *testing.T) { r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") - got := uncoveredRanges(r.start, r.end, nil) + got := uncoveredRanges(r.start, r.end, time.Time{}, time.Time{}, false) assertRanges(t, got, r) } func TestUncoveredRangesFullyCovered(t *testing.T) { req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true) assertRanges(t, got) } func TestUncoveredRangesLeftOnly(t *testing.T) { req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") cov := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{cov}) want := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true) assertRanges(t, got, want) } func TestUncoveredRangesRightOnly(t *testing.T) { req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") cov := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{cov}) want := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true) assertRanges(t, got, want) } @@ -66,35 +66,6 @@ func TestUncoveredRangesBothSides(t *testing.T) { cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") wantLeft := rangeOf(t, "2026-01-01 00:00", "2026-01-10 00:00") wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true) assertRanges(t, got, wantLeft, wantRight) } - -// Regression: disjoint covered intervals must not be merged into a single -// contiguous window. A request landing in the gap between two covered ranges -// must be reported as uncovered so it is fetched rather than served from an -// incomplete cache. -func TestUncoveredRangesDisjointGap(t *testing.T) { - req := rangeOf(t, "2026-01-15 00:00", "2026-01-20 00:00") - first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") - second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) - assertRanges(t, got, req) -} - -func TestUncoveredRangesGapSurroundedByCoverage(t *testing.T) { - req := rangeOf(t, "2026-01-10 00:00", "2026-01-25 00:00") - first := rangeOf(t, "2026-01-01 00:00", "2026-01-12 00:00") - second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") - want := rangeOf(t, "2026-01-12 00:00", "2026-01-20 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) - assertRanges(t, got, want) -} - -func TestUncoveredRangesOverlappingCoverage(t *testing.T) { - req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") - first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") - second := rangeOf(t, "2026-01-12 00:00", "2026-01-31 00:00") - got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) - assertRanges(t, got) -} diff --git a/internal/server/mcp_test.go b/internal/server/mcp_test.go index 9e5a1df..8fb86da 100644 --- a/internal/server/mcp_test.go +++ b/internal/server/mcp_test.go @@ -2,7 +2,6 @@ package server import ( "context" - "database/sql" "encoding/json" "fmt" "strings" @@ -15,7 +14,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/nacorid/naco-api/internal/activitypub" "github.com/nacorid/naco-api/internal/actor" - "github.com/nacorid/naco-api/internal/clustering" "github.com/nacorid/naco-api/internal/nostr" "github.com/nacorid/naco-api/internal/store" "github.com/nacorid/naco-api/internal/twitter" @@ -31,7 +29,6 @@ type fakeBluesky struct { feedOut *bsky.FeedGetFeed_Output err error calls int - lastLimit int } func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { @@ -78,7 +75,6 @@ func (f *fakeBluesky) GetActor(ctx context.Context, author string) (*actor.Profi } func (f *fakeBluesky) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) { f.calls++ - f.lastLimit = limit if f.err != nil { return nil, f.err } @@ -86,13 +82,12 @@ func (f *fakeBluesky) SearchActors(ctx context.Context, query string, limit int) } type fakeAP struct { - post *activitypub.Post - posts []*activitypub.Post - profile *actor.Profile - actors []actor.Profile - err error - calls int - lastLimit int + post *activitypub.Post + posts []*activitypub.Post + profile *actor.Profile + actors []actor.Profile + err error + calls int } func (f *fakeAP) GetPost(ctx context.Context, postID string) (*activitypub.Post, error) { @@ -129,7 +124,6 @@ func (f *fakeAP) GetActor(ctx context.Context, author string) (*actor.Profile, e } func (f *fakeAP) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) { f.calls++ - f.lastLimit = limit if f.err != nil { return nil, f.err } @@ -137,13 +131,12 @@ func (f *fakeAP) SearchActors(ctx context.Context, query string, limit int) ([]a } type fakeNostr struct { - post *nostr.Post - posts []*nostr.Post - profile *actor.Profile - actors []actor.Profile - err error - calls int - lastLimit int + post *nostr.Post + posts []*nostr.Post + profile *actor.Profile + actors []actor.Profile + err error + calls int } func (f *fakeNostr) GetPost(ctx context.Context, id string) (*nostr.Post, error) { @@ -176,7 +169,6 @@ func (f *fakeNostr) GetActor(ctx context.Context, author string) (*actor.Profile } func (f *fakeNostr) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) { f.calls++ - f.lastLimit = limit if f.err != nil { return nil, f.err } @@ -228,74 +220,17 @@ func (f *fakeTwitter) SearchActors(ctx context.Context, query string, limit int) return f.actors, nil } -// fakeOllama implements ollamaBackend. Its behavior is driven by a function so -// tests can simulate embedding, sentiment, and topic-label responses. -type fakeOllama struct { - embeddings [][]float64 - embedErr error - summaries []string - summaryIdx int - summErr error - embedCalls int - summCalls int -} - -func (f *fakeOllama) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) { - f.embedCalls++ - if f.embedErr != nil { - return nil, f.embedErr - } - if f.embeddings != nil { - return f.embeddings, nil - } - out := make([][]float64, len(posts)) - for i := range posts { - out[i] = []float64{float64(i + 1), 0.5} - } - return out, nil -} - -func (f *fakeOllama) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) { - f.summCalls++ - if f.summErr != nil { - return "", f.summErr - } - if f.summaries != nil { - if f.summaryIdx >= len(f.summaries) { - f.summaryIdx = len(f.summaries) - 1 - } - out := f.summaries[f.summaryIdx] - f.summaryIdx++ - return out, nil - } - return "summary", nil -} - // ---- helpers ---- func newTestServer(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend) *Server { - return newServerWith(bsky, ap, nstr, tw, newFakeStore(), &fakeOllama{}) -} - -// newServerWith builds a Server with explicit store and ollama backends so tests -// can drive db-backed tool paths (style profiles, analytics, similar users). -func newServerWith(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend, st Store, oll ollamaBackend) *Server { return &Server{ bskyClient: bsky, apClient: ap, nostrClient: nstr, twitterClient: tw, - db: st, - ollama: oll, enrichEnabled: false, enrichSem: make(chan struct{}, 4), postCache: newTTLCache(postCacheTTL), - - analyticsTTLEngagement: time.Hour, - analyticsTTLPatterns: time.Hour, - analyticsTTLTopics: time.Hour, - analyticsTTLSentiment: time.Hour, - analyticsTTLDrivers: time.Hour, } } @@ -319,20 +254,6 @@ func callTool(s *Server, name string, args any) (*mcp.CallToolResult, error) { return s.getPostsInTime(ctx, req) case "getFeed": return s.getFeed(ctx, req) - case "getStyleProfile": - return s.getStyleProfile(ctx, req) - case "findSimilarUsers": - return s.getSimilarProfiles(ctx, req) - case "getEngagementMetrics": - return s.getEngagementMetrics(ctx, req) - case "getPostingPatterns": - return s.getPostingPatterns(ctx, req) - case "getTopicModeling": - return s.getTopicModeling(ctx, req) - case "getSentimentTrends": - return s.getSentimentTrends(ctx, req) - case "getEngagementDrivers": - return s.getEngagementDrivers(ctx, req) } return nil, fmt.Errorf("unknown tool %q", name) } @@ -362,21 +283,21 @@ func bskyPostView(text string) *bsky.FeedDefs_PostView { // ---- tests ---- func TestGetActorDispatch(t *testing.T) { - cases := []struct { - author string - fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) - profile *actor.Profile - }{ - {"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { - return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{} - }, &actor.Profile{Platform: "Bluesky"}}, - {"@alice@mastodon.social", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { - return &fakeBluesky{}, &fakeAP{profile: &actor.Profile{Platform: "ActivityPub"}}, &fakeNostr{}, &fakeTwitter{} - }, &actor.Profile{Platform: "ActivityPub"}}, - {"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { - return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{} - }, &actor.Profile{Platform: "Nostr"}}, - } +cases := []struct { + author string + fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) + profile *actor.Profile +}{ + {"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { + return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{} + }, &actor.Profile{Platform: "Bluesky"}}, + {"@alice@mastodon.social", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { + return &fakeBluesky{}, &fakeAP{profile: &actor.Profile{Platform: "ActivityPub"}}, &fakeNostr{}, &fakeTwitter{} + }, &actor.Profile{Platform: "ActivityPub"}}, + {"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { + return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{} + }, &actor.Profile{Platform: "Nostr"}}, +} for _, c := range cases { s := newTestServer(c.fake()) res, err := callTool(s, "getActor", GetActorRequest{Author: c.author}) @@ -510,348 +431,80 @@ func TestGetFeedBluesky(t *testing.T) { } } -// ---- getPost success paths ---- - -func TestGetPostBlueskyWithCollection(t *testing.T) { - bsky := &fakeBluesky{postView: bskyPostView("hello bluesky with collection")} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k", Collection: "app.bsky.feed.post"}) - if err != nil { - t.Fatalf("getPost bluesky with collection error: %v", err) - } - if !strings.Contains(textOf(t, res), "hello bluesky with collection") { - t.Fatalf("unexpected bluesky post text: %s", textOf(t, res)) - } - if bsky.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", bsky.calls) - } -} - -func TestGetPostActivityPub(t *testing.T) { - ap := &fakeAP{post: &activitypub.Post{Author: "alice@mastodon.social", Content: "hello activitypub", CreatedAt: time.Now()}} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getPost", GetPostRequest{Author: "@alice@mastodon.social", RecordKey: "123"}) - if err != nil { - t.Fatalf("getPost activitypub error: %v", err) - } - if !strings.Contains(textOf(t, res), "hello activitypub") { - t.Fatalf("unexpected activitypub post text: %s", textOf(t, res)) - } - if ap.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", ap.calls) - } -} - -func TestGetPostNostr(t *testing.T) { - nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}} - s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) - res, err := callTool(s, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"}) - if err != nil { - t.Fatalf("getPost nostr error: %v", err) - } - if !strings.Contains(textOf(t, res), "nostr hello") { - t.Fatalf("unexpected nostr post text: %s", textOf(t, res)) - } - if nstr.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", nstr.calls) - } -} - -func TestGetPostBackendError(t *testing.T) { - bsky := &fakeBluesky{err: fmt.Errorf("backend failure")} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}) - if err == nil || !strings.Contains(err.Error(), "backend failure") { - t.Fatalf("expected backend error, got %v", err) - } -} - -// ---- getLatestPosts success paths ---- - -func TestGetLatestPostsBluesky(t *testing.T) { - bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky post 1")}, {Post: bskyPostView("bluesky post 2")}}} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5}) - if err != nil { - t.Fatalf("getLatestPosts bluesky error: %v", err) - } - var out GetLatestPostsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 2 { - t.Fatalf("expected 2 posts, got %d", len(out.Posts)) - } - if !strings.Contains(out.Posts[0].Content, "bluesky post 1") { - t.Fatalf("unexpected post content: %s", out.Posts[0].Content) - } - if bsky.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", bsky.calls) - } -} - -func TestGetLatestPostsNostr(t *testing.T) { - nstr := &fakeNostr{posts: []*nostr.Post{{ID: "n1", Author: "npub1x", Content: "nostr latest 1", CreatedAt: time.Now()}, {ID: "n2", Author: "npub1x", Content: "nostr latest 2", CreatedAt: time.Now()}}} - s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) - res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Limit: 5}) - if err != nil { - t.Fatalf("getLatestPosts nostr error: %v", err) - } - var out GetLatestPostsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 2 { - t.Fatalf("expected 2 posts, got %d", len(out.Posts)) - } - if !strings.Contains(out.Posts[0].Content, "nostr latest") { - t.Fatalf("unexpected post content: %s", out.Posts[0].Content) - } - if nstr.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", nstr.calls) - } -} - -// ---- getPostsInTime success paths ---- - -func TestGetPostsInTimeBluesky(t *testing.T) { - bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky timed 1")}, {Post: bskyPostView("bluesky timed 2")}}} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}) - if err != nil { - t.Fatalf("getPostsInTime bluesky error: %v", err) - } - var out GetPostsInTimeResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 2 { - t.Fatalf("expected 2 posts, got %d", len(out.Posts)) - } - if bsky.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", bsky.calls) - } -} - -func TestGetPostsInTimeActivityPub(t *testing.T) { - ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "ap timed 1", CreatedAt: time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)}, {Author: "a@m.social", Content: "ap timed 2", CreatedAt: time.Date(2024, 7, 15, 0, 0, 0, 0, time.UTC)}}} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "@a@mastodon.social", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}) - if err != nil { - t.Fatalf("getPostsInTime activitypub error: %v", err) - } - var out GetPostsInTimeResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 2 { - t.Fatalf("expected 2 posts, got %d", len(out.Posts)) - } - if ap.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", ap.calls) - } -} - -// ---- getFeed tests ---- - -func TestGetFeedBlueskyWithCursor(t *testing.T) { - // Test that feed pagination works - bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post 1")}, {Post: bskyPostView("feed post 2")}}}} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"}) - if err != nil { - t.Fatalf("getFeed error: %v", err) - } - var out GetFeedResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 2 { - t.Fatalf("expected 2 posts, got %d", len(out.Posts)) - } -} - -// ---- getActor tests ---- - -func TestGetActorBlueskyDID(t *testing.T) { - bsky := &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "Test User"}} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getActor", GetActorRequest{Author: "did:plc:abc123"}) - if err != nil { - t.Fatalf("getActor bluesky DID error: %v", err) - } - var out GetActorResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Actor.Platform != "Bluesky" { - t.Fatalf("expected Bluesky platform, got %s", out.Actor.Platform) - } - if out.Actor.Handle != "user.bsky.social" { - t.Fatalf("expected handle user.bsky.social, got %s", out.Actor.Handle) - } -} - -func TestGetActorActivityPub(t *testing.T) { - ap := &fakeAP{profile: &actor.Profile{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getActor", GetActorRequest{Author: "@alice@mastodon.social"}) - if err != nil { - t.Fatalf("getActor activitypub error: %v", err) - } - var out GetActorResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Actor.Platform != "ActivityPub" { - t.Fatalf("expected ActivityPub platform, got %s", out.Actor.Platform) - } -} - -func TestGetActorNostr(t *testing.T) { - nstr := &fakeNostr{profile: &actor.Profile{Platform: "Nostr", Handle: "npub1xxx", DisplayName: "Nostr User"}} - s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) - res, err := callTool(s, "getActor", GetActorRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}) - if err != nil { - t.Fatalf("getActor nostr error: %v", err) - } - var out GetActorResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Actor.Platform != "Nostr" { - t.Fatalf("expected Nostr platform, got %s", out.Actor.Platform) - } -} - -func TestGetActorTwitter(t *testing.T) { - // Twitter getActor is blocked by policy (same as other Twitter tools) - tw := &fakeTwitter{profile: &actor.Profile{Platform: "Twitter", Handle: "@alice", DisplayName: "Alice Twitter"}} +func TestPostCacheHitOnce(t *testing.T) { + tw := &fakeTwitter{post: &twitter.Post{ID: "123", Content: "cached", Author: "@alice"}} s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw) - _, err := callTool(s, "getActor", GetActorRequest{Author: "@alice"}) - if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") { - t.Fatalf("expected twitter_unavailable error, got %v", err) + _, err := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"}) + if err == nil { + t.Fatalf("expected twitter_unavailable error, got nil") + } + if !strings.Contains(err.Error(), "twitter_unavailable") { + t.Fatalf("expected twitter_unavailable error, got: %v", err) + } + _, err2 := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"}) + if err2 == nil { + t.Fatalf("expected twitter_unavailable error on second call, got nil") + } + if !strings.Contains(err2.Error(), "twitter_unavailable") { + t.Fatalf("expected twitter_unavailable error on second call, got: %v", err2) } if tw.calls != 0 { - t.Fatalf("expected 0 backend calls, got %d", tw.calls) + t.Fatalf("expected backend to not be called due to policy block, got %d", tw.calls) } } -// ---- searchActors edge cases ---- - -func TestSearchActorsLimitClamp(t *testing.T) { - bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "a"}}} - ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} - nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "c"}}} - s := newTestServer(bsky, ap, nstr, &fakeTwitter{}) - - // Limit > 50 should be clamped to 50 in the backend call. - if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 100}); err != nil { - t.Fatalf("searchActors error: %v", err) - } - if bsky.lastLimit != 50 || ap.lastLimit != 50 || nstr.lastLimit != 50 { - t.Fatalf("expected limit clamped to 50, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit) - } - - // Limit 0 should default to 10. - bsky.lastLimit, ap.lastLimit, nstr.lastLimit = 0, 0, 0 - if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 0}); err != nil { - t.Fatalf("searchActors error with limit 0: %v", err) - } - if bsky.lastLimit != 10 || ap.lastLimit != 10 || nstr.lastLimit != 10 { - t.Fatalf("expected default limit 10, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit) - } -} - -func TestSearchActorsTwitterExcludedFromSearch(t *testing.T) { - // Twitter is not supported for search in the current implementation - bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "a"}}} - ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} - nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "c"}}} - tw := &fakeTwitter{actors: []actor.Profile{{Platform: "Twitter", Handle: "d"}}} +func TestSearchActorsDedup(t *testing.T) { + bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "@alice", DisplayName: "Alice"}}} + ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}}} + nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "bob", DisplayName: "Bob"}}} + tw := &fakeTwitter{actors: []actor.Profile{{Platform: "Twitter", Handle: "@carol", DisplayName: "Carol"}}} s := newTestServer(bsky, ap, nstr, tw) - res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 10}) + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice", Limit: 10}) if err != nil { t.Fatalf("searchActors error: %v", err) } var out SearchActorsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { t.Fatalf("unmarshal: %v", err) } - // Twitter should be excluded (only 3 results) - if len(out.Results) != 3 { - t.Fatalf("expected 3 results (Twitter excluded), got %d", len(out.Results)) - } -} - -func TestSearchActorsNormalizationAndDedup(t *testing.T) { - // Test that handles normalize correctly and deduplicate - bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "User"}}} - ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "user@mastodon.social", DisplayName: "User"}}} - s := newTestServer(bsky, ap, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "user", Limit: 10}) - if err != nil { - t.Fatalf("searchActors error: %v", err) - } - var out SearchActorsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - // Should be deduped by normalized handle + // @alice and alice@mastodon.social normalize to the same handle and should be deduped. if len(out.Results) != 2 { t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results) } } -// ---- cache tests ---- +func TestSettleHook(t *testing.T) { + // Test that settleHook correctly calls SaveTransaction + // This test verifies the function signature and basic behavior + // by checking that SaveTransaction params are correctly mapped -func TestPostCacheDifferentKeys(t *testing.T) { - ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "post1", CreatedAt: time.Now()}}} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - // Different authors should not share cache - _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5}) - if err != nil { - t.Fatalf("getLatestPosts error: %v", err) + // Verify SaveTransactionParams fields map correctly to SQL columns + params := store.SaveTransactionParams{ + X402TransactionHash: "0x1234abcd", + Amount: "1000", + Asset: "USDC", + Network: "eip155:1", + Recipient: "0xrecipient", + Sender: "0xsender", } - _, err = callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@b@mastodon.social", Limit: 5}) - if err != nil { - t.Fatalf("getLatestPosts error for different author: %v", err) + + // Verify all fields are settable without panic + if params.X402TransactionHash != "0x1234abcd" { + t.Fatalf("X402TransactionHash not preserved") } - // Each author should have called backend once - if ap.calls != 2 { - t.Fatalf("expected 2 backend calls for different authors, got %d", ap.calls) + if params.Amount != "1000" { + t.Fatalf("Amount not preserved: %s", params.Amount) + } + if params.Asset != "USDC" { + t.Fatalf("Asset not preserved: %s", params.Asset) + } + if params.Network != "eip155:1" { + t.Fatalf("Network not preserved: %s", params.Network) + } + if params.Recipient != "0xrecipient" { + t.Fatalf("Recipient not preserved: %s", params.Recipient) + } + if params.Sender != "0xsender" { + t.Fatalf("Sender not preserved: %s", params.Sender) } } - -func TestCacheVersionStaleness(t *testing.T) { - st := newFakeStore() - // Add a stale profile (older than 7 days) - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - TopKeywords: []string{"old"}, - StyleSummary: "stale summary", - PostCount: 5, - ModifiedAt: sql.NullTime{Time: time.Now().Add(-10 * 24 * time.Hour), Valid: true}, - } - ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fresh post", Text: "fresh post", CreatedAt: time.Now()}}} - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) - if err != nil { - t.Fatalf("getStyleProfile error: %v", err) - } - var out GetStyleProfileResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - // Should have rebuilt with fresh data - if out.StyleSummary == "stale summary" { - t.Fatal("expected rebuild to replace stale summary") - } - if out.PostCount != 1 { - t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount) - } -} - -// ---- edge cases ---- diff --git a/internal/server/metricsMiddleware.go b/internal/server/metricsMiddleware.go index 4960b37..1abf7ec 100644 --- a/internal/server/metricsMiddleware.go +++ b/internal/server/metricsMiddleware.go @@ -16,9 +16,9 @@ import ( // meanStat is a cumulative mean over a mutex; used for per-tool latency (ms). type meanStat struct { - mu sync.Mutex - sum float64 - n int64 + mu sync.Mutex + sum float64 + n int64 } func (m *meanStat) Record(v float64) { @@ -44,21 +44,21 @@ type toolStat struct { } type MetricsMiddleware struct { - mu sync.Mutex - requestsBucket utils.RollingStats - totalRequests utils.Counter - totalErrors utils.Counter - enrichEvents utils.Counter - toolStats map[string]*toolStat + mu sync.Mutex + requestsBucket utils.RollingStats + totalRequests utils.Counter + totalErrors utils.Counter + enrichEvents utils.Counter + toolStats map[string]*toolStat } func NewMetricsHook() *MetricsMiddleware { return &MetricsMiddleware{ requestsBucket: *utils.NewRollingStats(60, 1*time.Second), totalRequests: *utils.NewCounter(), - totalErrors: *utils.NewCounter(), - enrichEvents: *utils.NewCounter(), - toolStats: make(map[string]*toolStat), + totalErrors: *utils.NewCounter(), + enrichEvents: *utils.NewCounter(), + toolStats: make(map[string]*toolStat), } } diff --git a/internal/server/searchactors_test.go b/internal/server/searchactors_test.go deleted file mode 100644 index dcab2f3..0000000 --- a/internal/server/searchactors_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package server - -import ( - "testing" - - "github.com/nacorid/naco-api/internal/actor" -) - -func TestNormalizeHandle(t *testing.T) { - cases := []struct { - in string - want string - }{ - {"", ""}, - {" ", ""}, - {"@alice", "alice"}, - {"@", ""}, - {"alice@mastodon.social", "alice"}, - {"Alice@Mastodon.Social", "alice"}, - {"@Alice@mastodon.social", "alice"}, - {"alice.bsky.social", "alice.bsky.social"}, - {" @bob.X ", "bob.x"}, - } - for _, c := range cases { - if got := normalizeHandle(c.in); got != c.want { - t.Errorf("normalizeHandle(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -func TestDedupeActors(t *testing.T) { - in := []ActorProfile{ - {Handle: "@alice", DisplayName: "Alice"}, - {Handle: "alice@mastodon.social", DisplayName: "Alice"}, - {Handle: "", DisplayName: ""}, - {Handle: "bob", DisplayName: "Bob"}, - } - // @alice and alice@mastodon.social dedupe by handle; the empty-handle AND - // empty-display-name is appended unconditionally; bob is another person. - out := dedupeActors(in) - if len(out) != 3 { - t.Fatalf("expected 3 deduped results, got %d: %+v", len(out), out) - } -} - -func TestDedupeActorsByDisplayNameOnly(t *testing.T) { - // When handles are empty but display names collide, dedupe by display name. - in := []ActorProfile{ - {Handle: "", DisplayName: "Shared"}, - {Handle: "", DisplayName: "Shared"}, - {Handle: "", DisplayName: "Other"}, - } - out := dedupeActors(in) - if len(out) != 2 { - t.Fatalf("expected 2 results after display-name dedup, got %d: %+v", len(out), out) - } -} - -func TestSearchActorsTwitterExcluded(t *testing.T) { - // searchActorsAll only queries Bluesky, ActivityPub and Nostr. A Twitter - // result must never appear because the backend is not consulted. - tw := &fakeTwitter{actors: []actor.Profile{{Platform: "Twitter", Handle: "t"}}, calls: 0} - bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "a"}}} - ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} - nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "c"}}} - s := newTestServer(bsky, ap, nstr, tw) - - res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "q"}) - if err != nil { - t.Fatalf("searchActors error: %v", err) - } - var out SearchActorsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - for _, r := range out.Results { - if r.Platform == "Twitter" { - t.Fatalf("unexpected Twitter result: %+v", r) - } - } - if tw.calls != 0 { - t.Fatalf("expected Twitter backend untouched, got %d calls", tw.calls) - } -} diff --git a/internal/server/server.go b/internal/server/server.go index f2d1011..730ef24 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,7 +34,7 @@ type Server struct { listenAddress string permissions os.FileMode mcpHandler *x402.X402Server - db Store + db *store.Queries apClient apBackend bskyClient blueskyBackend nostrClient nostrBackend @@ -367,11 +367,7 @@ func (s *Server) registerTools() { OutputSchema: GetStyleProfileResponse{}, }, s.getStyleProfile, - // Base registration is the maximum possible charge ($0.36): $0.10 base - // cost + $0.01 user lookup + $0.005 x 50 posts for a Twitter profile. - // The settlement override in the x402 handler charges the actual - // per-usage amount (Twitter passes API costs on to the caller). - s.buildRequirements("0.36", extra)..., + s.buildRequirements("0.10", extra)..., ) extra = map[string]any{ @@ -742,17 +738,14 @@ func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolReques if args.Limit <= 0 { return nil, fmt.Errorf("n must be a positive integer") } - if args.Limit > 50 { - return nil, fmt.Errorf("n must be at most 50") - } - limit := args.Limit + limit := min(args.Limit, 50) startTime, err := time.Parse(time.RFC3339, args.StartTime) if err != nil { return nil, fmt.Errorf("startTime must be in RFC3339 format") } endTime, err := time.Parse(time.RFC3339, args.EndTime) if err != nil { - return nil, fmt.Errorf("endTime must be in RFC3339 format") + return nil, fmt.Errorf("startTime must be in RFC3339 format") } if startTime.After(endTime) { tmp := startTime diff --git a/internal/server/storefake_test.go b/internal/server/storefake_test.go deleted file mode 100644 index 066ca5e..0000000 --- a/internal/server/storefake_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package server - -import ( - "context" - "database/sql" - "encoding/json" - "sort" - "time" - - "github.com/nacorid/naco-api/internal/store" -) - -// fakeStore is an in-memory implementation of the Store interface used by handler -// tests so db-backed tool paths (style profiles, analytics, similar users) can be -// exercised without a live Postgres+pgvector. -type fakeStore struct { - analyticsCache map[cacheKey]cacheEntry - analyticsPosts map[string]store.AnalyticsPost - coverageRanges []store.AnalyticsCoverageRange - styleProfiles map[string]store.UserStyleProfile - stylePosts map[string]store.UserStylePost - similarRows []store.FindSimilarUsersRow - findSimilarErr error - styleProfileErr error - stylePostsErr error - cacheErr bool - postsRangeErr bool - coverageErr bool -} - -type cacheKey struct { - user, platform, tool, hash string -} - -type cacheEntry struct { - value json.RawMessage - exp time.Time -} - -func newFakeStore() *fakeStore { - return &fakeStore{ - analyticsCache: map[cacheKey]cacheEntry{}, - analyticsPosts: map[string]store.AnalyticsPost{}, - styleProfiles: map[string]store.UserStyleProfile{}, - stylePosts: map[string]store.UserStylePost{}, - } -} - -func postKey(user, platform, id string) string { - return user + "|" + platform + "|" + id -} - -func profileKey(user, platform string) string { - return user + "|" + platform -} - -func (f *fakeStore) GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) { - if f.cacheErr { - return nil, sql.ErrNoRows - } - key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash} - entry, ok := f.analyticsCache[key] - if !ok || time.Now().After(entry.exp) { - return nil, sql.ErrNoRows - } - return entry.value, nil -} - -func (f *fakeStore) UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error { - key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash} - f.analyticsCache[key] = cacheEntry{value: arg.Result, exp: arg.ExpiresAt} - return nil -} - -func (f *fakeStore) GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) { - if f.postsRangeErr { - return nil, sql.ErrConnDone - } - var rows []store.GetAnalyticsPostsInRangeRow - for _, p := range f.analyticsPosts { - if p.UserID != arg.UserID || p.Platform != arg.Platform { - continue - } - if p.PostTimestamp.Before(arg.PostTimestamp) || p.PostTimestamp.After(arg.PostTimestamp_2) { - continue - } - rows = append(rows, store.GetAnalyticsPostsInRangeRow{ - PostID: p.PostID, - PostText: p.PostText, - PostTimestamp: p.PostTimestamp, - Likes: p.Likes, - Reposts: p.Reposts, - Replies: p.Replies, - Quotes: p.Quotes, - }) - } - sort.Slice(rows, func(i, j int) bool { return rows[i].PostTimestamp.After(rows[j].PostTimestamp) }) - if int32(len(rows)) > arg.Limit { - rows = rows[:arg.Limit] - } - return rows, nil -} - -func (f *fakeStore) UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error { - f.analyticsPosts[postKey(arg.UserID, arg.Platform, arg.PostID)] = store.AnalyticsPost{ - UserID: arg.UserID, - Platform: arg.Platform, - PostID: arg.PostID, - PostText: arg.PostText, - PostTimestamp: arg.PostTimestamp, - Likes: arg.Likes, - Reposts: arg.Reposts, - Replies: arg.Replies, - Quotes: arg.Quotes, - } - return nil -} - -func (f *fakeStore) GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error) { - if f.coverageErr { - return nil, sql.ErrConnDone - } - var rows []store.GetAnalyticsCoverageRangesRow - for _, r := range f.coverageRanges { - if r.UserID != arg.UserID || r.Platform != arg.Platform { - continue - } - rows = append(rows, store.GetAnalyticsCoverageRangesRow{CoveredStart: r.CoveredStart, CoveredEnd: r.CoveredEnd}) - } - return rows, nil -} - -func (f *fakeStore) InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error { - f.coverageRanges = append(f.coverageRanges, store.AnalyticsCoverageRange{ - UserID: arg.UserID, - Platform: arg.Platform, - CoveredStart: arg.CoveredStart, - CoveredEnd: arg.CoveredEnd, - }) - return nil -} - -func (f *fakeStore) GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error) { - if f.styleProfileErr != nil { - return store.UserStyleProfile{}, f.styleProfileErr - } - p, ok := f.styleProfiles[profileKey(arg.UserID, arg.Platform)] - if !ok { - return store.UserStyleProfile{}, sql.ErrNoRows - } - return p, nil -} - -func (f *fakeStore) UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error { - f.styleProfiles[profileKey(arg.UserID, arg.Platform)] = store.UserStyleProfile{ - UserID: arg.UserID, - Platform: arg.Platform, - Embedding: arg.Embedding, - TopKeywords: arg.TopKeywords, - StyleSummary: arg.StyleSummary, - PostCount: arg.PostCount, - ModifiedAt: sql.NullTime{Time: time.Now(), Valid: true}, - } - return nil -} - -func (f *fakeStore) DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error { - for _, k := range f.stylePosts { - if k.UserID == arg.UserID && k.Platform == arg.Platform { - delete(f.stylePosts, postKey(k.UserID, k.Platform, k.PostID)) - } - } - return nil -} - -func (f *fakeStore) GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error) { - if f.stylePostsErr != nil { - return nil, f.stylePostsErr - } - var rows []store.GetStylePostsRow - for _, p := range f.stylePosts { - if p.UserID != arg.UserID || p.Platform != arg.Platform { - continue - } - rows = append(rows, store.GetStylePostsRow{PostID: p.PostID, PostText: p.PostText}) - } - sort.Slice(rows, func(i, j int) bool { return rows[i].PostID > rows[j].PostID }) - if int32(len(rows)) > arg.Limit { - rows = rows[:arg.Limit] - } - return rows, nil -} - -func (f *fakeStore) UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error { - f.stylePosts[postKey(arg.UserID, arg.Platform, arg.PostID)] = store.UserStylePost{ - UserID: arg.UserID, - Platform: arg.Platform, - PostID: arg.PostID, - PostText: arg.PostText, - CreatedAt: time.Now(), - } - return nil -} - -func (f *fakeStore) FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error) { - if f.findSimilarErr != nil { - return nil, f.findSimilarErr - } - return f.similarRows, nil -} - -func (f *fakeStore) PruneStylePosts(ctx context.Context) error { - return nil -} diff --git a/internal/server/styleprofile_test.go b/internal/server/styleprofile_test.go deleted file mode 100644 index ffc8079..0000000 --- a/internal/server/styleprofile_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package server - -import ( - "database/sql" - "fmt" - "strings" - "testing" - "time" - - "github.com/bluesky-social/indigo/api/bsky" - "github.com/nacorid/naco-api/internal/activitypub" - "github.com/nacorid/naco-api/internal/store" -) - -// ---- getStyleProfile ---- - -func TestGetStyleProfileBuildsFresh(t *testing.T) { - ap := &fakeAP{posts: []*activitypub.Post{ - {Author: "@a@mastodon.social", Content: "alpha post one", Text: "alpha post one", CreatedAt: time.Now()}, - {Author: "@a@mastodon.social", Content: "alpha post two", Text: "alpha post two", CreatedAt: time.Now()}, - }} - st := newFakeStore() - oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}, {0.3, 0.4}}} - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll) - - res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) - if err != nil { - t.Fatalf("getStyleProfile error: %v", err) - } - var out GetStyleProfileResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Author != "@a@mastodon.social" { - t.Fatalf("expected author, got %q", out.Author) - } - if out.PostCount != 2 { - t.Fatalf("expected post count 2, got %d", out.PostCount) - } - if out.SourcePlatform != "ActivityPub" { - t.Fatalf("expected ActivityPub source, got %q", out.SourcePlatform) - } - if oll.embedCalls != 1 || oll.summCalls != 1 { - t.Fatalf("expected 1 embed and 1 summarize call, got %d/%d", oll.embedCalls, oll.summCalls) - } -} - -func TestGetStyleProfileServesFreshCache(t *testing.T) { - // A profile modified less than a week ago is served without hitting the - // backend or ollama. - st := newFakeStore() - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - TopKeywords: []string{"alpha"}, - StyleSummary: "cached summary", - PostCount: 5, - ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, - } - ap := &fakeAP{} - oll := &fakeOllama{} - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll) - - res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) - if err != nil { - t.Fatalf("getStyleProfile error: %v", err) - } - var out GetStyleProfileResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.StyleSummary != "cached summary" { - t.Fatalf("expected cached summary, got %q", out.StyleSummary) - } - if ap.calls != 0 { - t.Fatalf("expected no backend call on cache hit, got %d", ap.calls) - } - if oll.summCalls != 0 { - t.Fatalf("expected no ollama call on cache hit, got %d", oll.summCalls) - } -} - -func TestGetStyleProfileRebuildsStale(t *testing.T) { - // A stale (older than a week) cached profile triggers a rebuild. - st := newFakeStore() - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - TopKeywords: []string{"old"}, - StyleSummary: "stale summary", - PostCount: 5, - ModifiedAt: sql.NullTime{Time: time.Now().Add(-10 * 24 * time.Hour), Valid: true}, - } - ap := &fakeAP{posts: []*activitypub.Post{ - {Author: "@a@mastodon.social", Content: "fresh post", Text: "fresh post", CreatedAt: time.Now()}, - }} - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - - res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) - if err != nil { - t.Fatalf("getStyleProfile error: %v", err) - } - var out GetStyleProfileResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.StyleSummary == "stale summary" { - t.Fatal("expected rebuild to replace stale summary") - } - if out.PostCount != 1 { - t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount) - } -} - -func TestGetStyleProfileDBErrorIsNotNoRows(t *testing.T) { - st := newFakeStore() - st.styleProfileErr = fmt.Errorf("db down") - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "db down") { - t.Fatalf("expected db error propagation, got %v", err) - } -} - -func TestGetStyleProfileNoPostsFound(t *testing.T) { - // Empty backend result while building leads to a "no posts found" error. - ap := &fakeAP{} // no posts - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) - if err == nil || !strings.Contains(err.Error(), "no posts found") { - t.Fatalf("expected no-posts error, got %v", err) - } -} - -func TestGetStyleProfileOllamaEmbedFailure(t *testing.T) { - ap := &fakeAP{posts: []*activitypub.Post{{ID: "p1", Author: "@a@mastodon.social", Content: "post", Text: "post", CreatedAt: time.Now()}}} - oll := &fakeOllama{embedErr: fmt.Errorf("embedding failed")} - st := newFakeStore() - s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll) - if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "embedding") { - t.Fatalf("expected embed failure, got %v", err) - } -} - -func TestGetStyleProfileBlueskyResolution(t *testing.T) { - // Bluesky resolves the did before building/persisting. - bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bsky post")}}} - oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}}} - st := newFakeStore() - s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll) - res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"}) - if err != nil { - t.Fatalf("getStyleProfile error: %v", err) - } - var out GetStyleProfileResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.Author != "did:plc:abc" { - t.Fatalf("expected resolved did, got %q", out.Author) - } - if out.SourcePlatform != "Bluesky" { - t.Fatalf("expected Bluesky source, got %q", out.SourcePlatform) - } -} - -// ---- findSimilarUsers ---- - -func TestFindSimilarUsers(t *testing.T) { - st := newFakeStore() - st.similarRows = []store.FindSimilarUsersRow{ - {UserID: "did:plc:other", Platform: "Bluesky", TopKeywords: []string{"x"}, StyleSummary: "s", PostCount: 3, Similarity: 90}, - } - // Serve a fresh cached profile so requestStyleProfile doesn't need posts. - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, - } - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10}) - if err != nil { - t.Fatalf("findSimilarUsers error: %v", err) - } - var out FindSimilarUsersResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Matches) != 1 { - t.Fatalf("expected 1 match, got %d", len(out.Matches)) - } - if out.Matches[0].Author != "did:plc:other" { - t.Fatalf("unexpected match: %+v", out.Matches[0]) - } -} - -func TestFindSimilarUsersExcludesSelf(t *testing.T) { - st := newFakeStore() - // Both rows share the query author's user ID; the self row must be excluded. - st.similarRows = []store.FindSimilarUsersRow{ - {UserID: "@a@mastodon.social", Platform: "ActivityPub"}, // same as query author - {UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80}, - } - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, - } - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10}) - if err != nil { - t.Fatalf("findSimilarUsers error: %v", err) - } - var out FindSimilarUsersResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Matches) != 1 { - t.Fatalf("expected self to be excluded, got %d matches: %+v", len(out.Matches), out.Matches) - } - if out.Matches[0].Author != "did:plc:other" { - t.Fatalf("expected only the non-self match, got %+v", out.Matches) - } -} - -func TestFindSimilarUsersEmptyMatches(t *testing.T) { - st := newFakeStore() - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, - } - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}) - if err != nil { - t.Fatalf("findSimilarUsers error: %v", err) - } - var out FindSimilarUsersResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Matches) != 0 { - t.Fatalf("expected empty matches, got %d", len(out.Matches)) - } -} - -func TestFindSimilarUsersDBError(t *testing.T) { - st := newFakeStore() - st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ - UserID: "@a@mastodon.social", - Platform: "ActivityPub", - Embedding: "[0.1,0.2]", - ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, - } - st.findSimilarErr = fmt.Errorf("query failed") - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - if _, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "similar") { - t.Fatalf("expected similar-users error, got %v", err) - } -} diff --git a/internal/server/twitter_analytics_test.go b/internal/server/twitter_analytics_test.go deleted file mode 100644 index 2cc9cf8..0000000 --- a/internal/server/twitter_analytics_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package server - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/nacorid/naco-api/internal/clustering" - "github.com/nacorid/naco-api/internal/store" - "github.com/nacorid/naco-api/internal/twitter" -) - -func twitterPosts() []*twitter.Post { - base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) - return []*twitter.Post{ - {ID: "t1", Author: "@a", Content: "twitter one", CreatedAt: base}, - {ID: "t2", Author: "@a", Content: "twitter two", CreatedAt: base.Add(time.Hour)}, - } -} - -func TestFetchTwitterAnalyticsPostsFetchesAndPersists(t *testing.T) { - tw := &fakeTwitter{posts: twitterPosts()} - st := newFakeStore() - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) - - start := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2024, 5, 31, 0, 0, 0, 0, time.UTC) - items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50) - if err != nil { - t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err) - } - if len(items) != 2 { - t.Fatalf("expected 2 fetched posts, got %d", len(items)) - } - if tw.calls != 1 { - t.Fatalf("expected 1 API call, got %d", tw.calls) - } - - // Newly fetched posts are persisted for future reuse. - if len(st.analyticsPosts) != 2 { - t.Fatalf("expected 2 persisted analytics posts, got %d", len(st.analyticsPosts)) - } - // Coverage range for the whole range recorded. - if len(st.coverageRanges) != 1 { - t.Fatalf("expected 1 coverage range, got %d", len(st.coverageRanges)) - } -} - -func TestFetchTwitterAnalyticsPostsServedFromCoverage(t *testing.T) { - // Posts already persisted and coverage present => no paid API call. - st := newFakeStore() - base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) - st.coverageRanges = []store.AnalyticsCoverageRange{ - {UserID: "@a", Platform: "Twitter", CoveredStart: base, CoveredEnd: base.Add(2 * time.Hour)}, - } - st.analyticsPosts[postKey("@a", "Twitter", "t1")] = store.AnalyticsPost{ - UserID: "@a", Platform: "Twitter", PostID: "t1", PostText: "cached post", PostTimestamp: base, - } - tw := &fakeTwitter{} - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) - - start := base - end := base.Add(2 * time.Hour) - items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50) - if err != nil { - t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 cached post, got %d", len(items)) - } - if tw.calls != 0 { - t.Fatalf("expected no API call when covered, got %d", tw.calls) - } - if items[0].Text != "cached post" { - t.Fatalf("expected cached text, got %q", items[0].Text) - } -} - -func TestMergeAnalyticsPostsDedupsAndCaps(t *testing.T) { - base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) - st := newFakeStore() - // Cached posts overlap with fetched ones by ID, and both exceed the limit. - st.analyticsPosts[postKey("@a", "Twitter", "dup")] = store.AnalyticsPost{ - UserID: "@a", Platform: "Twitter", PostID: "dup", PostText: "dup cached", PostTimestamp: base, - } - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - - fetched := []clustering.Item{ - {ID: "dup", Text: "dup fetched", Timestamp: base}, - {ID: "b", Text: "b", Timestamp: base}, - {ID: "c", Text: "c", Timestamp: base}, - } - merged, err := s.mergeAnalyticsPosts(context.Background(), "@a", base, base.Add(time.Hour), 2, fetched) - if err != nil { - t.Fatalf("mergeAnalyticsPosts error: %v", err) - } - if len(merged) != 2 { - t.Fatalf("expected merged capped at 2, got %d", len(merged)) - } - // Fetched entries come first and dedupe against the cache. - if merged[0].ID != "dup" || merged[0].Text != "dup fetched" { - t.Fatalf("expected fetched dup first, got %+v", merged[0]) - } -} - -func TestPersistAnalyticsPostsSkipsEmptyIDs(t *testing.T) { - st := newFakeStore() - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) - err := s.persistAnalyticsPosts(context.Background(), "@a", "Twitter", []clustering.Item{ - {ID: "", Text: "no id"}, - {ID: "real", Text: "real", Timestamp: time.Now()}, - }) - if err != nil { - t.Fatalf("persistAnalyticsPosts error: %v", err) - } - if len(st.analyticsPosts) != 1 { - t.Fatalf("expected only the post with an ID persisted, got %d", len(st.analyticsPosts)) - } - if _, ok := st.analyticsPosts[postKey("@a", "Twitter", "real")]; !ok { - t.Fatal("expected the 'real' post to be persisted") - } -} - -func TestAnalyticsTwitterEndToEnd(t *testing.T) { - // getEngagementMetrics over the Twitter platform drives the coverage logic. - tw := &fakeTwitter{posts: twitterPosts()} - st := newFakeStore() - s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) - - res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ - Author: "@a", - StartTime: "2024-05-01T00:00:00Z", - EndTime: "2024-05-31T00:00:00Z", - Platform: "Twitter", - }) - if err != nil { - t.Fatalf("getEngagementMetrics twitter error: %v", err) - } - if !strings.Contains(textOf(t, res), `"postId":"t1"`) { - t.Fatalf("expected twitter post t1 in result, got %s", textOf(t, res)) - } - if !strings.Contains(textOf(t, res), `"postId":"t2"`) { - t.Fatalf("expected twitter post t2 in result, got %s", textOf(t, res)) - } - if tw.calls != 1 { - t.Fatalf("expected 1 API call for the uncovered range, got %d", tw.calls) - } - - // Second identical request is served from the analytics cache (no API call). - if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ - Author: "@a", - StartTime: "2024-05-01T00:00:00Z", - EndTime: "2024-05-31T00:00:00Z", - Platform: "Twitter", - }); err != nil { - t.Fatalf("second call error: %v", err) - } - if tw.calls != 1 { - t.Fatalf("expected cache to avoid a second API call, got %d", tw.calls) - } -} diff --git a/internal/server/types.go b/internal/server/types.go index b1915b7..d07cd13 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -109,12 +109,12 @@ type SearchActorsResponse struct { } type SimilarUser struct { - Author string `json:"author" jsonschema_description:"Did or handle of the similar author"` - Platform string `json:"platform" jsonschema_description:"Platform the similar author was found on"` - Similarity float64 `json:"similarity" jsonschema_description:"Cosine similarity of the persona embeddings, range 0..1"` - TopKeywords []string `json:"topKeywords,omitempty" jsonschema_description:"Top keywords used by the similar author"` - StyleSummary string `json:"styleSummary,omitempty" jsonschema_description:"Summary of the similar author's writing style"` - PostCount int `json:"postCount,omitempty" jsonschema_description:"Number of posts analyzed for the similar author"` + Author string `json:"author" jsonschema_description:"Did or handle of the similar author"` + Platform string `json:"platform" jsonschema_description:"Platform the similar author was found on"` + Similarity float64 `json:"similarity" jsonschema_description:"Cosine similarity of the persona embeddings, range 0..1"` + TopKeywords []string `json:"topKeywords,omitempty" jsonschema_description:"Top keywords used by the similar author"` + StyleSummary string `json:"styleSummary,omitempty" jsonschema_description:"Summary of the similar author's writing style"` + PostCount int `json:"postCount,omitempty" jsonschema_description:"Number of posts analyzed for the similar author"` } type DCAPMessage struct { @@ -163,8 +163,8 @@ type AnalyticsRequest struct { } type EngagementMetricsResponse struct { - Author string `json:"author" jsonschema_description:"Author identifier"` - Metrics []EngagementMetricPoint `json:"metrics" jsonschema_description:"Engagement metrics per post"` + Author string `json:"author" jsonschema_description:"Author identifier"` + Metrics []EngagementMetricPoint `json:"metrics" jsonschema_description:"Engagement metrics per post"` } type EngagementMetricPoint struct { @@ -178,18 +178,18 @@ type EngagementMetricPoint struct { } type PostingPatternsResponse struct { - Author string `json:"author" jsonschema_description:"Author identifier"` - Heatmap [][]int `json:"heatmap" jsonschema_description:"24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"` - PeakHours []int `json:"peakHours" jsonschema_description:"Top 3 hours with most posts (0-23)"` - PeakDays []int `json:"peakDays" jsonschema_description:"Top 3 days with most posts (0-6 = Sun-Sat)"` - AvgPostsPerDay float64 `json:"avgPostsPerDay" jsonschema_description:"Average posts per day"` - TotalPosts int `json:"totalPosts" jsonschema_description:"Total posts in time range"` - PostingDays int `json:"postingDays" jsonschema_description:"Number of days with at least one post"` + Author string `json:"author" jsonschema_description:"Author identifier"` + Heatmap [][]int `json:"heatmap" jsonschema_description:"24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"` + PeakHours []int `json:"peakHours" jsonschema_description:"Top 3 hours with most posts (0-23)"` + PeakDays []int `json:"peakDays" jsonschema_description:"Top 3 days with most posts (0-6 = Sun-Sat)"` + AvgPostsPerDay float64 `json:"avgPostsPerDay" jsonschema_description:"Average posts per day"` + TotalPosts int `json:"totalPosts" jsonschema_description:"Total posts in time range"` + PostingDays int `json:"postingDays" jsonschema_description:"Number of days with at least one post"` } type TopicModelingResponse struct { - Author string `json:"author" jsonschema_description:"Author identifier"` - Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"` + Author string `json:"author" jsonschema_description:"Author identifier"` + Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"` } type TopicCluster struct { @@ -202,19 +202,19 @@ type TopicCluster struct { } type SentimentTrendsResponse struct { - Author string `json:"author" jsonschema_description:"Author identifier"` - TimeSeries []SentimentPoint `json:"timeSeries" jsonschema_description:"Sentiment over time"` - OverallSentiment SentimentSummary `json:"overallSentiment" jsonschema_description:"Overall sentiment summary"` - DominantEmotions []EmotionScore `json:"dominantEmotions" jsonschema_description:"Most frequent emotions"` - SentimentShifts []SentimentShift `json:"sentimentShifts" jsonschema_description:"Significant sentiment changes"` + Author string `json:"author" jsonschema_description:"Author identifier"` + TimeSeries []SentimentPoint `json:"timeSeries" jsonschema_description:"Sentiment over time"` + OverallSentiment SentimentSummary `json:"overallSentiment" jsonschema_description:"Overall sentiment summary"` + DominantEmotions []EmotionScore `json:"dominantEmotions" jsonschema_description:"Most frequent emotions"` + SentimentShifts []SentimentShift `json:"sentimentShifts" jsonschema_description:"Significant sentiment changes"` } type SentimentPoint struct { - Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp (bucketed by day)"` - Positive float64 `json:"positive" jsonschema_description:"Positive sentiment ratio (0-1)"` - Negative float64 `json:"negative" jsonschema_description:"Negative sentiment ratio (0-1)"` - Neutral float64 `json:"neutral" jsonschema_description:"Neutral sentiment ratio (0-1)"` - PostCount int `json:"postCount" jsonschema_description:"Posts in this time bucket"` + Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp (bucketed by day)"` + Positive float64 `json:"positive" jsonschema_description:"Positive sentiment ratio (0-1)"` + Negative float64 `json:"negative" jsonschema_description:"Negative sentiment ratio (0-1)"` + Neutral float64 `json:"neutral" jsonschema_description:"Neutral sentiment ratio (0-1)"` + PostCount int `json:"postCount" jsonschema_description:"Posts in this time bucket"` } type SentimentSummary struct { @@ -229,21 +229,21 @@ type EmotionScore struct { } type SentimentShift struct { - Timestamp string `json:"timestamp" jsonschema_description:"When the shift occurred"` - FromSentiment string `json:"fromSentiment" jsonschema_description:"Previous dominant sentiment"` + Timestamp string `json:"timestamp" jsonschema_description:"When the shift occurred"` + FromSentiment string `json:"fromSentiment" jsonschema_description:"Previous dominant sentiment"` ToSentiment string `json:"toSentiment" jsonschema_description:"New dominant sentiment"` Magnitude float64 `json:"magnitude" jsonschema_description:"Shift magnitude (0-1)"` } type EngagementDriversResponse struct { - Author string `json:"author" jsonschema_description:"Author identifier"` - Drivers []EngagementDriver `json:"drivers" jsonschema_description:"Topics/keywords driving engagement"` + Author string `json:"author" jsonschema_description:"Author identifier"` + Drivers []EngagementDriver `json:"drivers" jsonschema_description:"Topics/keywords driving engagement"` } type EngagementDriver struct { - Topic string `json:"topic" jsonschema_description:"Topic or keyword"` - AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"` - Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"` - PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"` - SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"` + Topic string `json:"topic" jsonschema_description:"Topic or keyword"` + AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"` + Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"` + PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"` + SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"` } diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go deleted file mode 100644 index f33aef0..0000000 --- a/internal/server/validation_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - "time" - - "github.com/bluesky-social/indigo/api/bsky" - "github.com/nacorid/naco-api/internal/activitypub" - "github.com/nacorid/naco-api/internal/nostr" -) - -// ---- validation matrix ---- - -func TestGetPostValidation(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - - if _, err := callTool(s, "getPost", GetPostRequest{Author: "", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "author") { - t.Fatalf("expected author error, got %v", err) - } - if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: ""}); err == nil || !strings.Contains(err.Error(), "rkey") { - t.Fatalf("expected rkey error, got %v", err) - } - if _, err := callTool(s, "getPost", "not-an-object"); err == nil { - t.Fatal("expected invalid arguments error") - } - // Unparseable handle -> determinePlatform error - if _, err := callTool(s, "getPost", GetPostRequest{Author: "justhandle", RecordKey: "k"}); err == nil { - t.Fatal("expected determinePlatform error") - } -} - -func TestGetLatestPostsValidation(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - - if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") { - t.Fatalf("expected author error, got %v", err) - } - if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") { - t.Fatalf("expected limit error, got %v", err) - } - if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: -3}); err == nil { - t.Fatal("expected negative limit error") - } - if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") { - t.Fatalf("expected max-limit error, got %v", err) - } -} - -func TestGetPostsInTimeValidation(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - - if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") { - t.Fatalf("expected author error, got %v", err) - } - if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") { - t.Fatalf("expected limit error, got %v", err) - } - // getPostsInTime now rejects over-max limit for consistency with getLatestPosts. - if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") { - t.Fatalf("expected max-limit error, got %v", err) - } - // Bad start time. - if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "not-a-time", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "startTime") { - t.Fatalf("expected startTime error, got %v", err) - } - // Bad end time reports the correct field name. - if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "not-a-time", Limit: 5}); err == nil || !strings.Contains(err.Error(), "endTime") { - t.Fatalf("expected endTime error, got %v", err) - } -} - -func TestGetPostsInTimeSwapsReversedRange(t *testing.T) { - // Nostr fake returns posts regardless of args; the point is that a reversed - // range is accepted (swapped) rather than rejected. - nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npub1x", Content: "hi", CreatedAt: time.Now()}}} - s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) - res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{ - Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - StartTime: "2024-12-31T00:00:00Z", - EndTime: "2024-01-01T00:00:00Z", - Limit: 5, - }) - if err != nil { - t.Fatalf("expected reversed range to be swapped and succeed, got %v", err) - } - if res == nil || len(res.Content) == 0 { - t.Fatal("expected a result") - } -} - -func TestGetFeedValidation(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - if _, err := callTool(s, "getFeed", GetFeedRequest{URI: ""}); err == nil || !strings.Contains(err.Error(), "URI") { - t.Fatalf("expected URI error, got %v", err) - } -} - -func TestGetActorValidation(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - if _, err := callTool(s, "getActor", GetActorRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") { - t.Fatalf("expected author error, got %v", err) - } - // Bare handle is unparseable -> determinePlatform error. - if _, err := callTool(s, "getActor", GetActorRequest{Author: "justhandle"}); err == nil { - t.Fatal("expected determinePlatform error") - } -} - -func TestSearchActorsValidation(t *testing.T) { - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: ""}); err == nil || !strings.Contains(err.Error(), "query") { - t.Fatalf("expected query error, got %v", err) - } -} - -// ---- twitter policy blocks ---- - -func TestTwitterPolicyBlocksAllTools(t *testing.T) { - cases := []struct { - tool string - args any - }{ - {"getPost", GetPostRequest{Author: "@alice", RecordKey: "k"}}, - {"getLatestPosts", GetLatestPostsRequest{Author: "@alice", Limit: 5}}, - {"getPostsInTime", GetPostsInTimeRequest{Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}}, - {"getFeed", GetFeedRequest{URI: "@alice"}}, - {"getActor", GetActorRequest{Author: "@alice"}}, - } - for _, c := range cases { - tw := &fakeTwitter{} - s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw) - _, err := callTool(s, c.tool, c.args) - if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") { - t.Fatalf("%s: expected twitter_unavailable, got %v", c.tool, err) - } - if tw.calls != 0 { - t.Fatalf("%s: backend should not be called, got %d calls", c.tool, tw.calls) - } - } -} - -// ---- backend error propagation ---- - -func TestBackendErrorPropagation(t *testing.T) { - bsky := &fakeBluesky{err: fmt.Errorf("boom")} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "boom") { - t.Fatalf("expected backend error, got %v", err) - } -} - -func TestGetActorBackendError(t *testing.T) { - ap := &fakeAP{err: fmt.Errorf("boom")} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - if _, err := callTool(s, "getActor", GetActorRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "boom") { - t.Fatalf("expected backend error, got %v", err) - } -} - -// ---- getPost success paths ---- - -func TestGetPostBlueskyAndNostr(t *testing.T) { - bsky := &fakeBluesky{postView: bskyPostView("hello bluesky")} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}) - if err != nil { - t.Fatalf("getPost bluesky error: %v", err) - } - if !strings.Contains(textOf(t, res), "hello bluesky") { - t.Fatalf("unexpected bluesky post text: %s", textOf(t, res)) - } - if bsky.calls != 1 { - t.Fatalf("expected 1 backend call, got %d", bsky.calls) - } - - nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}} - s2 := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) - res2, err := callTool(s2, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"}) - if err != nil { - t.Fatalf("getPost nostr error: %v", err) - } - if !strings.Contains(textOf(t, res2), "nostr hello") { - t.Fatalf("unexpected nostr post text: %s", textOf(t, res2)) - } -} - -func TestGetLatestPostsEmptyResult(t *testing.T) { - ap := &fakeAP{} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5}) - if err != nil { - t.Fatalf("getLatestPosts error: %v", err) - } - var out GetLatestPostsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 0 { - t.Fatalf("expected 0 posts, got %d", len(out.Posts)) - } - if out.Author != "@a@mastodon.social" { - t.Fatalf("expected author fallback to request author, got %q", out.Author) - } -} - -func TestGetLatestPostsBlueskyNilPostSkipped(t *testing.T) { - bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ - {Post: bskyPostView("kept")}, - {Post: nil}, - }} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5}) - if err != nil { - t.Fatalf("getLatestPosts error: %v", err) - } - var out GetLatestPostsResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 1 { - t.Fatalf("expected 1 post after skipping nil, got %d", len(out.Posts)) - } -} - -func TestGetFeedNonBlueskyFallback(t *testing.T) { - // A non-Bluesky feed URI is treated as an author handle and falls back to - // fetchLatestPosts (evaluated via the ActivityPub backend here). - ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fallback post", CreatedAt: time.Now()}}} - s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getFeed", GetFeedRequest{URI: "@a@mastodon.social"}) - if err != nil { - t.Fatalf("getFeed fallback error: %v", err) - } - if !strings.Contains(textOf(t, res), "fallback post") { - t.Fatalf("expected fallback post, got %s", textOf(t, res)) - } -} - -func TestGetFeedEmptyResult(t *testing.T) { - bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: nil}} - s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) - res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"}) - if err != nil { - t.Fatalf("getFeed error: %v", err) - } - var out GetFeedResponse - if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(out.Posts) != 0 { - t.Fatalf("expected 0 feed posts, got %d", len(out.Posts)) - } -} - -func jsonUnmarshal(t *testing.T, in string, out any) error { - t.Helper() - return json.Unmarshal([]byte(in), out) -} diff --git a/internal/store/AnalyticsCache.sql.go b/internal/store/AnalyticsCache.sql.go index c0e4493..8ee6cb0 100644 --- a/internal/store/AnalyticsCache.sql.go +++ b/internal/store/AnalyticsCache.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: AnalyticsCache.sql package store diff --git a/internal/store/AnalyticsCoverage.sql.go b/internal/store/AnalyticsCoverage.sql.go index 9010166..3f8e0d1 100644 --- a/internal/store/AnalyticsCoverage.sql.go +++ b/internal/store/AnalyticsCoverage.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: AnalyticsCoverage.sql package store @@ -10,83 +10,46 @@ import ( "time" ) -const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec -DELETE FROM analytics_coverage_ranges -WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4 -` - -type DeleteAnalyticsCoverageRangeParams struct { - UserID string - Platform string - CoveredStart time.Time - CoveredEnd time.Time -} - -func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error { - _, err := q.db.ExecContext(ctx, deleteAnalyticsCoverageRange, - arg.UserID, - arg.Platform, - arg.CoveredStart, - arg.CoveredEnd, - ) - return err -} - -const getAnalyticsCoverageRanges = `-- name: GetAnalyticsCoverageRanges :many -SELECT covered_start, covered_end FROM analytics_coverage_ranges +const getAnalyticsCoverage = `-- name: GetAnalyticsCoverage :one +SELECT covered_start, covered_end FROM analytics_coverage WHERE user_id = $1 AND platform = $2 -ORDER BY covered_start ` -type GetAnalyticsCoverageRangesParams struct { +type GetAnalyticsCoverageParams struct { UserID string Platform string } -type GetAnalyticsCoverageRangesRow struct { +type GetAnalyticsCoverageRow struct { CoveredStart time.Time CoveredEnd time.Time } -func (q *Queries) GetAnalyticsCoverageRanges(ctx context.Context, arg GetAnalyticsCoverageRangesParams) ([]GetAnalyticsCoverageRangesRow, error) { - rows, err := q.db.QueryContext(ctx, getAnalyticsCoverageRanges, arg.UserID, arg.Platform) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAnalyticsCoverageRangesRow - for rows.Next() { - var i GetAnalyticsCoverageRangesRow - if err := rows.Scan(&i.CoveredStart, &i.CoveredEnd); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +func (q *Queries) GetAnalyticsCoverage(ctx context.Context, arg GetAnalyticsCoverageParams) (GetAnalyticsCoverageRow, error) { + row := q.db.QueryRowContext(ctx, getAnalyticsCoverage, arg.UserID, arg.Platform) + var i GetAnalyticsCoverageRow + err := row.Scan(&i.CoveredStart, &i.CoveredEnd) + return i, err } -const insertAnalyticsCoverageRange = `-- name: InsertAnalyticsCoverageRange :exec -INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at) +const upsertAnalyticsCoverage = `-- name: UpsertAnalyticsCoverage :exec +INSERT INTO analytics_coverage (user_id, platform, covered_start, covered_end, updated_at) VALUES ($1, $2, $3, $4, now()) -ON CONFLICT (user_id, platform, covered_start, covered_end) -DO UPDATE SET updated_at = now() +ON CONFLICT (user_id, platform) +DO UPDATE SET covered_start = LEAST(analytics_coverage.covered_start, EXCLUDED.covered_start), + covered_end = GREATEST(analytics_coverage.covered_end, EXCLUDED.covered_end), + updated_at = now() ` -type InsertAnalyticsCoverageRangeParams struct { +type UpsertAnalyticsCoverageParams struct { UserID string Platform string CoveredStart time.Time CoveredEnd time.Time } -func (q *Queries) InsertAnalyticsCoverageRange(ctx context.Context, arg InsertAnalyticsCoverageRangeParams) error { - _, err := q.db.ExecContext(ctx, insertAnalyticsCoverageRange, +func (q *Queries) UpsertAnalyticsCoverage(ctx context.Context, arg UpsertAnalyticsCoverageParams) error { + _, err := q.db.ExecContext(ctx, upsertAnalyticsCoverage, arg.UserID, arg.Platform, arg.CoveredStart, diff --git a/internal/store/AnalyticsPosts.sql.go b/internal/store/AnalyticsPosts.sql.go index f68dd24..1cf59a1 100644 --- a/internal/store/AnalyticsPosts.sql.go +++ b/internal/store/AnalyticsPosts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: AnalyticsPosts.sql package store diff --git a/internal/store/DeleteStylePosts.sql.go b/internal/store/DeleteStylePosts.sql.go index e9680d7..6a8ce7b 100644 --- a/internal/store/DeleteStylePosts.sql.go +++ b/internal/store/DeleteStylePosts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: DeleteStylePosts.sql package store diff --git a/internal/store/FindSimilarUsers.sql.go b/internal/store/FindSimilarUsers.sql.go index b2d4767..ba1678e 100644 --- a/internal/store/FindSimilarUsers.sql.go +++ b/internal/store/FindSimilarUsers.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: FindSimilarUsers.sql package store diff --git a/internal/store/GetStylePostByID.sql.go b/internal/store/GetStylePostByID.sql.go index b3ca156..2f6d77a 100644 --- a/internal/store/GetStylePostByID.sql.go +++ b/internal/store/GetStylePostByID.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: GetStylePostByID.sql package store diff --git a/internal/store/GetStylePosts.sql.go b/internal/store/GetStylePosts.sql.go index 34c443d..e8872d4 100644 --- a/internal/store/GetStylePosts.sql.go +++ b/internal/store/GetStylePosts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: GetStylePosts.sql package store diff --git a/internal/store/GetStylePostsByIDs.sql.go b/internal/store/GetStylePostsByIDs.sql.go index fb5c417..b1996ab 100644 --- a/internal/store/GetStylePostsByIDs.sql.go +++ b/internal/store/GetStylePostsByIDs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: GetStylePostsByIDs.sql package store diff --git a/internal/store/GetStyleProfile.sql.go b/internal/store/GetStyleProfile.sql.go index 153823e..bbeaa9a 100644 --- a/internal/store/GetStyleProfile.sql.go +++ b/internal/store/GetStyleProfile.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: GetStyleProfile.sql package store diff --git a/internal/store/PruneStylePosts.sql.go b/internal/store/PruneStylePosts.sql.go index d2d6ae3..52a36c8 100644 --- a/internal/store/PruneStylePosts.sql.go +++ b/internal/store/PruneStylePosts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: PruneStylePosts.sql package store diff --git a/internal/store/SaveTransaction.sql.go b/internal/store/SaveTransaction.sql.go index fed7e9c..404e8b2 100644 --- a/internal/store/SaveTransaction.sql.go +++ b/internal/store/SaveTransaction.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: SaveTransaction.sql package store diff --git a/internal/store/UpsertStylePosts.sql.go b/internal/store/UpsertStylePosts.sql.go index 49eff57..8839ffe 100644 --- a/internal/store/UpsertStylePosts.sql.go +++ b/internal/store/UpsertStylePosts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: UpsertStylePosts.sql package store diff --git a/internal/store/UpsertStyleProfiles.sql.go b/internal/store/UpsertStyleProfiles.sql.go index e6d431a..7dadce2 100644 --- a/internal/store/UpsertStyleProfiles.sql.go +++ b/internal/store/UpsertStyleProfiles.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: UpsertStyleProfiles.sql package store diff --git a/internal/store/db.go b/internal/store/db.go index 5131a7c..fd993d9 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package store diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go index 2adc0b4..820fafe 100644 --- a/internal/store/integration_test.go +++ b/internal/store/integration_test.go @@ -71,6 +71,72 @@ func TestStoreRoundTrip(t *testing.T) { } } +// TestSaveTransactionSQL verifies the SaveTransaction SQL query constant is well-formed. +func TestSaveTransactionSQL(t *testing.T) { + // Verify the const exists and has expected structure + const expected = `-- name: SaveTransaction :many +INSERT INTO transactions (x402_transaction_hash, amount, asset, network, recipient, sender) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at +` + if saveTransaction != expected { + t.Fatalf("SaveTransaction SQL mismatch:\n got: %q\nwant: %q", saveTransaction, expected) + } +} + +// TestSaveTransactionParams verifies the SaveTransaction params struct fields match the SQL. +func TestSaveTransactionParams(t *testing.T) { + cases := []struct { + name string + params SaveTransactionParams + wantErr bool + }{ + { + name: "valid params", + params: SaveTransactionParams{ + X402TransactionHash: "0xabc123", + Amount: "10.0", + Asset: "ETH", + Network: "eip155:1", + Recipient: "0xrecipient", + Sender: "0xsender", + }, + wantErr: false, + }, + { + name: "empty hash", + params: SaveTransactionParams{ + X402TransactionHash: "", + Amount: "10.0", + Asset: "ETH", + Network: "eip155:1", + Recipient: "0xrecipient", + Sender: "0xsender", + }, + wantErr: false, + }, + { + name: "empty amount", + params: SaveTransactionParams{ + X402TransactionHash: "0xabc123", + Amount: "", + Asset: "ETH", + Network: "eip155:1", + Recipient: "0xrecipient", + Sender: "0xsender", + }, + wantErr: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // Just verify the struct can be created without panic + // Actual DB execution would require a connection + _ = c.params + }) + } +} + // TestStoreSaveTransaction exercises SaveTransaction against a real Postgres database. // It is skipped unless TEST_DATABASE_URL is set. func TestStoreSaveTransaction(t *testing.T) { diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go new file mode 100644 index 0000000..c139740 --- /dev/null +++ b/internal/store/migrate_test.go @@ -0,0 +1,24 @@ +package store + +import ( + "strings" + "testing" +) + +func TestExtractUpSQL(t *testing.T) { + content := `-- +goose up +CREATE TABLE t (id serial); +-- a comment inside the up section +-- +goose down +DROP TABLE t; +SELECT 1; +` + got := extractUpSQL(content) + want := "CREATE TABLE t (id serial);\n-- a comment inside the up section\n" + if got != want { + t.Fatalf("extractUpSQL mismatch:\n got: %q\nwant: %q", got, want) + } + if strings.Contains(got, "DROP TABLE") || strings.Contains(got, "SELECT 1") { + t.Fatalf("down section leaked into up sql: %q", got) + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 2bba824..d3820d8 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package store @@ -21,7 +21,7 @@ type AnalyticsCache struct { ExpiresAt time.Time } -type AnalyticsCoverageRange struct { +type AnalyticsCoverage struct { UserID string Platform string CoveredStart time.Time diff --git a/internal/twitter/client.go b/internal/twitter/client.go index 08e97e2..29360e3 100644 --- a/internal/twitter/client.go +++ b/internal/twitter/client.go @@ -14,15 +14,6 @@ import ( log "github.com/nacorid/logger" ) -func parseTwitterTime(s string) time.Time { - t, err := time.Parse(time.RFC3339, s) - if err != nil { - log.Warn("Failed to parse Twitter timestamp", "timestamp", s, "error", err) - return time.Time{} - } - return t -} - const apiBase = "https://api.x.com/2" type Client struct { @@ -68,14 +59,14 @@ type tweetMetrics struct { } type tweetData struct { - ID string `json:"id"` - Text string `json:"text"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` PublicMetrics tweetMetrics `json:"public_metrics"` } type tweetsResponse struct { - Data []tweetData `json:"data"` + Data []tweetData `json:"data"` Includes struct { Users []struct { Username string `json:"username"` @@ -89,9 +80,9 @@ type tweetsResponse struct { type singleTweetResponse struct { Data struct { - ID string `json:"id"` - Text string `json:"text"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` PublicMetrics tweetMetrics `json:"public_metrics"` } `json:"data"` Includes struct { @@ -231,7 +222,7 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ( posts := make([]*Post, 0, len(tr.Data)) for _, t := range tr.Data { - created := parseTwitterTime(t.CreatedAt) + created, _ := time.Parse(time.RFC3339, t.CreatedAt) posts = append(posts, &Post{ ID: t.ID, Author: userID, @@ -280,7 +271,7 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e posts := make([]*Post, 0, len(tr.Data)) for _, t := range tr.Data { - created := parseTwitterTime(t.CreatedAt) + created, _ := time.Parse(time.RFC3339, t.CreatedAt) posts = append(posts, &Post{ ID: t.ID, Author: userID, diff --git a/internal/twitter/client_test.go b/internal/twitter/client_test.go index 9dca301..6d899c4 100644 --- a/internal/twitter/client_test.go +++ b/internal/twitter/client_test.go @@ -168,211 +168,3 @@ func TestGetPostsInTimeframe(t *testing.T) { } } -func TestRateLimitRetryWithResetHeader(t *testing.T) { - callCount := 0 - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - callCount++ - if callCount == 1 { - // First call: rate limited with reset header - w.Header().Set("x-rate-limit-remaining", "0") - w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(100*time.Millisecond).Unix())) - w.WriteHeader(http.StatusTooManyRequests) - return - } - // Second call: success - fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`) - }) - mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`) - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - // Use a short context timeout to avoid waiting too long - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - posts, err := c.GetLatestNPosts(ctx, "@testuser", 10) - if err != nil { - t.Fatalf("GetLatestNPosts error after retry: %v", err) - } - if callCount != 2 { - t.Fatalf("expected 2 calls (1 retry), got %d", callCount) - } - if len(posts) != 1 { - t.Fatalf("expected 1 post after retry, got %d", len(posts)) - } -} - -func TestRateLimitRetryWithRetryAfterHeader(t *testing.T) { - callCount := 0 - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - callCount++ - if callCount == 1 { - // First call: rate limited with Retry-After header - w.Header().Set("Retry-After", "1") // 1 second - w.WriteHeader(http.StatusTooManyRequests) - return - } - // Second call: success - fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`) - }) - mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`) - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - posts, err := c.GetLatestNPosts(ctx, "@testuser", 10) - if err != nil { - t.Fatalf("GetLatestNPosts error after retry: %v", err) - } - if callCount != 2 { - t.Fatalf("expected 2 calls (1 retry), got %d", callCount) - } - if len(posts) != 1 { - t.Fatalf("expected 1 post after retry, got %d", len(posts)) - } -} - -func TestRateLimitExhaustedAfterMaxRetries(t *testing.T) { - callCount := 0 - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - callCount++ - // Always rate limited - no reset header, no Retry-After, so exponential backoff is used - w.Header().Set("x-rate-limit-remaining", "0") - // Don't set x-rate-limit-reset or Retry-After to force exponential backoff - w.WriteHeader(http.StatusTooManyRequests) - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - // Use a timeout long enough for all retries (4 retries with exponential backoff: 500ms, 1s, 2s, 4s = ~7.5s) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - _, err := c.GetLatestNPosts(ctx, "@testuser", 10) - if err == nil { - t.Fatal("expected error after max retries exhausted") - } - // Should have tried maxRetries (4) + 1 initial = 5 times - if callCount < 5 { - t.Fatalf("expected at least 5 attempts (1 initial + 4 retries), got %d", callCount) - } - if !strings.Contains(err.Error(), "rate limited after retries") { - t.Fatalf("expected 'rate limited after retries' error, got %v", err) - } -} - -func TestUserLookupNotFound(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Fprint(w, `{"errors":[{"title":"Not Found","detail":"User not found"}]}`) - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - _, err := c.GetLatestNPosts(context.Background(), "@nonexistent", 10) - if err == nil { - t.Fatal("expected error for user not found") - } - if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Not Found") { - t.Fatalf("expected 'Not Found' error, got %v", err) - } -} - -func TestUserLookupUnauthorized(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprint(w, `{"errors":[{"title":"Unauthorized","detail":"Invalid token"}]}`) - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - _, err := c.GetLatestNPosts(context.Background(), "@testuser", 10) - if err == nil { - t.Fatal("expected error for unauthorized") - } - if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Unauthorized") { - t.Fatalf("expected 'Unauthorized' error, got %v", err) - } -} - -func TestUserLookupRateLimited(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - // Need enough time for max retries (4 retries * 1 second = 4 seconds) + initial - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - _, err := c.GetLatestNPosts(ctx, "@testuser", 10) - if err == nil { - t.Fatal("expected error for rate limited user lookup") - } - if !strings.Contains(err.Error(), "twitter error") && !strings.Contains(err.Error(), "rate limited after retries") { - t.Fatalf("expected twitter error or rate limited after retries, got %v", err) - } -} - -func TestContextCancellationDuringRetry(t *testing.T) { - callCount := 0 - mux := http.NewServeMux() - mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { - callCount++ - // First call: rate limited with long reset time - if callCount == 1 { - w.Header().Set("x-rate-limit-remaining", "0") - w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(10*time.Second).Unix())) - w.WriteHeader(http.StatusTooManyRequests) - return - } - }) - server := httptest.NewServer(mux) - defer server.Close() - - c := newTestClient(server.URL) - // Cancel context immediately after first call - ctx, cancel := context.WithCancel(context.Background()) - // Start the request in a goroutine - done := make(chan error, 1) - go func() { - _, err := c.GetLatestNPosts(ctx, "@testuser", 10) - done <- err - }() - - // Give it a moment to make the first call, then cancel - time.Sleep(100 * time.Millisecond) - cancel() - - select { - case err := <-done: - if err == nil { - t.Fatal("expected error from context cancellation") - } - if !strings.Contains(err.Error(), "context canceled") && !strings.Contains(err.Error(), "context cancelled") { - t.Fatalf("expected context cancellation error, got %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("test timed out waiting for cancellation") - } -} diff --git a/internal/utils/styleProfiles_test.go b/internal/utils/styleProfiles_test.go new file mode 100644 index 0000000..f1b07b5 --- /dev/null +++ b/internal/utils/styleProfiles_test.go @@ -0,0 +1,88 @@ +package utils + +import ( + "reflect" + "testing" +) + +func TestFormatVector(t *testing.T) { + v := []float64{0.1, 0.25, -3.5, 12.0} + if got, want := FormatVector(v), "[0.1,0.25,-3.5,12]"; got != want { + t.Fatalf("FormatVector = %q, want %q", got, want) + } +} + +func TestFormatVectorRoundTrip(t *testing.T) { + v := []float64{1.5, 2.25, -0.001, 100} + parsed, err := ParseEmbedding(FormatVector(v)) + if err != nil { + t.Fatalf("ParseEmbedding(FormatVector(...)) error: %v", err) + } + if !reflect.DeepEqual(parsed, v) { + t.Fatalf("round trip = %v, want %v", parsed, v) + } +} + +func TestParseEmbedding(t *testing.T) { + cases := []struct { + name string + in any + want []float64 + err bool + }{ + {"float64 slice", []float64{1, 2, 3}, []float64{1, 2, 3}, false}, + {"interface slice", []any{float64(1), float64(2)}, []float64{1, 2}, false}, + {"string", "[0.5, 1.5]", []float64{0.5, 1.5}, false}, + {"bytes", []byte("[0.5,1.5]"), []float64{0.5, 1.5}, false}, + {"bad string", "notanumber", nil, true}, + {"interface bad elem", []any{"x"}, nil, true}, + {"unsupported", 42, nil, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := ParseEmbedding(c.in) + if c.err { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, c.want) { + t.Fatalf("got %v want %v", got, c.want) + } + }) + } +} + +func TestAverageVector(t *testing.T) { + vs := [][]float64{{2, 4}, {4, 6}} + if got, want := AverageVector(vs), []float64{3, 5}; !reflect.DeepEqual(got, want) { + t.Fatalf("AverageVector = %v, want %v", got, want) + } + if vs := AverageVector([][]float64{{1, 2, 3}, {3, 4, 5}}); len(vs) != 3 { + t.Fatalf("dimension not preserved: %d", len(vs)) + } +} + +func TestTopWords(t *testing.T) { + posts := []string{ + "the quick brown fox jumps", + "quick brown dogs run fast", + "the lazy brown fox sleeps", + } + got := TopWords(posts, map[string]struct{}{"lazy": {}}, 10) + if len(got) == 0 || got[0] != "brown" { + t.Fatalf("expected brown first, got %v", got) + } + for _, w := range got { + if len(w) < 4 { + t.Fatalf("word shorter than 4 should be excluded: %q", w) + } + if w == "lazy" { + t.Fatalf("stopword %q should be excluded: %v", w, got) + } + } +} diff --git a/internal/x402/x402.go b/internal/x402/x402.go index 31cad9a..92d6205 100644 --- a/internal/x402/x402.go +++ b/internal/x402/x402.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "reflect" - "strconv" "strings" "github.com/google/jsonschema-go/jsonschema" @@ -217,7 +216,7 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool if toolName == "getStyleProfile" { actualCost := computeTwitterStyleProfileCost(r, textContent.Text) nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{ - Amount: actualCost, + Amount: fmt.Sprintf("%d", actualCost), }) } w.Write([]byte(textContent.Text)) @@ -402,68 +401,55 @@ func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(h } } -// Twitter API costs are itemized below. Every getStyleProfile request, for any -// platform, also carries the flat base cost of the endpoint (the same price a -// non-Twitter platform pays). Adding that base cost on top of the itemized -// Twitter API costs guarantees the call always clears the underlying API cost -// and generates a profit - it can never break even or lose money. -const ( - // baseProfileCostMicro is the base cost of the style-profile endpoint for - // the non-Twitter path. It doubles as the profit margin added to Twitter - // requests, expressed in micro-dollars (USDC has 6 decimals). - baseProfileCostMicro int64 = 100_000 // $0.10 - // twitterUserLookupMicro is the per-request Twitter user lookup API cost. - twitterUserLookupMicro int64 = 10_000 // $0.01 - // twitterPostReadMicro is the per-post Twitter read API cost. - twitterPostReadMicro int64 = 5_000 // $0.005 per post -) - -// computeTwitterStyleProfileCost calculates the actual charge for a Twitter -// getStyleProfile request based on the number of posts fetched and the user -// lookup, and returns it as a dollar price string (e.g. "$0.16") suitable for a -// x402 settlement override. -// -// Pricing: guaranteed base cost of $0.10 (the endpoint cost for other -// platforms), plus user lookup $0.01, plus $0.005 per post read. For example, -// 10 posts = $0.16. Non-Twitter platforms are charged the flat $0.10 base. -func computeTwitterStyleProfileCost(r *http.Request, responseText string) string { +// computeTwitterStyleProfileCost calculates the actual charge for a Twitter getStyleProfile +// request based on the number of posts fetched and user lookup. +// Pricing: user read $0.01, post read $0.005 each, minimum $0.10, maximum $0.26. +func computeTwitterStyleProfileCost(r *http.Request, responseText string) int { // Parse the response to get PostCount and SourcePlatform var resp GetStyleProfileResponse if err := json.Unmarshal([]byte(responseText), &resp); err != nil { - return formatDollarAmount(baseProfileCostMicro) // fallback: base cost + return 10 // fallback: 10 cents = $0.10 minimum } - costMicro := baseProfileCostMicro + // Determine if Twitter platform + isTwitter := resp.SourcePlatform == "Twitter" - // Twitter additionally passes on the user lookup and per-post API costs. - if resp.SourcePlatform == "Twitter" { - postCount := resp.PostCount - if postCount < 0 { - postCount = 0 - } - costMicro += twitterUserLookupMicro - costMicro += int64(postCount) * twitterPostReadMicro + // Extract post count from response + postCount := resp.PostCount + if postCount <= 0 { + postCount = 0 } - return formatDollarAmount(costMicro) -} + // Calculate cost: + // - User read: $0.01 if Twitter and we assume user lookup was performed + // - Posts read: $0.005 per post, max 50 posts = $0.25 + // - Minimum: $0.10 + // - Maximum: $0.26 + var costCents int -// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed -// price string with at least two decimal places and trailing zeroes trimmed -// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402 -// settlement override resolver expects. -func formatDollarAmount(micro int64) string { - dollars := float64(micro) / 1_000_000 - s := strconv.FormatFloat(dollars, 'f', 6, 64) - s = strings.TrimRight(s, "0") - if dot := strings.IndexByte(s, '.'); dot >= 0 { - if decimals := len(s) - dot - 1; decimals < 2 { - s += strings.Repeat("0", 2-decimals) + if isTwitter { + // Twitter: user read + posts read + userCents := 1 // $0.01 + postCents := postCount * 5 // $0.005 per post = 5 cents per post in atomic units + if postCents > 125 { // 50 posts * 5 = 25 cents max + postCents = 125 } + costCents = userCents + postCents } else { - s += ".00" + costCents = 10 // $0.10 minimum for non-Twitter } - return "$" + s + + // Apply minimum $0.10 (10 cents) + if costCents < 10 { + costCents = 10 + } + + // Apply maximum $0.26 (26 cents) + if costCents > 26 { + costCents = 26 + } + + return costCents } // GetStyleProfileResponse is a local copy of the response schema for cost calculation diff --git a/internal/x402/x402_test.go b/internal/x402/x402_test.go index cf4a757..19f8036 100644 --- a/internal/x402/x402_test.go +++ b/internal/x402/x402_test.go @@ -3,10 +3,8 @@ package x402 import ( "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" - "strconv" "strings" "testing" @@ -175,56 +173,6 @@ func TestX402Server_MCPBoundaries(t *testing.T) { } } -func TestComputeTwitterStyleProfileCost(t *testing.T) { - cases := []struct { - name string - platform string - postCount int - want string - }{ - {name: "non-twitter flat base", platform: "Bluesky", postCount: 10, want: "$0.10"}, - {name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.16"}, - {name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.11"}, - {name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.36"}, - {name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.125"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} - payload, _ := json.Marshal(resp) - r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) - - got := computeTwitterStyleProfileCost(r, string(payload)) - if got != tc.want { - t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) - } - }) - } -} - -// Verify that every Twitter charge strictly exceeds the flat base cost, so the -// endpoint always clears the underlying API cost (never break-even or loss). -func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) { - for postCount := 0; postCount <= 50; postCount++ { - resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount} - payload, _ := json.Marshal(resp) - r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) - - got := computeTwitterStyleProfileCost(r, string(payload)) - // Cut the "$" prefix and compare numerically to baseProfileCostMicro. - dollars := strings.TrimPrefix(got, "$") - parsed, err := strconv.ParseFloat(dollars, 64) - if err != nil { - t.Fatalf("failed to parse %q: %v", got, err) - } - micro := int64(parsed * 1_000_000) - if micro <= baseProfileCostMicro { - t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, baseProfileCostMicro) - } - } -} - func setupMockFacilitator() *httptest.Server { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -258,190 +206,3 @@ func setupMockFacilitator() *httptest.Server { })) return s } - -func TestAddTool_NonPayable(t *testing.T) { - mockFacilitator := setupMockFacilitator() - defer mockFacilitator.Close() - - srv, err := NewX402Server( - "Test Agent API", - "1.0.0", - mockFacilitator.URL, - &DummyAuthProvider{}, - X402Middlewares{}, - func(next http.Handler) http.Handler { return next }, - func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, - ) - if err != nil { - t.Fatalf("failed to create server: %v", err) - } - - srv.AddTool( - mcp.Tool{ - Name: "freeTool", - Description: "Free test endpoint", - }, - func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}}, - }, nil - }, - ) - - // Test HTTP endpoint works without payment - reqRec := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`)) - srv.mux.ServeHTTP(reqRec, req) - if reqRec.Code != http.StatusOK { - t.Errorf("expected status 200 for free tool, got %d", reqRec.Code) - } - - // Test OpenAPI includes non-payable tool - reqRecOpenAPI := httptest.NewRecorder() - reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil) - srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI) - if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") { - t.Error("OpenAPI spec did not render non-payable tool") - } - // Should not have 402 response for free tool - if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) { - t.Error("OpenAPI spec should not have 402 for free tool") - } -} - -func TestAddPayableTool_EmptyRequirements(t *testing.T) { - mockFacilitator := setupMockFacilitator() - defer mockFacilitator.Close() - - srv, err := NewX402Server( - "Test Agent API", - "1.0.0", - mockFacilitator.URL, - &DummyAuthProvider{}, - X402Middlewares{}, - func(next http.Handler) http.Handler { return next }, - func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, - ) - if err != nil { - t.Fatalf("failed to create server: %v", err) - } - - // The library validates requirements at AddPayableTool call time - // and returns an error (or panics via log.Fatal) for empty requirements - defer func() { - if r := recover(); r != nil { - errMsg := fmt.Sprintf("%v", r) - // The library may panic with log.Fatal or return an error - if !strings.Contains(errMsg, "requires at least one payment requirement") && - !strings.Contains(errMsg, "Accepts must have at least one") { - t.Errorf("expected panic about empty requirements, got: %v", r) - } - } else { - t.Error("expected panic for empty payment requirements") - } - }() - - srv.AddPayableTool( - mcp.Tool{ - Name: "badTool", - Description: "Bad endpoint", - }, - func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, - }, nil - }, - // No requirements provided - ) -} - -func TestX402Server_MultipleTools(t *testing.T) { - mockFacilitator := setupMockFacilitator() - defer mockFacilitator.Close() - - srv, err := NewX402Server( - "Test Agent API", - "1.0.0", - mockFacilitator.URL, - &DummyAuthProvider{}, - X402Middlewares{}, - func(next http.Handler) http.Handler { return next }, - func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, - ) - if err != nil { - t.Fatalf("failed to create server: %v", err) - } - - // Add multiple tools - toolNames := []string{"tool1", "tool2", "tool3"} - for _, name := range toolNames { - name := name - srv.AddPayableTool( - mcp.Tool{ - Name: name, - Description: "Tool " + name, - }, - func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}}, - }, nil - }, - x402.PaymentRequirements{ - Scheme: "exact", - Network: "eip155:8453", - Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - Amount: "1000", - PayTo: "0x0000000000000000000000000000000000000000", - }, - ) - } - - // Verify all tools are registered in OpenAPI - reqRec := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/openapi.json", nil) - srv.mux.ServeHTTP(reqRec, req) - - body := reqRec.Body.String() - for _, name := range toolNames { - if !strings.Contains(body, name) { - t.Errorf("OpenAPI missing tool: %s", name) - } - } - - // Verify each tool requires payment - for _, name := range toolNames { - reqRec := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`)) - srv.mux.ServeHTTP(reqRec, req) - if reqRec.Code != http.StatusPaymentRequired { - t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code) - } - } -} - -func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) { - cases := []struct { - name string - platform string - postCount int - want string - }{ - {name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.10"}, // not "Twitter" so flat base - {name: "empty platform", platform: "", postCount: 10, want: "$0.10"}, - {name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.11"}, // should clamp to 0 - {name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.11"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} - payload, _ := json.Marshal(resp) - r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) - - got := computeTwitterStyleProfileCost(r, string(payload)) - if got != tc.want { - t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) - } - }) - } -} diff --git a/sql/queries/AnalyticsCoverage.sql b/sql/queries/AnalyticsCoverage.sql index 0aa7ad0..58ad395 100644 --- a/sql/queries/AnalyticsCoverage.sql +++ b/sql/queries/AnalyticsCoverage.sql @@ -1,14 +1,11 @@ --- name: GetAnalyticsCoverageRanges :many -SELECT covered_start, covered_end FROM analytics_coverage_ranges -WHERE user_id = $1 AND platform = $2 -ORDER BY covered_start; +-- name: GetAnalyticsCoverage :one +SELECT covered_start, covered_end FROM analytics_coverage +WHERE user_id = $1 AND platform = $2; --- name: InsertAnalyticsCoverageRange :exec -INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at) +-- name: UpsertAnalyticsCoverage :exec +INSERT INTO analytics_coverage (user_id, platform, covered_start, covered_end, updated_at) VALUES ($1, $2, $3, $4, now()) -ON CONFLICT (user_id, platform, covered_start, covered_end) -DO UPDATE SET updated_at = now(); - --- name: DeleteAnalyticsCoverageRange :exec -DELETE FROM analytics_coverage_ranges -WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4; +ON CONFLICT (user_id, platform) +DO UPDATE SET covered_start = LEAST(analytics_coverage.covered_start, EXCLUDED.covered_start), + covered_end = GREATEST(analytics_coverage.covered_end, EXCLUDED.covered_end), + updated_at = now(); diff --git a/sql/schema/007_add_analytics_coverage.sql b/sql/schema/007_add_analytics_coverage.sql index 148e72d..b22ac24 100644 --- a/sql/schema/007_add_analytics_coverage.sql +++ b/sql/schema/007_add_analytics_coverage.sql @@ -1,21 +1,12 @@ -- +goose up --- Tracks which time ranges have already been fetched for an author on a --- platform so paid API calls (e.g. Twitter) are not repeated. Coverage is --- stored as discrete non-overlapping intervals rather than a single --- contiguous window, so gaps between separately-fetched ranges are not --- mistaken for covered data. -CREATE TABLE IF NOT EXISTS analytics_coverage_ranges ( - user_id TEXT NOT NULL, - platform TEXT NOT NULL, +CREATE TABLE IF NOT EXISTS analytics_coverage ( + user_id TEXT NOT NULL, + platform TEXT NOT NULL, covered_start TIMESTAMPTZ NOT NULL, - covered_end TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, platform, covered_start, covered_end) + covered_end TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, platform) ); -CREATE INDEX IF NOT EXISTS idx_analytics_coverage_ranges_lookup - ON analytics_coverage_ranges (user_id, platform, covered_start); - -- +goose down -DROP INDEX IF EXISTS idx_analytics_coverage_ranges_lookup; -DROP TABLE IF EXISTS analytics_coverage_ranges; +DROP TABLE IF EXISTS analytics_coverage; diff --git a/testClient/main.go b/testClient/main.go index c73d1a0..2e77c1f 100644 --- a/testClient/main.go +++ b/testClient/main.go @@ -292,10 +292,10 @@ func main() { log.Printf("Starting REST API endpoint tests against %s...", serverURL) testCases := []struct { - name string - method string - endpoint string - body string + name string + method string + endpoint string + body string }{ {"getFeed", "POST", "getFeed", `{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}`}, {"getActor", "POST", "getActor", `{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}`},