Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
dab6fe081f |
|||
|
abd8b73e09 |
|||
|
9865f3633c |
|||
|
36c19e8704 |
|||
|
5f25956fdd |
|||
|
08f2d5f562 |
|||
|
2454ef9e62 |
51 changed files with 3326 additions and 794 deletions
|
|
@ -2,7 +2,7 @@ name: Test Every Endpoint on Base Sepolia
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, dev ]
|
branches: [ main, ai-main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ name: CI/CD Go Verification
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, dev ]
|
branches: [ main, dev, ai-main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package activitypub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -98,13 +99,30 @@ func TestGetLatestNPostsHTTPError(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetPostsInTimeframe(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 := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Query().Get("max_id") != "" {
|
feed := []map[string]any{}
|
||||||
fmt.Fprint(w, `[]`)
|
switch r.URL.Query().Get("max_id") {
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `[{"id":"1","content":"timed","text":"timed","created_at":"2024-06-01T00:00:00Z","account":{"acct":"alice"}}]`)
|
|
||||||
})
|
})
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
@ -116,11 +134,11 @@ func TestGetPostsInTimeframe(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPostsInTimeframe error: %v", err)
|
t.Fatalf("GetPostsInTimeframe error: %v", err)
|
||||||
}
|
}
|
||||||
if len(posts) != 1 {
|
if len(posts) != 2 {
|
||||||
t.Fatalf("expected 1 post, got %d", len(posts))
|
t.Fatalf("expected 2 in-window posts, got %d", len(posts))
|
||||||
}
|
}
|
||||||
if posts[0].Content != "timed" || posts[0].Author != "alice" {
|
if posts[0].Content != "mid" || posts[1].Content != "early-mid" {
|
||||||
t.Fatalf("unexpected post: %+v", posts[0])
|
t.Fatalf("unexpected posts: %+v", posts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import (
|
||||||
"github.com/bluesky-social/indigo/api/bsky"
|
"github.com/bluesky-social/indigo/api/bsky"
|
||||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||||
"github.com/bluesky-social/indigo/xrpc"
|
"github.com/bluesky-social/indigo/xrpc"
|
||||||
"github.com/nacorid/naco-api/internal/actor"
|
|
||||||
log "github.com/nacorid/logger"
|
log "github.com/nacorid/logger"
|
||||||
|
"github.com/nacorid/naco-api/internal/actor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BlueskyClient struct {
|
type BlueskyClient struct {
|
||||||
|
|
@ -146,7 +146,9 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, post := range feed.Feed {
|
for _, post := range feed.Feed {
|
||||||
if post.Post.Record != nil {
|
if post.Post == nil || post.Post.Record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
|
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri)
|
log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri)
|
||||||
|
|
@ -167,7 +169,6 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if feed.Cursor == nil || *feed.Cursor == "" {
|
if feed.Cursor == nil || *feed.Cursor == "" {
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/bluesky-social/indigo/api/atproto"
|
"github.com/bluesky-social/indigo/api/atproto"
|
||||||
"github.com/bluesky-social/indigo/api/bsky"
|
"github.com/bluesky-social/indigo/api/bsky"
|
||||||
|
|
@ -109,6 +110,126 @@ func setupMockServer() *httptest.Server {
|
||||||
json.NewEncoder(w).Encode(resp)
|
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)
|
return httptest.NewServer(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,3 +348,264 @@ func TestGetFeed(t *testing.T) {
|
||||||
t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,41 +69,6 @@ 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) {
|
func TestKMeans_DeterministicWithSeed(t *testing.T) {
|
||||||
items := []Item{
|
items := []Item{
|
||||||
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
|
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
|
||||||
|
|
@ -123,31 +88,6 @@ 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) {
|
func TestCosineSimilarity(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr/nip19"
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip19"
|
||||||
|
|
||||||
"github.com/nacorid/naco-api/internal/actor"
|
"github.com/nacorid/naco-api/internal/actor"
|
||||||
)
|
)
|
||||||
|
|
@ -39,11 +39,14 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
||||||
posts := make([]*Post, 0, limit)
|
posts := make([]*Post, 0, limit)
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
connected := false
|
||||||
|
|
||||||
for _, url := range c.relays {
|
for _, url := range c.relays {
|
||||||
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
connected = true
|
||||||
sub, err := relay.Subscribe(ctx, nostr.Filter{
|
sub, err := relay.Subscribe(ctx, nostr.Filter{
|
||||||
Authors: []nostr.PubKey{pubkey},
|
Authors: []nostr.PubKey{pubkey},
|
||||||
Kinds: []nostr.Kind{1},
|
Kinds: []nostr.Kind{1},
|
||||||
|
|
@ -53,6 +56,8 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
||||||
relay.Close()
|
relay.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
defer sub.Unsub()
|
defer sub.Unsub()
|
||||||
defer relay.Close()
|
defer relay.Close()
|
||||||
|
|
||||||
|
|
@ -80,12 +85,16 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
||||||
break collect
|
break collect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if len(posts) >= limit {
|
if len(posts) >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
return nil, fmt.Errorf("could not connect to any relay")
|
||||||
|
}
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,11 +110,14 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
||||||
posts := make([]*Post, 0, limit)
|
posts := make([]*Post, 0, limit)
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
connected := false
|
||||||
|
|
||||||
for _, url := range c.relays {
|
for _, url := range c.relays {
|
||||||
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
connected = true
|
||||||
sub, err := relay.Subscribe(ctx, nostr.Filter{
|
sub, err := relay.Subscribe(ctx, nostr.Filter{
|
||||||
Authors: []nostr.PubKey{pubkey},
|
Authors: []nostr.PubKey{pubkey},
|
||||||
Kinds: []nostr.Kind{1},
|
Kinds: []nostr.Kind{1},
|
||||||
|
|
@ -117,6 +129,8 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
||||||
relay.Close()
|
relay.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
defer sub.Unsub()
|
defer sub.Unsub()
|
||||||
defer relay.Close()
|
defer relay.Close()
|
||||||
|
|
||||||
|
|
@ -144,12 +158,17 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
||||||
break collect
|
break collect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if len(posts) >= limit {
|
if len(posts) >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
return nil, fmt.Errorf("could not connect to any relay")
|
||||||
|
}
|
||||||
|
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,45 +4,228 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/nip19"
|
"fiatjaf.com/nostr/nip19"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolvePubkey(t *testing.T) {
|
func TestNewNostrClientEmptyRelays(t *testing.T) {
|
||||||
var pk nostr.PubKey
|
_, 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()
|
||||||
npub := nip19.EncodeNpub(pk)
|
npub := nip19.EncodeNpub(pk)
|
||||||
if _, err := resolvePubkey(npub); err != nil {
|
hex := pk.Hex()
|
||||||
t.Fatalf("valid npub should resolve: %v", err)
|
|
||||||
|
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("not-a-key"); err == nil {
|
|
||||||
t.Fatal("expected error for invalid pubkey")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveEventID(t *testing.T) {
|
func TestResolveEventIDVariants(t *testing.T) {
|
||||||
// 64 hex chars with no '1' (which would otherwise be read as a bech32 separator)
|
var eid nostr.ID
|
||||||
hex := strings.Repeat("deadbeef", 8)
|
for i := range eid {
|
||||||
if _, err := resolveEventID(hex); err != nil {
|
eid[i] = byte(i + 1)
|
||||||
t.Fatalf("valid 64-hex event id should resolve: %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := resolveEventID("not-an-id"); err == nil {
|
hex := eid.Hex()
|
||||||
t.Fatal("expected error for invalid event id")
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchActors is best-effort on Nostr: a query that is neither an npub nor a 64-hex pubkey
|
func TestGetPostInvalidID(t *testing.T) {
|
||||||
// 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"})
|
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
results, err := c.SearchActors(context.Background(), "not-a-real-query", 5)
|
|
||||||
if err != nil {
|
_, err = c.GetPost(context.Background(), "invalid-id")
|
||||||
t.Fatalf("expected no error, got %v", err)
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid event ID")
|
||||||
}
|
}
|
||||||
if len(results) != 0 {
|
if !strings.Contains(err.Error(), "invalid nostr event id") {
|
||||||
t.Fatalf("expected empty results, got %d", len(results))
|
t.Fatalf("expected 'invalid nostr event id' error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActorInvalidAuthor(t *testing.T) {
|
||||||
|
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,51 +126,6 @@ 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) {
|
func TestSummarizeStyle_Streaming(t *testing.T) {
|
||||||
callCount := 0
|
callCount := 0
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package server
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -37,14 +36,18 @@ func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, err
|
||||||
start := now.AddDate(0, 0, -defaultAnalyticsDays)
|
start := now.AddDate(0, 0, -defaultAnalyticsDays)
|
||||||
|
|
||||||
if endStr != "" {
|
if endStr != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
t, err := time.Parse(time.RFC3339, endStr)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid endTime %q: %w", endStr, err)
|
||||||
|
}
|
||||||
end = t
|
end = t
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if startStr != "" {
|
if startStr != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
t, err := time.Parse(time.RFC3339, startStr)
|
||||||
start = t
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err)
|
||||||
}
|
}
|
||||||
|
start = t
|
||||||
}
|
}
|
||||||
if start.After(end) {
|
if start.After(end) {
|
||||||
start, end = end, start
|
start, end = end, start
|
||||||
|
|
@ -132,6 +135,10 @@ func (s *Server) getEngagementDrivers(ctx context.Context, request *mcp.CallTool
|
||||||
type analyticsComputer func(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error)
|
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) {
|
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
|
// Normalize args
|
||||||
limit := args.Limit
|
limit := args.Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
|
|
@ -150,7 +157,10 @@ func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, to
|
||||||
if args.Platform != "" {
|
if args.Platform != "" {
|
||||||
platforms = []string{args.Platform}
|
platforms = []string{args.Platform}
|
||||||
} else {
|
} else {
|
||||||
platforms = []string{"Bluesky", "ActivityPub", "Nostr"}
|
// 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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeRangeHash := hashTimeRange(start, end, limit)
|
timeRangeHash := hashTimeRange(start, end, limit)
|
||||||
|
|
@ -175,7 +185,7 @@ func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, to
|
||||||
for _, p := range platforms {
|
for _, p := range platforms {
|
||||||
plat, ok := platformFromString(p)
|
plat, ok := platformFromString(p)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil, fmt.Errorf("unsupported platform %q", p)
|
||||||
}
|
}
|
||||||
posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit)
|
posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -332,31 +342,28 @@ func (s *Server) fetchPostsInTimeForAnalytics(ctx context.Context, platform plat
|
||||||
// fetchTwitterAnalyticsPosts returns posts in [start, end] for the author,
|
// fetchTwitterAnalyticsPosts returns posts in [start, end] for the author,
|
||||||
// fetching from the Twitter API only the sub-ranges not already covered by the
|
// 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.
|
// 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) {
|
func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||||
coverage, err := s.getAnalyticsCoverage(ctx, author, "Twitter")
|
covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Unknown coverage (missing row) is not an error; fall back to fetching.
|
|
||||||
if err != sql.ErrNoRows {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
coverageStart, coverageEnd := time.Time{}, time.Time{}
|
// If the entire requested range falls within already-covered intervals,
|
||||||
haveCoverage := !coverage.CoveredStart.IsZero() && !coverage.CoveredEnd.IsZero()
|
// serve from the cache without incurring a paid Twitter request.
|
||||||
if haveCoverage {
|
if len(uncoveredRanges(start, end, covered)) == 0 {
|
||||||
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)
|
return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the sub-ranges of [start, end] not yet covered, and fetch each
|
// Determine the sub-ranges of [start, end] not yet covered, and fetch each
|
||||||
// from the API. Fetching per uncovered range keeps costs bounded while
|
// from the API. Fetching per uncovered range keeps costs bounded while
|
||||||
// expanding coverage on every call.
|
// expanding coverage on every call.
|
||||||
ranges := uncoveredRanges(start, end, coverageStart, coverageEnd, haveCoverage)
|
ranges := uncoveredRanges(start, end, covered)
|
||||||
|
|
||||||
var fetched []clustering.Item
|
var fetched []clustering.Item
|
||||||
for _, r := range ranges {
|
for _, r := range ranges {
|
||||||
|
|
@ -374,9 +381,12 @@ func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extend coverage to encompass the whole requested range.
|
// Record the newly fetched intervals as covered so future calls can reuse
|
||||||
if err := s.extendAnalyticsCoverage(ctx, author, "Twitter", start, end); err != nil {
|
// them without hitting the paid API.
|
||||||
log.WarnWithContext(ctx, "failed to extend analytics coverage", "platform", "Twitter", "error", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the full set: posts from cache plus anything newly fetched, merged
|
// Return the full set: posts from cache plus anything newly fetched, merged
|
||||||
|
|
@ -388,33 +398,49 @@ type timeRange struct {
|
||||||
start, end time.Time
|
start, end time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// uncoveredRanges returns the sub-ranges of [start, end] not covered by
|
// uncoveredRanges returns the sub-ranges of [start, end] that are not covered
|
||||||
// [coveredStart, coveredEnd]. If there is no coverage, the whole range is
|
// by any of the given intervals. Intervals are assumed sorted by their start,
|
||||||
// returned.
|
// and no coverage (empty slice) yields the whole range as uncovered.
|
||||||
func uncoveredRanges(start, end, coveredStart, coveredEnd time.Time, have bool) []timeRange {
|
func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange {
|
||||||
if !have {
|
|
||||||
return []timeRange{{start: start, end: end}}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ranges []timeRange
|
var ranges []timeRange
|
||||||
if start.Before(coveredStart) {
|
cursor := start
|
||||||
ranges = append(ranges, timeRange{start: start, end: coveredStart})
|
for _, c := range covered {
|
||||||
|
if c.end.Before(cursor) || c.end.Equal(cursor) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if end.After(coveredEnd) {
|
if c.start.After(end) {
|
||||||
ranges = append(ranges, timeRange{start: coveredEnd, end: end})
|
break
|
||||||
|
}
|
||||||
|
if c.start.After(cursor) {
|
||||||
|
ranges = append(ranges, timeRange{start: cursor, end: c.start})
|
||||||
|
}
|
||||||
|
if c.end.After(cursor) {
|
||||||
|
cursor = c.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursor.Before(end) {
|
||||||
|
ranges = append(ranges, timeRange{start: cursor, end: end})
|
||||||
}
|
}
|
||||||
return ranges
|
return ranges
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getAnalyticsCoverage(ctx context.Context, author, platform string) (store.GetAnalyticsCoverageRow, error) {
|
func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) {
|
||||||
return s.db.GetAnalyticsCoverage(ctx, store.GetAnalyticsCoverageParams{
|
rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{
|
||||||
UserID: author,
|
UserID: author,
|
||||||
Platform: platform,
|
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) extendAnalyticsCoverage(ctx context.Context, author, platform string, start, end time.Time) error {
|
func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error {
|
||||||
return s.db.UpsertAnalyticsCoverage(ctx, store.UpsertAnalyticsCoverageParams{
|
return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{
|
||||||
UserID: author,
|
UserID: author,
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
CoveredStart: start,
|
CoveredStart: start,
|
||||||
|
|
@ -446,11 +472,16 @@ func (s *Server) fetchTwitterFromAPI(ctx context.Context, author string, start,
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched
|
// mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched
|
||||||
// posts, de-duplicating by post ID.
|
// 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.
|
||||||
func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int, fetched []clustering.Item) ([]clustering.Item, error) {
|
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))
|
merged := make([]clustering.Item, 0, len(fetched))
|
||||||
seen := make(map[string]struct{}, len(fetched))
|
seen := make(map[string]struct{}, len(fetched))
|
||||||
for _, p := range fetched {
|
for _, p := range fetched {
|
||||||
|
if len(merged) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
if _, ok := seen[p.ID]; ok {
|
if _, ok := seen[p.ID]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -463,6 +494,9 @@ func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start,
|
||||||
return merged, nil
|
return merged, nil
|
||||||
}
|
}
|
||||||
for _, p := range cached {
|
for _, p := range cached {
|
||||||
|
if len(merged) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
if _, ok := seen[p.ID]; ok {
|
if _, ok := seen[p.ID]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -598,8 +632,23 @@ func (s *Server) computePostingPatterns(ctx context.Context, args AnalyticsReque
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
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 {
|
if len(posts) < minTopicCount {
|
||||||
return TopicModelingResponse{Author: args.Author, Topics: []TopicCluster{}}, nil
|
return []TopicCluster{}, map[string]string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use embeddings from ollama for clustering
|
// Use embeddings from ollama for clustering
|
||||||
|
|
@ -607,7 +656,7 @@ func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest
|
||||||
// We need embeddings for clustering - compute them
|
// We need embeddings for clustering - compute them
|
||||||
embeddings, err := s.ollama.EmbedTexts(ctx, items)
|
embeddings, err := s.ollama.EmbedTexts(ctx, items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to compute embeddings: %w", err)
|
return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update items with embeddings
|
// Update items with embeddings
|
||||||
|
|
@ -627,10 +676,11 @@ func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest
|
||||||
// Run KMeans
|
// Run KMeans
|
||||||
results, err := clustering.KMeans(items, topicCount, 50, 42)
|
results, err := clustering.KMeans(items, topicCount, 50, 42)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("kmeans failed: %w", err)
|
return nil, nil, fmt.Errorf("kmeans failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate labels for each cluster using Ollama
|
membership := make(map[string]string)
|
||||||
|
labelByCluster := make(map[int]string)
|
||||||
var topics []TopicCluster
|
var topics []TopicCluster
|
||||||
for _, cluster := range results {
|
for _, cluster := range results {
|
||||||
if len(cluster.Items) == 0 {
|
if len(cluster.Items) == 0 {
|
||||||
|
|
@ -648,6 +698,7 @@ func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest
|
||||||
|
|
||||||
// Generate label using Ollama
|
// Generate label using Ollama
|
||||||
label := s.generateTopicLabel(ctx, samples)
|
label := s.generateTopicLabel(ctx, samples)
|
||||||
|
labelByCluster[cluster.Cluster] = label
|
||||||
keywords := s.extractKeywords(cluster.Items)
|
keywords := s.extractKeywords(cluster.Items)
|
||||||
|
|
||||||
topics = append(topics, TopicCluster{
|
topics = append(topics, TopicCluster{
|
||||||
|
|
@ -660,13 +711,20 @@ func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 by post count descending
|
||||||
sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount })
|
sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount })
|
||||||
|
|
||||||
return TopicModelingResponse{
|
return topics, membership, nil
|
||||||
Author: args.Author,
|
|
||||||
Topics: topics,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||||
|
|
@ -687,6 +745,16 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque
|
||||||
var prevSentiment string
|
var prevSentiment string
|
||||||
var prevScores [3]float64
|
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.
|
// Process buckets in chronological order so sentiment shifts are detected correctly.
|
||||||
buckets := make([]string, 0, len(bucketMap))
|
buckets := make([]string, 0, len(bucketMap))
|
||||||
for b := range bucketMap {
|
for b := range bucketMap {
|
||||||
|
|
@ -728,7 +796,7 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque
|
||||||
prevScores = [3]float64{pos, neg, neu}
|
prevScores = [3]float64{pos, neg, neu}
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPosts := float64(len(posts))
|
totalPosts := float64(timestampedPosts)
|
||||||
if totalPosts > 0 {
|
if totalPosts > 0 {
|
||||||
overallPos /= totalPosts
|
overallPos /= totalPosts
|
||||||
overallNeg /= totalPosts
|
overallNeg /= totalPosts
|
||||||
|
|
@ -767,20 +835,53 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||||
// Topic modeling first
|
// Topic modeling first, getting post->topic membership so we can correlate
|
||||||
topicResult, err := s.computeTopicModeling(ctx, args, posts)
|
// each topic with the engagement of its member posts.
|
||||||
|
topics, membership, err := s.clusterPosts(ctx, posts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
topicResp := topicResult.(TopicModelingResponse)
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
var drivers []EngagementDriver
|
var drivers []EngagementDriver
|
||||||
for _, topic := range topicResp.Topics {
|
for _, topic := range topics {
|
||||||
// In real impl, correlate topic presence with engagement metrics
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
drivers = append(drivers, EngagementDriver{
|
drivers = append(drivers, EngagementDriver{
|
||||||
Topic: topic.Label,
|
Topic: topic.Label,
|
||||||
AvgEngagement: 0.0,
|
AvgEngagement: topicMean,
|
||||||
Correlation: 0.0,
|
Correlation: correlation,
|
||||||
PostCount: topic.PostCount,
|
PostCount: topic.PostCount,
|
||||||
SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))],
|
SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))],
|
||||||
})
|
})
|
||||||
|
|
@ -796,6 +897,17 @@ func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsReq
|
||||||
}, nil
|
}, 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 ----
|
// ---- Helper functions ----
|
||||||
|
|
||||||
func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string {
|
func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string {
|
||||||
|
|
|
||||||
269
internal/server/analytics_test.go
Normal file
269
internal/server/analytics_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bluesky-social/indigo/api/bsky"
|
"github.com/bluesky-social/indigo/api/bsky"
|
||||||
|
|
@ -10,6 +11,7 @@ import (
|
||||||
"github.com/nacorid/naco-api/internal/actor"
|
"github.com/nacorid/naco-api/internal/actor"
|
||||||
"github.com/nacorid/naco-api/internal/clustering"
|
"github.com/nacorid/naco-api/internal/clustering"
|
||||||
"github.com/nacorid/naco-api/internal/nostr"
|
"github.com/nacorid/naco-api/internal/nostr"
|
||||||
|
"github.com/nacorid/naco-api/internal/store"
|
||||||
"github.com/nacorid/naco-api/internal/twitter"
|
"github.com/nacorid/naco-api/internal/twitter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,3 +55,22 @@ type ollamaBackend interface {
|
||||||
EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error)
|
EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error)
|
||||||
SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,30 +34,30 @@ func assertRanges(t *testing.T, got []timeRange, want ...timeRange) {
|
||||||
|
|
||||||
func TestUncoveredRangesNoCoverage(t *testing.T) {
|
func TestUncoveredRangesNoCoverage(t *testing.T) {
|
||||||
r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||||
got := uncoveredRanges(r.start, r.end, time.Time{}, time.Time{}, false)
|
got := uncoveredRanges(r.start, r.end, nil)
|
||||||
assertRanges(t, got, r)
|
assertRanges(t, got, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUncoveredRangesFullyCovered(t *testing.T) {
|
func TestUncoveredRangesFullyCovered(t *testing.T) {
|
||||||
req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
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")
|
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||||
got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true)
|
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||||
assertRanges(t, got)
|
assertRanges(t, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUncoveredRangesLeftOnly(t *testing.T) {
|
func TestUncoveredRangesLeftOnly(t *testing.T) {
|
||||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
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")
|
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")
|
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)
|
assertRanges(t, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUncoveredRangesRightOnly(t *testing.T) {
|
func TestUncoveredRangesRightOnly(t *testing.T) {
|
||||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
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")
|
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")
|
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)
|
assertRanges(t, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +66,35 @@ func TestUncoveredRangesBothSides(t *testing.T) {
|
||||||
cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
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")
|
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")
|
wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
|
||||||
got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true)
|
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||||
assertRanges(t, got, wantLeft, wantRight)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -14,6 +15,7 @@ import (
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/nacorid/naco-api/internal/activitypub"
|
"github.com/nacorid/naco-api/internal/activitypub"
|
||||||
"github.com/nacorid/naco-api/internal/actor"
|
"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/nostr"
|
||||||
"github.com/nacorid/naco-api/internal/store"
|
"github.com/nacorid/naco-api/internal/store"
|
||||||
"github.com/nacorid/naco-api/internal/twitter"
|
"github.com/nacorid/naco-api/internal/twitter"
|
||||||
|
|
@ -29,6 +31,7 @@ type fakeBluesky struct {
|
||||||
feedOut *bsky.FeedGetFeed_Output
|
feedOut *bsky.FeedGetFeed_Output
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
|
lastLimit int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
|
func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
|
||||||
|
|
@ -75,6 +78,7 @@ 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) {
|
func (f *fakeBluesky) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
|
f.lastLimit = limit
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +92,7 @@ type fakeAP struct {
|
||||||
actors []actor.Profile
|
actors []actor.Profile
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
|
lastLimit int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeAP) GetPost(ctx context.Context, postID string) (*activitypub.Post, error) {
|
func (f *fakeAP) GetPost(ctx context.Context, postID string) (*activitypub.Post, error) {
|
||||||
|
|
@ -124,6 +129,7 @@ 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) {
|
func (f *fakeAP) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
|
f.lastLimit = limit
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +143,7 @@ type fakeNostr struct {
|
||||||
actors []actor.Profile
|
actors []actor.Profile
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
|
lastLimit int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeNostr) GetPost(ctx context.Context, id string) (*nostr.Post, error) {
|
func (f *fakeNostr) GetPost(ctx context.Context, id string) (*nostr.Post, error) {
|
||||||
|
|
@ -169,6 +176,7 @@ 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) {
|
func (f *fakeNostr) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
|
f.lastLimit = limit
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
|
|
@ -220,17 +228,74 @@ func (f *fakeTwitter) SearchActors(ctx context.Context, query string, limit int)
|
||||||
return f.actors, nil
|
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 ----
|
// ---- helpers ----
|
||||||
|
|
||||||
func newTestServer(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend) *Server {
|
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{
|
return &Server{
|
||||||
bskyClient: bsky,
|
bskyClient: bsky,
|
||||||
apClient: ap,
|
apClient: ap,
|
||||||
nostrClient: nstr,
|
nostrClient: nstr,
|
||||||
twitterClient: tw,
|
twitterClient: tw,
|
||||||
|
db: st,
|
||||||
|
ollama: oll,
|
||||||
enrichEnabled: false,
|
enrichEnabled: false,
|
||||||
enrichSem: make(chan struct{}, 4),
|
enrichSem: make(chan struct{}, 4),
|
||||||
postCache: newTTLCache(postCacheTTL),
|
postCache: newTTLCache(postCacheTTL),
|
||||||
|
|
||||||
|
analyticsTTLEngagement: time.Hour,
|
||||||
|
analyticsTTLPatterns: time.Hour,
|
||||||
|
analyticsTTLTopics: time.Hour,
|
||||||
|
analyticsTTLSentiment: time.Hour,
|
||||||
|
analyticsTTLDrivers: time.Hour,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,6 +319,20 @@ func callTool(s *Server, name string, args any) (*mcp.CallToolResult, error) {
|
||||||
return s.getPostsInTime(ctx, req)
|
return s.getPostsInTime(ctx, req)
|
||||||
case "getFeed":
|
case "getFeed":
|
||||||
return s.getFeed(ctx, req)
|
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)
|
return nil, fmt.Errorf("unknown tool %q", name)
|
||||||
}
|
}
|
||||||
|
|
@ -283,11 +362,11 @@ func bskyPostView(text string) *bsky.FeedDefs_PostView {
|
||||||
// ---- tests ----
|
// ---- tests ----
|
||||||
|
|
||||||
func TestGetActorDispatch(t *testing.T) {
|
func TestGetActorDispatch(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
author string
|
author string
|
||||||
fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend)
|
fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend)
|
||||||
profile *actor.Profile
|
profile *actor.Profile
|
||||||
}{
|
}{
|
||||||
{"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
{"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||||
return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}
|
return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}
|
||||||
}, &actor.Profile{Platform: "Bluesky"}},
|
}, &actor.Profile{Platform: "Bluesky"}},
|
||||||
|
|
@ -297,7 +376,7 @@ cases := []struct {
|
||||||
{"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
{"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||||
return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{}
|
return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{}
|
||||||
}, &actor.Profile{Platform: "Nostr"}},
|
}, &actor.Profile{Platform: "Nostr"}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
s := newTestServer(c.fake())
|
s := newTestServer(c.fake())
|
||||||
res, err := callTool(s, "getActor", GetActorRequest{Author: c.author})
|
res, err := callTool(s, "getActor", GetActorRequest{Author: c.author})
|
||||||
|
|
@ -431,80 +510,348 @@ func TestGetFeedBluesky(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPostCacheHitOnce(t *testing.T) {
|
// ---- getPost success paths ----
|
||||||
tw := &fakeTwitter{post: &twitter.Post{ID: "123", Content: "cached", Author: "@alice"}}
|
|
||||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
|
func TestGetPostBlueskyWithCollection(t *testing.T) {
|
||||||
_, err := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"})
|
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky with collection")}
|
||||||
if err == nil {
|
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||||
t.Fatalf("expected twitter_unavailable error, got nil")
|
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(err.Error(), "twitter_unavailable") {
|
if !strings.Contains(textOf(t, res), "hello bluesky with collection") {
|
||||||
t.Fatalf("expected twitter_unavailable error, got: %v", err)
|
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
|
||||||
}
|
}
|
||||||
_, err2 := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"})
|
if bsky.calls != 1 {
|
||||||
if err2 == nil {
|
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||||
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 backend to not be called due to policy block, got %d", tw.calls)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchActorsDedup(t *testing.T) {
|
func TestGetPostActivityPub(t *testing.T) {
|
||||||
bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "@alice", DisplayName: "Alice"}}}
|
ap := &fakeAP{post: &activitypub.Post{Author: "alice@mastodon.social", Content: "hello activitypub", CreatedAt: time.Now()}}
|
||||||
ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}}}
|
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||||
nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "bob", DisplayName: "Bob"}}}
|
res, err := callTool(s, "getPost", GetPostRequest{Author: "@alice@mastodon.social", RecordKey: "123"})
|
||||||
tw := &fakeTwitter{actors: []actor.Profile{{Platform: "Twitter", Handle: "@carol", DisplayName: "Carol"}}}
|
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"}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if tw.calls != 0 {
|
||||||
|
t.Fatalf("expected 0 backend calls, 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"}}}
|
||||||
s := newTestServer(bsky, ap, nstr, tw)
|
s := newTestServer(bsky, ap, nstr, tw)
|
||||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice", Limit: 10})
|
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 10})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("searchActors error: %v", err)
|
t.Fatalf("searchActors error: %v", err)
|
||||||
}
|
}
|
||||||
var out SearchActorsResponse
|
var out SearchActorsResponse
|
||||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
// @alice and alice@mastodon.social normalize to the same handle and should be deduped.
|
// 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
|
||||||
if len(out.Results) != 2 {
|
if len(out.Results) != 2 {
|
||||||
t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results)
|
t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSettleHook(t *testing.T) {
|
// ---- cache tests ----
|
||||||
// Test that settleHook correctly calls SaveTransaction
|
|
||||||
// This test verifies the function signature and basic behavior
|
|
||||||
// by checking that SaveTransaction params are correctly mapped
|
|
||||||
|
|
||||||
// Verify SaveTransactionParams fields map correctly to SQL columns
|
func TestPostCacheDifferentKeys(t *testing.T) {
|
||||||
params := store.SaveTransactionParams{
|
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "post1", CreatedAt: time.Now()}}}
|
||||||
X402TransactionHash: "0x1234abcd",
|
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||||
Amount: "1000",
|
// Different authors should not share cache
|
||||||
Asset: "USDC",
|
_, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
|
||||||
Network: "eip155:1",
|
if err != nil {
|
||||||
Recipient: "0xrecipient",
|
t.Fatalf("getLatestPosts error: %v", err)
|
||||||
Sender: "0xsender",
|
|
||||||
}
|
}
|
||||||
|
_, err = callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@b@mastodon.social", Limit: 5})
|
||||||
// Verify all fields are settable without panic
|
if err != nil {
|
||||||
if params.X402TransactionHash != "0x1234abcd" {
|
t.Fatalf("getLatestPosts error for different author: %v", err)
|
||||||
t.Fatalf("X402TransactionHash not preserved")
|
|
||||||
}
|
}
|
||||||
if params.Amount != "1000" {
|
// Each author should have called backend once
|
||||||
t.Fatalf("Amount not preserved: %s", params.Amount)
|
if ap.calls != 2 {
|
||||||
}
|
t.Fatalf("expected 2 backend calls for different authors, got %d", ap.calls)
|
||||||
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 ----
|
||||||
|
|
|
||||||
84
internal/server/searchactors_test.go
Normal file
84
internal/server/searchactors_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ type Server struct {
|
||||||
listenAddress string
|
listenAddress string
|
||||||
permissions os.FileMode
|
permissions os.FileMode
|
||||||
mcpHandler *x402.X402Server
|
mcpHandler *x402.X402Server
|
||||||
db *store.Queries
|
db Store
|
||||||
apClient apBackend
|
apClient apBackend
|
||||||
bskyClient blueskyBackend
|
bskyClient blueskyBackend
|
||||||
nostrClient nostrBackend
|
nostrClient nostrBackend
|
||||||
|
|
@ -367,7 +367,11 @@ func (s *Server) registerTools() {
|
||||||
OutputSchema: GetStyleProfileResponse{},
|
OutputSchema: GetStyleProfileResponse{},
|
||||||
},
|
},
|
||||||
s.getStyleProfile,
|
s.getStyleProfile,
|
||||||
s.buildRequirements("0.10", extra)...,
|
// 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)...,
|
||||||
)
|
)
|
||||||
|
|
||||||
extra = map[string]any{
|
extra = map[string]any{
|
||||||
|
|
@ -738,14 +742,17 @@ func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolReques
|
||||||
if args.Limit <= 0 {
|
if args.Limit <= 0 {
|
||||||
return nil, fmt.Errorf("n must be a positive integer")
|
return nil, fmt.Errorf("n must be a positive integer")
|
||||||
}
|
}
|
||||||
limit := min(args.Limit, 50)
|
if args.Limit > 50 {
|
||||||
|
return nil, fmt.Errorf("n must be at most 50")
|
||||||
|
}
|
||||||
|
limit := args.Limit
|
||||||
startTime, err := time.Parse(time.RFC3339, args.StartTime)
|
startTime, err := time.Parse(time.RFC3339, args.StartTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
||||||
}
|
}
|
||||||
endTime, err := time.Parse(time.RFC3339, args.EndTime)
|
endTime, err := time.Parse(time.RFC3339, args.EndTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
return nil, fmt.Errorf("endTime must be in RFC3339 format")
|
||||||
}
|
}
|
||||||
if startTime.After(endTime) {
|
if startTime.After(endTime) {
|
||||||
tmp := startTime
|
tmp := startTime
|
||||||
|
|
|
||||||
214
internal/server/storefake_test.go
Normal file
214
internal/server/storefake_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
264
internal/server/styleprofile_test.go
Normal file
264
internal/server/styleprofile_test.go
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/server/twitter_analytics_test.go
Normal file
162
internal/server/twitter_analytics_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
261
internal/server/validation_test.go
Normal file
261
internal/server/validation_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: AnalyticsCache.sql
|
// source: AnalyticsCache.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: AnalyticsCoverage.sql
|
// source: AnalyticsCoverage.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
@ -10,46 +10,83 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const getAnalyticsCoverage = `-- name: GetAnalyticsCoverage :one
|
const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec
|
||||||
SELECT covered_start, covered_end FROM analytics_coverage
|
DELETE FROM analytics_coverage_ranges
|
||||||
WHERE user_id = $1 AND platform = $2
|
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetAnalyticsCoverageParams struct {
|
type DeleteAnalyticsCoverageRangeParams struct {
|
||||||
UserID string
|
|
||||||
Platform string
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetAnalyticsCoverageRow struct {
|
|
||||||
CoveredStart time.Time
|
|
||||||
CoveredEnd time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
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 UpsertAnalyticsCoverageParams struct {
|
|
||||||
UserID string
|
UserID string
|
||||||
Platform string
|
Platform string
|
||||||
CoveredStart time.Time
|
CoveredStart time.Time
|
||||||
CoveredEnd time.Time
|
CoveredEnd time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertAnalyticsCoverage(ctx context.Context, arg UpsertAnalyticsCoverageParams) error {
|
func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, upsertAnalyticsCoverage,
|
_, 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
|
||||||
|
WHERE user_id = $1 AND platform = $2
|
||||||
|
ORDER BY covered_start
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAnalyticsCoverageRangesParams struct {
|
||||||
|
UserID string
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetAnalyticsCoverageRangesRow 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertAnalyticsCoverageRange = `-- name: InsertAnalyticsCoverageRange :exec
|
||||||
|
INSERT INTO analytics_coverage_ranges (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()
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertAnalyticsCoverageRangeParams 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,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
arg.Platform,
|
arg.Platform,
|
||||||
arg.CoveredStart,
|
arg.CoveredStart,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: AnalyticsPosts.sql
|
// source: AnalyticsPosts.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: DeleteStylePosts.sql
|
// source: DeleteStylePosts.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: FindSimilarUsers.sql
|
// source: FindSimilarUsers.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: GetStylePostByID.sql
|
// source: GetStylePostByID.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: GetStylePosts.sql
|
// source: GetStylePosts.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: GetStylePostsByIDs.sql
|
// source: GetStylePostsByIDs.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: GetStyleProfile.sql
|
// source: GetStyleProfile.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: PruneStylePosts.sql
|
// source: PruneStylePosts.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: SaveTransaction.sql
|
// source: SaveTransaction.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: UpsertStylePosts.sql
|
// source: UpsertStylePosts.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: UpsertStyleProfiles.sql
|
// source: UpsertStyleProfiles.sql
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,72 +71,6 @@ 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.
|
// TestStoreSaveTransaction exercises SaveTransaction against a real Postgres database.
|
||||||
// It is skipped unless TEST_DATABASE_URL is set.
|
// It is skipped unless TEST_DATABASE_URL is set.
|
||||||
func TestStoreSaveTransaction(t *testing.T) {
|
func TestStoreSaveTransaction(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package store
|
package store
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ type AnalyticsCache struct {
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsCoverage struct {
|
type AnalyticsCoverageRange struct {
|
||||||
UserID string
|
UserID string
|
||||||
Platform string
|
Platform string
|
||||||
CoveredStart time.Time
|
CoveredStart time.Time
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ import (
|
||||||
log "github.com/nacorid/logger"
|
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"
|
const apiBase = "https://api.x.com/2"
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
|
@ -222,7 +231,7 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
||||||
|
|
||||||
posts := make([]*Post, 0, len(tr.Data))
|
posts := make([]*Post, 0, len(tr.Data))
|
||||||
for _, t := range tr.Data {
|
for _, t := range tr.Data {
|
||||||
created, _ := time.Parse(time.RFC3339, t.CreatedAt)
|
created := parseTwitterTime(t.CreatedAt)
|
||||||
posts = append(posts, &Post{
|
posts = append(posts, &Post{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Author: userID,
|
Author: userID,
|
||||||
|
|
@ -271,7 +280,7 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
||||||
|
|
||||||
posts := make([]*Post, 0, len(tr.Data))
|
posts := make([]*Post, 0, len(tr.Data))
|
||||||
for _, t := range tr.Data {
|
for _, t := range tr.Data {
|
||||||
created, _ := time.Parse(time.RFC3339, t.CreatedAt)
|
created := parseTwitterTime(t.CreatedAt)
|
||||||
posts = append(posts, &Post{
|
posts = append(posts, &Post{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Author: userID,
|
Author: userID,
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,211 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
|
|
@ -216,7 +217,7 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool
|
||||||
if toolName == "getStyleProfile" {
|
if toolName == "getStyleProfile" {
|
||||||
actualCost := computeTwitterStyleProfileCost(r, textContent.Text)
|
actualCost := computeTwitterStyleProfileCost(r, textContent.Text)
|
||||||
nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{
|
nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{
|
||||||
Amount: fmt.Sprintf("%d", actualCost),
|
Amount: actualCost,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
w.Write([]byte(textContent.Text))
|
w.Write([]byte(textContent.Text))
|
||||||
|
|
@ -401,55 +402,68 @@ func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeTwitterStyleProfileCost calculates the actual charge for a Twitter getStyleProfile
|
// Twitter API costs are itemized below. Every getStyleProfile request, for any
|
||||||
// request based on the number of posts fetched and user lookup.
|
// platform, also carries the flat base cost of the endpoint (the same price a
|
||||||
// Pricing: user read $0.01, post read $0.005 each, minimum $0.10, maximum $0.26.
|
// non-Twitter platform pays). Adding that base cost on top of the itemized
|
||||||
func computeTwitterStyleProfileCost(r *http.Request, responseText string) int {
|
// 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 {
|
||||||
// Parse the response to get PostCount and SourcePlatform
|
// Parse the response to get PostCount and SourcePlatform
|
||||||
var resp GetStyleProfileResponse
|
var resp GetStyleProfileResponse
|
||||||
if err := json.Unmarshal([]byte(responseText), &resp); err != nil {
|
if err := json.Unmarshal([]byte(responseText), &resp); err != nil {
|
||||||
return 10 // fallback: 10 cents = $0.10 minimum
|
return formatDollarAmount(baseProfileCostMicro) // fallback: base cost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if Twitter platform
|
costMicro := baseProfileCostMicro
|
||||||
isTwitter := resp.SourcePlatform == "Twitter"
|
|
||||||
|
|
||||||
// Extract post count from response
|
// Twitter additionally passes on the user lookup and per-post API costs.
|
||||||
|
if resp.SourcePlatform == "Twitter" {
|
||||||
postCount := resp.PostCount
|
postCount := resp.PostCount
|
||||||
if postCount <= 0 {
|
if postCount < 0 {
|
||||||
postCount = 0
|
postCount = 0
|
||||||
}
|
}
|
||||||
|
costMicro += twitterUserLookupMicro
|
||||||
// Calculate cost:
|
costMicro += int64(postCount) * twitterPostReadMicro
|
||||||
// - 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
|
return formatDollarAmount(costMicro)
|
||||||
// - Maximum: $0.26
|
}
|
||||||
var costCents int
|
|
||||||
|
// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed
|
||||||
if isTwitter {
|
// price string with at least two decimal places and trailing zeroes trimmed
|
||||||
// Twitter: user read + posts read
|
// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402
|
||||||
userCents := 1 // $0.01
|
// settlement override resolver expects.
|
||||||
postCents := postCount * 5 // $0.005 per post = 5 cents per post in atomic units
|
func formatDollarAmount(micro int64) string {
|
||||||
if postCents > 125 { // 50 posts * 5 = 25 cents max
|
dollars := float64(micro) / 1_000_000
|
||||||
postCents = 125
|
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)
|
||||||
}
|
}
|
||||||
costCents = userCents + postCents
|
|
||||||
} else {
|
} else {
|
||||||
costCents = 10 // $0.10 minimum for non-Twitter
|
s += ".00"
|
||||||
}
|
}
|
||||||
|
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
|
// GetStyleProfileResponse is a local copy of the response schema for cost calculation
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package x402
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -173,6 +175,56 @@ 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 {
|
func setupMockFacilitator() *httptest.Server {
|
||||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -206,3 +258,190 @@ func setupMockFacilitator() *httptest.Server {
|
||||||
}))
|
}))
|
||||||
return s
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
-- name: GetAnalyticsCoverage :one
|
-- name: GetAnalyticsCoverageRanges :many
|
||||||
SELECT covered_start, covered_end FROM analytics_coverage
|
SELECT covered_start, covered_end FROM analytics_coverage_ranges
|
||||||
WHERE user_id = $1 AND platform = $2;
|
WHERE user_id = $1 AND platform = $2
|
||||||
|
ORDER BY covered_start;
|
||||||
|
|
||||||
-- name: UpsertAnalyticsCoverage :exec
|
-- name: InsertAnalyticsCoverageRange :exec
|
||||||
INSERT INTO analytics_coverage (user_id, platform, covered_start, covered_end, updated_at)
|
INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, now())
|
VALUES ($1, $2, $3, $4, now())
|
||||||
ON CONFLICT (user_id, platform)
|
ON CONFLICT (user_id, platform, covered_start, covered_end)
|
||||||
DO UPDATE SET covered_start = LEAST(analytics_coverage.covered_start, EXCLUDED.covered_start),
|
DO UPDATE SET updated_at = now();
|
||||||
covered_end = GREATEST(analytics_coverage.covered_end, EXCLUDED.covered_end),
|
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
-- +goose up
|
-- +goose up
|
||||||
CREATE TABLE IF NOT EXISTS analytics_coverage (
|
-- 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,
|
user_id TEXT NOT NULL,
|
||||||
platform TEXT NOT NULL,
|
platform TEXT NOT NULL,
|
||||||
covered_start TIMESTAMPTZ NOT NULL,
|
covered_start TIMESTAMPTZ NOT NULL,
|
||||||
covered_end TIMESTAMPTZ NOT NULL,
|
covered_end TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
PRIMARY KEY (user_id, platform)
|
PRIMARY KEY (user_id, platform, covered_start, covered_end)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_coverage_ranges_lookup
|
||||||
|
ON analytics_coverage_ranges (user_id, platform, covered_start);
|
||||||
|
|
||||||
-- +goose down
|
-- +goose down
|
||||||
DROP TABLE IF EXISTS analytics_coverage;
|
DROP INDEX IF EXISTS idx_analytics_coverage_ranges_lookup;
|
||||||
|
DROP TABLE IF EXISTS analytics_coverage_ranges;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue