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:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
branches: [ main, ai-main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: CI/CD Go Verification
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
branches: [ main, dev, ai-main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package activitypub
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -98,13 +99,30 @@ func TestGetLatestNPostsHTTPError(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPostsInTimeframe(t *testing.T) {
|
||||
// Feed is reverse-chronological: newer than `end`, two in-window, then older
|
||||
// than `start`. The client must skip the too-new post, return both in-window
|
||||
// posts, and stop when it crosses the `start` boundary.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("max_id") != "" {
|
||||
fmt.Fprint(w, `[]`)
|
||||
return
|
||||
feed := []map[string]any{}
|
||||
switch r.URL.Query().Get("max_id") {
|
||||
case "":
|
||||
// Page 1: too-new post and one in-window post.
|
||||
feed = []map[string]any{
|
||||
{"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
{"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
}
|
||||
case "4":
|
||||
// Page 2: in-window post then too-old post.
|
||||
feed = []map[string]any{
|
||||
{"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
{"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(feed); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
fmt.Fprint(w, `[{"id":"1","content":"timed","text":"timed","created_at":"2024-06-01T00:00:00Z","account":{"acct":"alice"}}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
|
@ -116,11 +134,11 @@ func TestGetPostsInTimeframe(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetPostsInTimeframe error: %v", err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 post, got %d", len(posts))
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected 2 in-window posts, got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "timed" || posts[0].Author != "alice" {
|
||||
t.Fatalf("unexpected post: %+v", posts[0])
|
||||
if posts[0].Content != "mid" || posts[1].Content != "early-mid" {
|
||||
t.Fatalf("unexpected posts: %+v", posts)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
)
|
||||
|
||||
type BlueskyClient struct {
|
||||
|
|
@ -146,7 +146,9 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s
|
|||
}
|
||||
|
||||
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)
|
||||
if !ok {
|
||||
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 == "" {
|
||||
break
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/atproto"
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
|
|
@ -109,6 +110,126 @@ func setupMockServer() *httptest.Server {
|
|||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for bsky.FeedGetPosts (used by GetPost)
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
|
||||
uris := r.URL.Query()["uris"]
|
||||
if len(uris) == 0 {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "uris parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
posts := make([]*bsky.FeedDefs_PostView, 0, len(uris))
|
||||
for _, uri := range uris {
|
||||
posts = append(posts, &bsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Cid: "bafy...",
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:test",
|
||||
Handle: "test.handle",
|
||||
},
|
||||
Record: &util.LexiconTypeDecoder{
|
||||
Val: &bsky.FeedPost{
|
||||
Text: "Test post content",
|
||||
CreatedAt: "2023-10-27T12:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
resp := bsky.FeedGetPosts_Output{
|
||||
Posts: posts,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for app.bsky.actor.getProfile (used by GetActor)
|
||||
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
|
||||
actor := r.URL.Query().Get("actor")
|
||||
if actor == "" {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "actor parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"did": "did:plc:test",
|
||||
"handle": actor,
|
||||
"displayName": "Test User",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"description": "Test bio",
|
||||
"followersCount": 100,
|
||||
"followsCount": 50,
|
||||
"postsCount": 200,
|
||||
"url": "https://bsky.app/profile/" + actor,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for app.bsky.actor.searchActors (used by SearchActors)
|
||||
mux.HandleFunc("/xrpc/app.bsky.actor.searchActors", func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 10
|
||||
if limitStr != "" {
|
||||
limit, _ = strconv.Atoi(limitStr)
|
||||
}
|
||||
|
||||
if query == "" {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "q parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
actors := make([]map[string]any, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
actors = append(actors, map[string]any{
|
||||
"did": fmt.Sprintf("did:plc:test%d", i),
|
||||
"handle": fmt.Sprintf("%s%d.test", query, i),
|
||||
"displayName": fmt.Sprintf("Test User %d", i),
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"description": fmt.Sprintf("Bio for user %d", i),
|
||||
"url": fmt.Sprintf("https://bsky.app/profile/%s%d.test", query, i),
|
||||
})
|
||||
}
|
||||
|
||||
resp := map[string]any{"actors": actors}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for com.atproto.identity.resolveHandle (used by ResolveUser)
|
||||
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
|
||||
handle := r.URL.Query().Get("handle")
|
||||
if handle == "" {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := atproto.IdentityResolveHandle_Output{
|
||||
Did: "did:plc:resolved",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for com.atproto.server.refreshSession (used by withRefresh)
|
||||
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check for Authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := atproto.ServerRefreshSession_Output{
|
||||
AccessJwt: "refreshed_access_jwt",
|
||||
RefreshJwt: "refreshed_refresh_jwt",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
|
|
@ -227,3 +348,264 @@ func TestGetFeed(t *testing.T) {
|
|||
t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPost(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
postURI := "at://did:plc:testactor/app.bsky.feed.post/123"
|
||||
post, err := client.GetPost(ctx, "did:plc:testactor", "", "123")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if post == nil {
|
||||
t.Fatal("Expected post not to be nil")
|
||||
}
|
||||
|
||||
if post.Uri != postURI {
|
||||
t.Errorf("Expected post URI %q, got %q", postURI, post.Uri)
|
||||
}
|
||||
|
||||
if post.Author.Handle != "test.handle" {
|
||||
t.Errorf("Expected author handle 'test.handle', got %q", post.Author.Handle)
|
||||
}
|
||||
|
||||
// Test with custom collection
|
||||
post2, err := client.GetPost(ctx, "did:plc:testactor", "app.bsky.feed.post", "456")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with custom collection, got %v", err)
|
||||
}
|
||||
if post2 == nil {
|
||||
t.Fatal("Expected post not to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframe(t *testing.T) {
|
||||
// The mock author-feed returns two pages: the first contains out-of-range posts,
|
||||
// then in-range posts, verifying both date filtering and cursor pagination.
|
||||
requests := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(atproto.ServerCreateSession_Output{Handle: "test.handle", Did: "did:plc:test"})
|
||||
})
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
||||
return &bsky.FeedDefs_FeedViewPost{
|
||||
Post: &bsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:testactor",
|
||||
Handle: "testactor.test",
|
||||
},
|
||||
Record: &util.LexiconTypeDecoder{
|
||||
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var feed []*bsky.FeedDefs_FeedViewPost
|
||||
var cursor string
|
||||
if requests == 1 {
|
||||
// Page 1: one out-of-range (before start), one out-of-range (after end), and a cursor.
|
||||
feed = []*bsky.FeedDefs_FeedViewPost{
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/4", "2024-01-01T00:00:00Z"),
|
||||
}
|
||||
cursor = "page-2-cursor"
|
||||
} else {
|
||||
// Page 2: two in-range posts and no cursor (end of feed).
|
||||
feed = []*bsky.FeedDefs_FeedViewPost{
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/2", "2023-08-01T00:00:00Z"),
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed, Cursor: &cursor})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
actor := "did:plc:testactor"
|
||||
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
limit := 10
|
||||
|
||||
posts, err := client.GetPostsInTimeframe(ctx, actor, start, end, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Only the two in-range posts must survive filtering; cursor pagination must
|
||||
// advance into page 2 to reach them.
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected 2 in-range posts, got %d", len(posts))
|
||||
}
|
||||
for _, post := range posts {
|
||||
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected record type")
|
||||
}
|
||||
createdAt, _ := time.Parse(time.RFC3339, rec.CreatedAt)
|
||||
if createdAt.Before(start) || createdAt.After(end) {
|
||||
t.Errorf("Post %s returned outside time range", post.Post.Uri)
|
||||
}
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Errorf("expected pagination to make 2 feed requests, got %d", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActor(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
actor := "test.handle"
|
||||
profile, err := client.GetActor(ctx, actor)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
t.Fatal("Expected profile not to be nil")
|
||||
}
|
||||
|
||||
if profile.Handle != actor {
|
||||
t.Errorf("Expected handle %q, got %q", actor, profile.Handle)
|
||||
}
|
||||
if profile.DisplayName != "Test User" {
|
||||
t.Errorf("Expected display name 'Test User', got %q", profile.DisplayName)
|
||||
}
|
||||
if profile.Platform != "Bluesky" {
|
||||
t.Errorf("Expected platform 'Bluesky', got %q", profile.Platform)
|
||||
}
|
||||
if profile.FollowersCount != 100 || profile.FollowingCount != 50 || profile.PostCount != 200 {
|
||||
t.Errorf("Unexpected counts: followers=%d, following=%d, posts=%d", profile.FollowersCount, profile.FollowingCount, profile.PostCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActors(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
query := "alice"
|
||||
limit := 5
|
||||
|
||||
results, err := client.SearchActors(ctx, query, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
t.Fatal("Expected results not to be nil")
|
||||
}
|
||||
|
||||
if len(results) != limit {
|
||||
t.Errorf("Expected %d results, got %d", limit, len(results))
|
||||
}
|
||||
|
||||
for i, result := range results {
|
||||
expectedHandle := fmt.Sprintf("%s%d.test", query, i)
|
||||
if result.Handle != expectedHandle {
|
||||
t.Errorf("Result %d: expected handle %q, got %q", i, expectedHandle, result.Handle)
|
||||
}
|
||||
if result.Platform != "Bluesky" {
|
||||
t.Errorf("Result %d: expected platform 'Bluesky', got %q", i, result.Platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUser(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with handle
|
||||
did, err := client.ResolveUser(ctx, "test.handle")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error resolving handle, got %v", err)
|
||||
}
|
||||
if did.String() != "did:plc:resolved" {
|
||||
t.Errorf("Expected resolved DID 'did:plc:resolved', got %q", did.String())
|
||||
}
|
||||
|
||||
// Test with DID directly
|
||||
did2, err := client.ResolveUser(ctx, "did:plc:direct")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with direct DID, got %v", err)
|
||||
}
|
||||
if did2.String() != "did:plc:direct" {
|
||||
t.Errorf("Expected same DID 'did:plc:direct', got %q", did2.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFeedURI(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "AT URI",
|
||||
input: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
||||
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Web URI with profile/feed",
|
||||
input: "https://bsky.app/profile/did:plc:abc/feed/xyz",
|
||||
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid URI",
|
||||
input: "not-a-uri",
|
||||
expected: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := client.sanitizeFeedURI(ctx, tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error for input %q, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error for input %q: %v", tc.input, err)
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Input %q: expected %q, got %q", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
items := []Item{
|
||||
{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) {
|
||||
tests := []struct {
|
||||
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"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
|
||||
"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)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
connected := false
|
||||
|
||||
for _, url := range c.relays {
|
||||
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
connected = true
|
||||
sub, err := relay.Subscribe(ctx, nostr.Filter{
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Kinds: []nostr.Kind{1},
|
||||
|
|
@ -53,6 +56,8 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
|||
relay.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
func() {
|
||||
defer sub.Unsub()
|
||||
defer relay.Close()
|
||||
|
||||
|
|
@ -80,12 +85,16 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
|||
break collect
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if len(posts) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !connected {
|
||||
return nil, fmt.Errorf("could not connect to any relay")
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
|
|
@ -101,11 +110,14 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
|||
posts := make([]*Post, 0, limit)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
connected := false
|
||||
|
||||
for _, url := range c.relays {
|
||||
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
connected = true
|
||||
sub, err := relay.Subscribe(ctx, nostr.Filter{
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Kinds: []nostr.Kind{1},
|
||||
|
|
@ -117,6 +129,8 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
|||
relay.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
func() {
|
||||
defer sub.Unsub()
|
||||
defer relay.Close()
|
||||
|
||||
|
|
@ -144,12 +158,17 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
|||
break collect
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if len(posts) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !connected {
|
||||
return nil, fmt.Errorf("could not connect to any relay")
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,45 +4,228 @@ import (
|
|||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
)
|
||||
|
||||
func TestResolvePubkey(t *testing.T) {
|
||||
var pk nostr.PubKey
|
||||
func TestNewNostrClientEmptyRelays(t *testing.T) {
|
||||
_, err := NewNostrClient([]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty relays")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one relay") {
|
||||
t.Fatalf("expected 'at least one relay' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePubkeyVariants(t *testing.T) {
|
||||
// Create a valid pubkey for testing using Generate()
|
||||
sk := nostr.Generate()
|
||||
pk := sk.Public()
|
||||
npub := nip19.EncodeNpub(pk)
|
||||
if _, err := resolvePubkey(npub); err != nil {
|
||||
t.Fatalf("valid npub should resolve: %v", err)
|
||||
hex := pk.Hex()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"npub encoding", npub, false},
|
||||
{"64-char hex", hex, false},
|
||||
{"invalid npub prefix", "nsec" + npub[4:], true},
|
||||
{"too short hex", hex[:32], true},
|
||||
{"random string", "not-a-key", true},
|
||||
{"empty string", "", true},
|
||||
}
|
||||
if _, err := resolvePubkey("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) {
|
||||
// 64 hex chars with no '1' (which would otherwise be read as a bech32 separator)
|
||||
hex := strings.Repeat("deadbeef", 8)
|
||||
if _, err := resolveEventID(hex); err != nil {
|
||||
t.Fatalf("valid 64-hex event id should resolve: %v", err)
|
||||
func TestResolveEventIDVariants(t *testing.T) {
|
||||
var eid nostr.ID
|
||||
for i := range eid {
|
||||
eid[i] = byte(i + 1)
|
||||
}
|
||||
if _, err := resolveEventID("not-an-id"); err == nil {
|
||||
t.Fatal("expected error for invalid event id")
|
||||
hex := eid.Hex()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"64-char hex", hex, false},
|
||||
{"too short hex", hex[:32], true},
|
||||
{"random string", "not-an-id", true},
|
||||
{"empty string", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := resolveEventID(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tc.input)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tc.input, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SearchActors is best-effort on Nostr: a query that is neither an npub nor a 64-hex pubkey
|
||||
// maps to nothing, but must return an empty slice (not an error) without any network call.
|
||||
func TestSearchActorsBestEffort(t *testing.T) {
|
||||
func TestGetPostInvalidID(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results, err := c.SearchActors(context.Background(), "not-a-real-query", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
|
||||
_, err = c.GetPost(context.Background(), "invalid-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid event ID")
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected empty results, got %d", len(results))
|
||||
if !strings.Contains(err.Error(), "invalid nostr event id") {
|
||||
t.Fatalf("expected 'invalid nostr event id' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorInvalidAuthor(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
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) {
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package server
|
|||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -37,14 +36,18 @@ func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, err
|
|||
start := now.AddDate(0, 0, -defaultAnalyticsDays)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
if startStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
||||
start = t
|
||||
t, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err)
|
||||
}
|
||||
start = t
|
||||
}
|
||||
if start.After(end) {
|
||||
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)
|
||||
|
||||
func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, toolName string, compute analyticsComputer) (*mcp.CallToolResult, error) {
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
|
||||
// Normalize args
|
||||
limit := args.Limit
|
||||
if limit <= 0 {
|
||||
|
|
@ -150,7 +157,10 @@ func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, to
|
|||
if args.Platform != "" {
|
||||
platforms = []string{args.Platform}
|
||||
} 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)
|
||||
|
|
@ -175,7 +185,7 @@ func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, to
|
|||
for _, p := range platforms {
|
||||
plat, ok := platformFromString(p)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("unsupported platform %q", p)
|
||||
}
|
||||
posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit)
|
||||
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,
|
||||
// fetching from the Twitter API only the sub-ranges not already covered by the
|
||||
// persistent post cache, and storing any newly fetched posts for future use.
|
||||
//
|
||||
// Coverage is tracked as a set of discrete intervals, so disjoint fetched
|
||||
// ranges (e.g. [Dec 1, Dec 15] and [Dec 20, Dec 31]) are not merged into one
|
||||
// continuous window. A later request that lands in the never-fetched gap
|
||||
// (e.g. [Dec 15, Dec 20]) is therefore fetched from the API rather than being
|
||||
// wrongly served from an incomplete cache.
|
||||
func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||
coverage, err := s.getAnalyticsCoverage(ctx, author, "Twitter")
|
||||
covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter")
|
||||
if err != nil {
|
||||
// Unknown coverage (missing row) is not an error; fall back to fetching.
|
||||
if err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
coverageStart, coverageEnd := time.Time{}, time.Time{}
|
||||
haveCoverage := !coverage.CoveredStart.IsZero() && !coverage.CoveredEnd.IsZero()
|
||||
if haveCoverage {
|
||||
coverageStart, coverageEnd = coverage.CoveredStart, coverage.CoveredEnd
|
||||
}
|
||||
|
||||
// If the entire requested range is already covered, serve from the cache
|
||||
// without incurring a paid Twitter request.
|
||||
if haveCoverage && !start.Before(coverageStart) && !end.After(coverageEnd) {
|
||||
// If the entire requested range falls within already-covered intervals,
|
||||
// serve from the cache without incurring a paid Twitter request.
|
||||
if len(uncoveredRanges(start, end, covered)) == 0 {
|
||||
return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
|
||||
}
|
||||
|
||||
// Determine the sub-ranges of [start, end] not yet covered, and fetch each
|
||||
// from the API. Fetching per uncovered range keeps costs bounded while
|
||||
// expanding coverage on every call.
|
||||
ranges := uncoveredRanges(start, end, coverageStart, coverageEnd, haveCoverage)
|
||||
ranges := uncoveredRanges(start, end, covered)
|
||||
|
||||
var fetched []clustering.Item
|
||||
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.
|
||||
if err := s.extendAnalyticsCoverage(ctx, author, "Twitter", start, end); err != nil {
|
||||
log.WarnWithContext(ctx, "failed to extend analytics coverage", "platform", "Twitter", "error", err)
|
||||
// Record the newly fetched intervals as covered so future calls can reuse
|
||||
// them without hitting the paid API.
|
||||
for _, r := range ranges {
|
||||
if err := s.addAnalyticsCoverageRange(ctx, author, "Twitter", r.start, r.end); err != nil {
|
||||
log.WarnWithContext(ctx, "failed to add analytics coverage range", "platform", "Twitter", "range", r, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the full set: posts from cache plus anything newly fetched, merged
|
||||
|
|
@ -388,33 +398,49 @@ type timeRange struct {
|
|||
start, end time.Time
|
||||
}
|
||||
|
||||
// uncoveredRanges returns the sub-ranges of [start, end] not covered by
|
||||
// [coveredStart, coveredEnd]. If there is no coverage, the whole range is
|
||||
// returned.
|
||||
func uncoveredRanges(start, end, coveredStart, coveredEnd time.Time, have bool) []timeRange {
|
||||
if !have {
|
||||
return []timeRange{{start: start, end: end}}
|
||||
}
|
||||
|
||||
// uncoveredRanges returns the sub-ranges of [start, end] that are not covered
|
||||
// by any of the given intervals. Intervals are assumed sorted by their start,
|
||||
// and no coverage (empty slice) yields the whole range as uncovered.
|
||||
func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange {
|
||||
var ranges []timeRange
|
||||
if start.Before(coveredStart) {
|
||||
ranges = append(ranges, timeRange{start: start, end: coveredStart})
|
||||
cursor := start
|
||||
for _, c := range covered {
|
||||
if c.end.Before(cursor) || c.end.Equal(cursor) {
|
||||
continue
|
||||
}
|
||||
if end.After(coveredEnd) {
|
||||
ranges = append(ranges, timeRange{start: coveredEnd, end: end})
|
||||
if c.start.After(end) {
|
||||
break
|
||||
}
|
||||
if c.start.After(cursor) {
|
||||
ranges = append(ranges, timeRange{start: cursor, end: c.start})
|
||||
}
|
||||
if c.end.After(cursor) {
|
||||
cursor = c.end
|
||||
}
|
||||
}
|
||||
if cursor.Before(end) {
|
||||
ranges = append(ranges, timeRange{start: cursor, end: end})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
func (s *Server) getAnalyticsCoverage(ctx context.Context, author, platform string) (store.GetAnalyticsCoverageRow, error) {
|
||||
return s.db.GetAnalyticsCoverage(ctx, store.GetAnalyticsCoverageParams{
|
||||
func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) {
|
||||
rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranges := make([]timeRange, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ranges = append(ranges, timeRange{start: r.CoveredStart, end: r.CoveredEnd})
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func (s *Server) extendAnalyticsCoverage(ctx context.Context, author, platform string, start, end time.Time) error {
|
||||
return s.db.UpsertAnalyticsCoverage(ctx, store.UpsertAnalyticsCoverageParams{
|
||||
func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error {
|
||||
return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
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
|
||||
// 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) {
|
||||
merged := make([]clustering.Item, 0, len(fetched))
|
||||
seen := make(map[string]struct{}, len(fetched))
|
||||
for _, p := range fetched {
|
||||
if len(merged) >= limit {
|
||||
break
|
||||
}
|
||||
if _, ok := seen[p.ID]; ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -463,6 +494,9 @@ func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start,
|
|||
return merged, nil
|
||||
}
|
||||
for _, p := range cached {
|
||||
if len(merged) >= limit {
|
||||
break
|
||||
}
|
||||
if _, ok := seen[p.ID]; ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -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) {
|
||||
topics, _, err := s.clusterPosts(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return TopicModelingResponse{
|
||||
Author: args.Author,
|
||||
Topics: topics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clusterPosts runs topic clustering over the posts and returns both the
|
||||
// resulting topics and a map from post ID to the label of the topic the post
|
||||
// belongs to. The membership map lets callers correlate topics with per-post
|
||||
// attributes (e.g. engagement) without repeating the clustering.
|
||||
func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]TopicCluster, map[string]string, error) {
|
||||
if len(posts) < minTopicCount {
|
||||
return TopicModelingResponse{Author: args.Author, Topics: []TopicCluster{}}, nil
|
||||
return []TopicCluster{}, map[string]string{}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
embeddings, err := s.ollama.EmbedTexts(ctx, items)
|
||||
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
|
||||
|
|
@ -627,10 +676,11 @@ func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest
|
|||
// Run KMeans
|
||||
results, err := clustering.KMeans(items, topicCount, 50, 42)
|
||||
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
|
||||
for _, cluster := range results {
|
||||
if len(cluster.Items) == 0 {
|
||||
|
|
@ -648,6 +698,7 @@ func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest
|
|||
|
||||
// Generate label using Ollama
|
||||
label := s.generateTopicLabel(ctx, samples)
|
||||
labelByCluster[cluster.Cluster] = label
|
||||
keywords := s.extractKeywords(cluster.Items)
|
||||
|
||||
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.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount })
|
||||
|
||||
return TopicModelingResponse{
|
||||
Author: args.Author,
|
||||
Topics: topics,
|
||||
}, nil
|
||||
return topics, membership, nil
|
||||
}
|
||||
|
||||
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 prevScores [3]float64
|
||||
|
||||
// Only posts with a timestamp are bucketed and contribute to sentiment, so
|
||||
// the overall ratios are normalized by that same count (not the total
|
||||
// number of fetched posts, some of which may lack a timestamp).
|
||||
timestampedPosts := 0
|
||||
for _, post := range posts {
|
||||
if !post.Timestamp.IsZero() {
|
||||
timestampedPosts++
|
||||
}
|
||||
}
|
||||
|
||||
// Process buckets in chronological order so sentiment shifts are detected correctly.
|
||||
buckets := make([]string, 0, len(bucketMap))
|
||||
for b := range bucketMap {
|
||||
|
|
@ -728,7 +796,7 @@ func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsReque
|
|||
prevScores = [3]float64{pos, neg, neu}
|
||||
}
|
||||
|
||||
totalPosts := float64(len(posts))
|
||||
totalPosts := float64(timestampedPosts)
|
||||
if totalPosts > 0 {
|
||||
overallPos /= 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) {
|
||||
// Topic modeling first
|
||||
topicResult, err := s.computeTopicModeling(ctx, args, posts)
|
||||
// Topic modeling first, getting post->topic membership so we can correlate
|
||||
// each topic with the engagement of its member posts.
|
||||
topics, membership, err := s.clusterPosts(ctx, posts)
|
||||
if err != nil {
|
||||
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
|
||||
for _, topic := range topicResp.Topics {
|
||||
// In real impl, correlate topic presence with engagement metrics
|
||||
for _, topic := range topics {
|
||||
values := topicEngagement[topic.Label]
|
||||
topicMean := mean(values)
|
||||
// Correlation is the normalized difference between the topic's mean
|
||||
// engagement and the overall baseline: positive when the topic draws
|
||||
// above-average engagement, negative when below. Bounded to [-1, 1].
|
||||
correlation := 0.0
|
||||
if denom := topicMean + baseline; denom > 0 {
|
||||
correlation = (topicMean - baseline) / denom
|
||||
if correlation > 1 {
|
||||
correlation = 1
|
||||
} else if correlation < -1 {
|
||||
correlation = -1
|
||||
}
|
||||
}
|
||||
drivers = append(drivers, EngagementDriver{
|
||||
Topic: topic.Label,
|
||||
AvgEngagement: 0.0,
|
||||
Correlation: 0.0,
|
||||
AvgEngagement: topicMean,
|
||||
Correlation: correlation,
|
||||
PostCount: topic.PostCount,
|
||||
SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))],
|
||||
})
|
||||
|
|
@ -796,6 +897,17 @@ func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsReq
|
|||
}, nil
|
||||
}
|
||||
|
||||
func mean(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
}
|
||||
|
||||
// ---- Helper functions ----
|
||||
|
||||
func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string {
|
||||
|
|
|
|||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"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/clustering"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
)
|
||||
|
||||
|
|
@ -53,3 +55,22 @@ type ollamaBackend interface {
|
|||
EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error)
|
||||
SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error)
|
||||
}
|
||||
|
||||
// Store abstracts the persistence layer the server depends on. *store.Queries satisfies it;
|
||||
// tests inject an in-memory fake so db-backed paths (style profiles, analytics, similar users)
|
||||
// can be exercised without a live Postgres.
|
||||
type Store interface {
|
||||
GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error)
|
||||
UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error
|
||||
GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error)
|
||||
UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error
|
||||
GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error)
|
||||
InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error
|
||||
GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error)
|
||||
UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error
|
||||
DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error
|
||||
GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error)
|
||||
UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error
|
||||
FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error)
|
||||
PruneStylePosts(ctx context.Context) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,30 +34,30 @@ func assertRanges(t *testing.T, got []timeRange, want ...timeRange) {
|
|||
|
||||
func TestUncoveredRangesNoCoverage(t *testing.T) {
|
||||
r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(r.start, r.end, time.Time{}, time.Time{}, false)
|
||||
got := uncoveredRanges(r.start, r.end, nil)
|
||||
assertRanges(t, got, r)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesFullyCovered(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
||||
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true)
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
assertRanges(t, got)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesLeftOnly(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
cov := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
want := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true)
|
||||
assertRanges(t, got, want)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesRightOnly(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
want := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true)
|
||||
assertRanges(t, got, want)
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +66,35 @@ func TestUncoveredRangesBothSides(t *testing.T) {
|
|||
cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
||||
wantLeft := rangeOf(t, "2026-01-01 00:00", "2026-01-10 00:00")
|
||||
wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, cov.start, cov.end, true)
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
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 (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
|
|
@ -29,6 +31,7 @@ type fakeBluesky struct {
|
|||
feedOut *bsky.FeedGetFeed_Output
|
||||
err error
|
||||
calls int
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
|
||||
|
|
@ -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) {
|
||||
f.calls++
|
||||
f.lastLimit = limit
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
|
|
@ -88,6 +92,7 @@ type fakeAP struct {
|
|||
actors []actor.Profile
|
||||
err error
|
||||
calls int
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
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) {
|
||||
f.calls++
|
||||
f.lastLimit = limit
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
|
|
@ -137,6 +143,7 @@ type fakeNostr struct {
|
|||
actors []actor.Profile
|
||||
err error
|
||||
calls int
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
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) {
|
||||
f.calls++
|
||||
f.lastLimit = limit
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
|
|
@ -220,17 +228,74 @@ func (f *fakeTwitter) SearchActors(ctx context.Context, query string, limit int)
|
|||
return f.actors, nil
|
||||
}
|
||||
|
||||
// fakeOllama implements ollamaBackend. Its behavior is driven by a function so
|
||||
// tests can simulate embedding, sentiment, and topic-label responses.
|
||||
type fakeOllama struct {
|
||||
embeddings [][]float64
|
||||
embedErr error
|
||||
summaries []string
|
||||
summaryIdx int
|
||||
summErr error
|
||||
embedCalls int
|
||||
summCalls int
|
||||
}
|
||||
|
||||
func (f *fakeOllama) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) {
|
||||
f.embedCalls++
|
||||
if f.embedErr != nil {
|
||||
return nil, f.embedErr
|
||||
}
|
||||
if f.embeddings != nil {
|
||||
return f.embeddings, nil
|
||||
}
|
||||
out := make([][]float64, len(posts))
|
||||
for i := range posts {
|
||||
out[i] = []float64{float64(i + 1), 0.5}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeOllama) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) {
|
||||
f.summCalls++
|
||||
if f.summErr != nil {
|
||||
return "", f.summErr
|
||||
}
|
||||
if f.summaries != nil {
|
||||
if f.summaryIdx >= len(f.summaries) {
|
||||
f.summaryIdx = len(f.summaries) - 1
|
||||
}
|
||||
out := f.summaries[f.summaryIdx]
|
||||
f.summaryIdx++
|
||||
return out, nil
|
||||
}
|
||||
return "summary", nil
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func newTestServer(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend) *Server {
|
||||
return newServerWith(bsky, ap, nstr, tw, newFakeStore(), &fakeOllama{})
|
||||
}
|
||||
|
||||
// newServerWith builds a Server with explicit store and ollama backends so tests
|
||||
// can drive db-backed tool paths (style profiles, analytics, similar users).
|
||||
func newServerWith(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend, st Store, oll ollamaBackend) *Server {
|
||||
return &Server{
|
||||
bskyClient: bsky,
|
||||
apClient: ap,
|
||||
nostrClient: nstr,
|
||||
twitterClient: tw,
|
||||
db: st,
|
||||
ollama: oll,
|
||||
enrichEnabled: false,
|
||||
enrichSem: make(chan struct{}, 4),
|
||||
postCache: newTTLCache(postCacheTTL),
|
||||
|
||||
analyticsTTLEngagement: time.Hour,
|
||||
analyticsTTLPatterns: time.Hour,
|
||||
analyticsTTLTopics: time.Hour,
|
||||
analyticsTTLSentiment: time.Hour,
|
||||
analyticsTTLDrivers: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +319,20 @@ func callTool(s *Server, name string, args any) (*mcp.CallToolResult, error) {
|
|||
return s.getPostsInTime(ctx, req)
|
||||
case "getFeed":
|
||||
return s.getFeed(ctx, req)
|
||||
case "getStyleProfile":
|
||||
return s.getStyleProfile(ctx, req)
|
||||
case "findSimilarUsers":
|
||||
return s.getSimilarProfiles(ctx, req)
|
||||
case "getEngagementMetrics":
|
||||
return s.getEngagementMetrics(ctx, req)
|
||||
case "getPostingPatterns":
|
||||
return s.getPostingPatterns(ctx, req)
|
||||
case "getTopicModeling":
|
||||
return s.getTopicModeling(ctx, req)
|
||||
case "getSentimentTrends":
|
||||
return s.getSentimentTrends(ctx, req)
|
||||
case "getEngagementDrivers":
|
||||
return s.getEngagementDrivers(ctx, req)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown tool %q", name)
|
||||
}
|
||||
|
|
@ -283,11 +362,11 @@ func bskyPostView(text string) *bsky.FeedDefs_PostView {
|
|||
// ---- tests ----
|
||||
|
||||
func TestGetActorDispatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
cases := []struct {
|
||||
author string
|
||||
fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend)
|
||||
profile *actor.Profile
|
||||
}{
|
||||
}{
|
||||
{"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||
return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}
|
||||
}, &actor.Profile{Platform: "Bluesky"}},
|
||||
|
|
@ -297,7 +376,7 @@ cases := []struct {
|
|||
{"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||
return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{}
|
||||
}, &actor.Profile{Platform: "Nostr"}},
|
||||
}
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := newTestServer(c.fake())
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: c.author})
|
||||
|
|
@ -431,80 +510,348 @@ func TestGetFeedBluesky(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPostCacheHitOnce(t *testing.T) {
|
||||
tw := &fakeTwitter{post: &twitter.Post{ID: "123", Content: "cached", Author: "@alice"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
|
||||
_, err := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected twitter_unavailable error, got nil")
|
||||
// ---- getPost success paths ----
|
||||
|
||||
func TestGetPostBlueskyWithCollection(t *testing.T) {
|
||||
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky with collection")}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k", Collection: "app.bsky.feed.post"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost bluesky with collection error: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "twitter_unavailable") {
|
||||
t.Fatalf("expected twitter_unavailable error, got: %v", err)
|
||||
if !strings.Contains(textOf(t, res), "hello bluesky with collection") {
|
||||
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
|
||||
}
|
||||
_, err2 := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"})
|
||||
if err2 == nil {
|
||||
t.Fatalf("expected twitter_unavailable error on second call, got nil")
|
||||
}
|
||||
if !strings.Contains(err2.Error(), "twitter_unavailable") {
|
||||
t.Fatalf("expected twitter_unavailable error on second call, got: %v", err2)
|
||||
}
|
||||
if tw.calls != 0 {
|
||||
t.Fatalf("expected backend to not be called due to policy block, got %d", tw.calls)
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsDedup(t *testing.T) {
|
||||
bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "@alice", DisplayName: "Alice"}}}
|
||||
ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}}}
|
||||
nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "bob", DisplayName: "Bob"}}}
|
||||
tw := &fakeTwitter{actors: []actor.Profile{{Platform: "Twitter", Handle: "@carol", DisplayName: "Carol"}}}
|
||||
func TestGetPostActivityPub(t *testing.T) {
|
||||
ap := &fakeAP{post: &activitypub.Post{Author: "alice@mastodon.social", Content: "hello activitypub", CreatedAt: time.Now()}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "@alice@mastodon.social", RecordKey: "123"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost activitypub error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "hello activitypub") {
|
||||
t.Fatalf("unexpected activitypub post text: %s", textOf(t, res))
|
||||
}
|
||||
if ap.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost nostr error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "nostr hello") {
|
||||
t.Fatalf("unexpected nostr post text: %s", textOf(t, res))
|
||||
}
|
||||
if nstr.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", nstr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostBackendError(t *testing.T) {
|
||||
bsky := &fakeBluesky{err: fmt.Errorf("backend failure")}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
_, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "backend failure") {
|
||||
t.Fatalf("expected backend error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getLatestPosts success paths ----
|
||||
|
||||
func TestGetLatestPostsBluesky(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky post 1")}, {Post: bskyPostView("bluesky post 2")}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts bluesky error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if !strings.Contains(out.Posts[0].Content, "bluesky post 1") {
|
||||
t.Fatalf("unexpected post content: %s", out.Posts[0].Content)
|
||||
}
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestPostsNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "n1", Author: "npub1x", Content: "nostr latest 1", CreatedAt: time.Now()}, {ID: "n2", Author: "npub1x", Content: "nostr latest 2", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts nostr error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if !strings.Contains(out.Posts[0].Content, "nostr latest") {
|
||||
t.Fatalf("unexpected post content: %s", out.Posts[0].Content)
|
||||
}
|
||||
if nstr.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", nstr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getPostsInTime success paths ----
|
||||
|
||||
func TestGetPostsInTimeBluesky(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky timed 1")}, {Post: bskyPostView("bluesky timed 2")}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getPostsInTime bluesky error: %v", err)
|
||||
}
|
||||
var out GetPostsInTimeResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeActivityPub(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "ap timed 1", CreatedAt: time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)}, {Author: "a@m.social", Content: "ap timed 2", CreatedAt: time.Date(2024, 7, 15, 0, 0, 0, 0, time.UTC)}}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "@a@mastodon.social", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getPostsInTime activitypub error: %v", err)
|
||||
}
|
||||
var out GetPostsInTimeResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if ap.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getFeed tests ----
|
||||
|
||||
func TestGetFeedBlueskyWithCursor(t *testing.T) {
|
||||
// Test that feed pagination works
|
||||
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post 1")}, {Post: bskyPostView("feed post 2")}}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getFeed error: %v", err)
|
||||
}
|
||||
var out GetFeedResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getActor tests ----
|
||||
|
||||
func TestGetActorBlueskyDID(t *testing.T) {
|
||||
bsky := &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "Test User"}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: "did:plc:abc123"})
|
||||
if err != nil {
|
||||
t.Fatalf("getActor bluesky DID error: %v", err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Actor.Platform != "Bluesky" {
|
||||
t.Fatalf("expected Bluesky platform, got %s", out.Actor.Platform)
|
||||
}
|
||||
if out.Actor.Handle != "user.bsky.social" {
|
||||
t.Fatalf("expected handle user.bsky.social, got %s", out.Actor.Handle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorActivityPub(t *testing.T) {
|
||||
ap := &fakeAP{profile: &actor.Profile{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: "@alice@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getActor activitypub error: %v", err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Actor.Platform != "ActivityPub" {
|
||||
t.Fatalf("expected ActivityPub platform, got %s", out.Actor.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{profile: &actor.Profile{Platform: "Nostr", Handle: "npub1xxx", DisplayName: "Nostr User"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"})
|
||||
if err != nil {
|
||||
t.Fatalf("getActor nostr error: %v", err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Actor.Platform != "Nostr" {
|
||||
t.Fatalf("expected Nostr platform, got %s", out.Actor.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorTwitter(t *testing.T) {
|
||||
// Twitter getActor is blocked by policy (same as other Twitter tools)
|
||||
tw := &fakeTwitter{profile: &actor.Profile{Platform: "Twitter", Handle: "@alice", DisplayName: "Alice Twitter"}}
|
||||
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)
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice", Limit: 10})
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("searchActors error: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// @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 {
|
||||
t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettleHook(t *testing.T) {
|
||||
// Test that settleHook correctly calls SaveTransaction
|
||||
// This test verifies the function signature and basic behavior
|
||||
// by checking that SaveTransaction params are correctly mapped
|
||||
// ---- cache tests ----
|
||||
|
||||
// Verify SaveTransactionParams fields map correctly to SQL columns
|
||||
params := store.SaveTransactionParams{
|
||||
X402TransactionHash: "0x1234abcd",
|
||||
Amount: "1000",
|
||||
Asset: "USDC",
|
||||
Network: "eip155:1",
|
||||
Recipient: "0xrecipient",
|
||||
Sender: "0xsender",
|
||||
func TestPostCacheDifferentKeys(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "post1", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
// Different authors should not share cache
|
||||
_, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error: %v", err)
|
||||
}
|
||||
|
||||
// Verify all fields are settable without panic
|
||||
if params.X402TransactionHash != "0x1234abcd" {
|
||||
t.Fatalf("X402TransactionHash not preserved")
|
||||
_, err = callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@b@mastodon.social", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error for different author: %v", err)
|
||||
}
|
||||
if params.Amount != "1000" {
|
||||
t.Fatalf("Amount not preserved: %s", params.Amount)
|
||||
}
|
||||
if params.Asset != "USDC" {
|
||||
t.Fatalf("Asset not preserved: %s", params.Asset)
|
||||
}
|
||||
if params.Network != "eip155:1" {
|
||||
t.Fatalf("Network not preserved: %s", params.Network)
|
||||
}
|
||||
if params.Recipient != "0xrecipient" {
|
||||
t.Fatalf("Recipient not preserved: %s", params.Recipient)
|
||||
}
|
||||
if params.Sender != "0xsender" {
|
||||
t.Fatalf("Sender not preserved: %s", params.Sender)
|
||||
// Each author should have called backend once
|
||||
if ap.calls != 2 {
|
||||
t.Fatalf("expected 2 backend calls for different authors, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
permissions os.FileMode
|
||||
mcpHandler *x402.X402Server
|
||||
db *store.Queries
|
||||
db Store
|
||||
apClient apBackend
|
||||
bskyClient blueskyBackend
|
||||
nostrClient nostrBackend
|
||||
|
|
@ -367,7 +367,11 @@ func (s *Server) registerTools() {
|
|||
OutputSchema: GetStyleProfileResponse{},
|
||||
},
|
||||
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{
|
||||
|
|
@ -738,14 +742,17 @@ func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolReques
|
|||
if args.Limit <= 0 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
||||
}
|
||||
endTime, err := time.Parse(time.RFC3339, args.EndTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
||||
return nil, fmt.Errorf("endTime must be in RFC3339 format")
|
||||
}
|
||||
if startTime.After(endTime) {
|
||||
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.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: AnalyticsCache.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: AnalyticsCoverage.sql
|
||||
|
||||
package store
|
||||
|
|
@ -10,46 +10,83 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const getAnalyticsCoverage = `-- name: GetAnalyticsCoverage :one
|
||||
SELECT covered_start, covered_end FROM analytics_coverage
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec
|
||||
DELETE FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4
|
||||
`
|
||||
|
||||
type GetAnalyticsCoverageParams 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 {
|
||||
type DeleteAnalyticsCoverageRangeParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
CoveredStart time.Time
|
||||
CoveredEnd time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAnalyticsCoverage(ctx context.Context, arg UpsertAnalyticsCoverageParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAnalyticsCoverage,
|
||||
func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAnalyticsCoverageRange,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.CoveredStart,
|
||||
arg.CoveredEnd,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAnalyticsCoverageRanges = `-- name: GetAnalyticsCoverageRanges :many
|
||||
SELECT covered_start, covered_end FROM analytics_coverage_ranges
|
||||
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.Platform,
|
||||
arg.CoveredStart,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: AnalyticsPosts.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: DeleteStylePosts.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: FindSimilarUsers.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: GetStylePostByID.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: GetStylePosts.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: GetStylePostsByIDs.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: GetStyleProfile.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: PruneStylePosts.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: SaveTransaction.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: UpsertStylePosts.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: UpsertStyleProfiles.sql
|
||||
|
||||
package store
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
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.
|
||||
// It is skipped unless TEST_DATABASE_URL is set.
|
||||
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.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package store
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ type AnalyticsCache struct {
|
|||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type AnalyticsCoverage struct {
|
||||
type AnalyticsCoverageRange struct {
|
||||
UserID string
|
||||
Platform string
|
||||
CoveredStart time.Time
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ import (
|
|||
log "github.com/nacorid/logger"
|
||||
)
|
||||
|
||||
func parseTwitterTime(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse Twitter timestamp", "timestamp", s, "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
const apiBase = "https://api.x.com/2"
|
||||
|
||||
type Client struct {
|
||||
|
|
@ -222,7 +231,7 @@ func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) (
|
|||
|
||||
posts := make([]*Post, 0, len(tr.Data))
|
||||
for _, t := range tr.Data {
|
||||
created, _ := time.Parse(time.RFC3339, t.CreatedAt)
|
||||
created := parseTwitterTime(t.CreatedAt)
|
||||
posts = append(posts, &Post{
|
||||
ID: t.ID,
|
||||
Author: userID,
|
||||
|
|
@ -271,7 +280,7 @@ func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, e
|
|||
|
||||
posts := make([]*Post, 0, len(tr.Data))
|
||||
for _, t := range tr.Data {
|
||||
created, _ := time.Parse(time.RFC3339, t.CreatedAt)
|
||||
created := parseTwitterTime(t.CreatedAt)
|
||||
posts = append(posts, &Post{
|
||||
ID: t.ID,
|
||||
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"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
|
|
@ -216,7 +217,7 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool
|
|||
if toolName == "getStyleProfile" {
|
||||
actualCost := computeTwitterStyleProfileCost(r, textContent.Text)
|
||||
nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{
|
||||
Amount: fmt.Sprintf("%d", actualCost),
|
||||
Amount: actualCost,
|
||||
})
|
||||
}
|
||||
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
|
||||
// request based on the number of posts fetched and user lookup.
|
||||
// Pricing: user read $0.01, post read $0.005 each, minimum $0.10, maximum $0.26.
|
||||
func computeTwitterStyleProfileCost(r *http.Request, responseText string) int {
|
||||
// Twitter API costs are itemized below. Every getStyleProfile request, for any
|
||||
// platform, also carries the flat base cost of the endpoint (the same price a
|
||||
// non-Twitter platform pays). Adding that base cost on top of the itemized
|
||||
// Twitter API costs guarantees the call always clears the underlying API cost
|
||||
// and generates a profit - it can never break even or lose money.
|
||||
const (
|
||||
// baseProfileCostMicro is the base cost of the style-profile endpoint for
|
||||
// the non-Twitter path. It doubles as the profit margin added to Twitter
|
||||
// requests, expressed in micro-dollars (USDC has 6 decimals).
|
||||
baseProfileCostMicro int64 = 100_000 // $0.10
|
||||
// twitterUserLookupMicro is the per-request Twitter user lookup API cost.
|
||||
twitterUserLookupMicro int64 = 10_000 // $0.01
|
||||
// twitterPostReadMicro is the per-post Twitter read API cost.
|
||||
twitterPostReadMicro int64 = 5_000 // $0.005 per post
|
||||
)
|
||||
|
||||
// computeTwitterStyleProfileCost calculates the actual charge for a Twitter
|
||||
// getStyleProfile request based on the number of posts fetched and the user
|
||||
// lookup, and returns it as a dollar price string (e.g. "$0.16") suitable for a
|
||||
// x402 settlement override.
|
||||
//
|
||||
// Pricing: guaranteed base cost of $0.10 (the endpoint cost for other
|
||||
// platforms), plus user lookup $0.01, plus $0.005 per post read. For example,
|
||||
// 10 posts = $0.16. Non-Twitter platforms are charged the flat $0.10 base.
|
||||
func computeTwitterStyleProfileCost(r *http.Request, responseText string) string {
|
||||
// Parse the response to get PostCount and SourcePlatform
|
||||
var resp GetStyleProfileResponse
|
||||
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
|
||||
isTwitter := resp.SourcePlatform == "Twitter"
|
||||
costMicro := baseProfileCostMicro
|
||||
|
||||
// Extract post count from response
|
||||
// Twitter additionally passes on the user lookup and per-post API costs.
|
||||
if resp.SourcePlatform == "Twitter" {
|
||||
postCount := resp.PostCount
|
||||
if postCount <= 0 {
|
||||
if postCount < 0 {
|
||||
postCount = 0
|
||||
}
|
||||
|
||||
// Calculate cost:
|
||||
// - User read: $0.01 if Twitter and we assume user lookup was performed
|
||||
// - Posts read: $0.005 per post, max 50 posts = $0.25
|
||||
// - Minimum: $0.10
|
||||
// - Maximum: $0.26
|
||||
var costCents int
|
||||
|
||||
if isTwitter {
|
||||
// Twitter: user read + posts read
|
||||
userCents := 1 // $0.01
|
||||
postCents := postCount * 5 // $0.005 per post = 5 cents per post in atomic units
|
||||
if postCents > 125 { // 50 posts * 5 = 25 cents max
|
||||
postCents = 125
|
||||
costMicro += twitterUserLookupMicro
|
||||
costMicro += int64(postCount) * twitterPostReadMicro
|
||||
}
|
||||
|
||||
return formatDollarAmount(costMicro)
|
||||
}
|
||||
|
||||
// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed
|
||||
// price string with at least two decimal places and trailing zeroes trimmed
|
||||
// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402
|
||||
// settlement override resolver expects.
|
||||
func formatDollarAmount(micro int64) string {
|
||||
dollars := float64(micro) / 1_000_000
|
||||
s := strconv.FormatFloat(dollars, 'f', 6, 64)
|
||||
s = strings.TrimRight(s, "0")
|
||||
if dot := strings.IndexByte(s, '.'); dot >= 0 {
|
||||
if decimals := len(s) - dot - 1; decimals < 2 {
|
||||
s += strings.Repeat("0", 2-decimals)
|
||||
}
|
||||
costCents = userCents + postCents
|
||||
} else {
|
||||
costCents = 10 // $0.10 minimum for non-Twitter
|
||||
s += ".00"
|
||||
}
|
||||
|
||||
// Apply minimum $0.10 (10 cents)
|
||||
if costCents < 10 {
|
||||
costCents = 10
|
||||
}
|
||||
|
||||
// Apply maximum $0.26 (26 cents)
|
||||
if costCents > 26 {
|
||||
costCents = 26
|
||||
}
|
||||
|
||||
return costCents
|
||||
return "$" + s
|
||||
}
|
||||
|
||||
// GetStyleProfileResponse is a local copy of the response schema for cost calculation
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package x402
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -206,3 +258,190 @@ func setupMockFacilitator() *httptest.Server {
|
|||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func TestAddTool_NonPayable(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
srv.AddTool(
|
||||
mcp.Tool{
|
||||
Name: "freeTool",
|
||||
Description: "Free test endpoint",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Test HTTP endpoint works without payment
|
||||
reqRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`))
|
||||
srv.mux.ServeHTTP(reqRec, req)
|
||||
if reqRec.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 for free tool, got %d", reqRec.Code)
|
||||
}
|
||||
|
||||
// Test OpenAPI includes non-payable tool
|
||||
reqRecOpenAPI := httptest.NewRecorder()
|
||||
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
|
||||
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
|
||||
if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") {
|
||||
t.Error("OpenAPI spec did not render non-payable tool")
|
||||
}
|
||||
// Should not have 402 response for free tool
|
||||
if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) {
|
||||
t.Error("OpenAPI spec should not have 402 for free tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPayableTool_EmptyRequirements(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// The library validates requirements at AddPayableTool call time
|
||||
// and returns an error (or panics via log.Fatal) for empty requirements
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errMsg := fmt.Sprintf("%v", r)
|
||||
// The library may panic with log.Fatal or return an error
|
||||
if !strings.Contains(errMsg, "requires at least one payment requirement") &&
|
||||
!strings.Contains(errMsg, "Accepts must have at least one") {
|
||||
t.Errorf("expected panic about empty requirements, got: %v", r)
|
||||
}
|
||||
} else {
|
||||
t.Error("expected panic for empty payment requirements")
|
||||
}
|
||||
}()
|
||||
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{
|
||||
Name: "badTool",
|
||||
Description: "Bad endpoint",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
|
||||
}, nil
|
||||
},
|
||||
// No requirements provided
|
||||
)
|
||||
}
|
||||
|
||||
func TestX402Server_MultipleTools(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Add multiple tools
|
||||
toolNames := []string{"tool1", "tool2", "tool3"}
|
||||
for _, name := range toolNames {
|
||||
name := name
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{
|
||||
Name: name,
|
||||
Description: "Tool " + name,
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}},
|
||||
}, nil
|
||||
},
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
Amount: "1000",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Verify all tools are registered in OpenAPI
|
||||
reqRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/openapi.json", nil)
|
||||
srv.mux.ServeHTTP(reqRec, req)
|
||||
|
||||
body := reqRec.Body.String()
|
||||
for _, name := range toolNames {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("OpenAPI missing tool: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each tool requires payment
|
||||
for _, name := range toolNames {
|
||||
reqRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`))
|
||||
srv.mux.ServeHTTP(reqRec, req)
|
||||
if reqRec.Code != http.StatusPaymentRequired {
|
||||
t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
platform string
|
||||
postCount int
|
||||
want string
|
||||
}{
|
||||
{name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.10"}, // not "Twitter" so flat base
|
||||
{name: "empty platform", platform: "", postCount: 10, want: "$0.10"},
|
||||
{name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.11"}, // should clamp to 0
|
||||
{name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.11"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
|
||||
payload, _ := json.Marshal(resp)
|
||||
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
|
||||
|
||||
got := computeTwitterStyleProfileCost(r, string(payload))
|
||||
if got != tc.want {
|
||||
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
-- name: GetAnalyticsCoverage :one
|
||||
SELECT covered_start, covered_end FROM analytics_coverage
|
||||
WHERE user_id = $1 AND platform = $2;
|
||||
-- name: GetAnalyticsCoverageRanges :many
|
||||
SELECT covered_start, covered_end FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
ORDER BY covered_start;
|
||||
|
||||
-- name: UpsertAnalyticsCoverage :exec
|
||||
INSERT INTO analytics_coverage (user_id, platform, covered_start, covered_end, updated_at)
|
||||
-- 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)
|
||||
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();
|
||||
ON CONFLICT (user_id, platform, covered_start, covered_end)
|
||||
DO UPDATE SET updated_at = now();
|
||||
|
||||
-- name: DeleteAnalyticsCoverageRange :exec
|
||||
DELETE FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
-- +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,
|
||||
platform TEXT NOT NULL,
|
||||
covered_start TIMESTAMPTZ NOT NULL,
|
||||
covered_end TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, platform)
|
||||
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
|
||||
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