Compare commits

...
Author SHA1 Message Date
dab6fe081f
workflows on ai-main
Some checks failed
Test Every Endpoint on Base Sepolia / test_endpoints (push) Failing after 7s
CI/CD Go Verification / build_and_test (push) Has been cancelled
2026-09-03 17:18:01 +00:00
abd8b73e09
chore: tidy store integration test and deps
- remove tautological SaveTransaction SQL/Params constant checks (const string replay)
- go.mod tidy drops unused go-sqlmock indirect
- testClient/main.go: gofmt alignment
2026-09-03 17:00:45 +00:00
9865f3633c
test(x402): cover Twitter style-profile cost passing and payable boundaries
- add ComputeTwitterStyleProfileCost table and AlwaysProfitable invariant
- add edge cases (case-insensitive, negative, large counts)
- cover MultipleTools and AddPayableTool validation, HTTP 402/MCP boundaries
2026-09-03 17:00:36 +00:00
36c19e8704
refactor(server): split mcp_test into focused suites with fakeStore/fakeOllama
- extract analytics, searchactors, styleprofile, twitter_analytics, validation, storefake from mcp_test
- add fakeStore/fakeOllama harnesses, lastLimit tracking, sql.NullTime staleness
- expand mcp_test with per-platform GetPost/Latest/PostsInTime, actor and cache tests
- touch analytics/backends/cache_version/metrics/server for coverage
2026-09-03 17:00:32 +00:00
5f25956fdd
test(clients): expand coverage for rate-limit, pagination and error paths
- nostr: variants for resolvePubkey/EventID, invalid ID/author checks, relay failure
- bluesky: add FeedGetPosts/getProfile/searchActors mocks, pagination and time-filtering with cursor
- twitter: add RateLimit retry (Reset/Retry-After), exhausted retries, lookup errors, context cancel
- activitypub: fix GetPostsInTimeframe to test 2-page pagination and start/end boundary
2026-09-03 17:00:28 +00:00
08f2d5f562
test: remove tautological stdlib-roundabout tests
Aggressive deletion of tests that only replay stdlib or copy impl:
- config/config_test.go: getEnvBool/splitComma/Load defaults (strings.* wrappers)
- utils/styleProfiles_test.go: ParseEmbedding type-switch
- store/migrate_test.go: extractUpSQL marker parsing
- clustering/kmeans_test.go: IdenticalVectors/SingleItem/DifferentSeeds (trivial)
- ollama/client_test.go: PromptStructure/UsesProvidedModels (setter replay)
2026-09-03 17:00:24 +00:00
2454ef9e62
fix(analytics,x402): fix coverage gaps, engagement drivers, and Twitter cost passing
Analytics review fixes:
- Store Twitter analytics coverage as discrete intervals instead of a single
  contiguous window so disjoint fetch ranges no longer mask inner gaps as
  covered (fetch per uncovered range, add gap regression tests)
- Compute real AvgEngagement/Correlation in getEngagementDrivers instead of
  all-zero placeholders
- Make parseAnalyticsTimeRange surface invalid timestamps, cap merged analytics
  posts at limit, and normalize sentiment by timestamped posts only
- gofmt: fix misalignment in config.go and types.go
- Include Twitter in the default aggregate analytics scope; its API costs are
  passed to the caller through paid tool pricing

x402 cost passing:
- computeTwitterStyleProfileCost now charges base cost ($0.10) + user lookup
  ($0.01) + $0.005 per Twitter post, guaranteeing the charge always clears the
  underlying API cost (never break-even or loss)
- Return a $-prefixed dollar string for the settlement override, fixing the
  previously off-by-factor-100 raw-atomic-unit amount
- Raise getStyleProfile base registration to $0.36 to authorize the max Twitter
  charge
- Add tests for the cost formula, profitable floor, and dollar formatting
2026-09-02 22:44:02 +00:00
51 changed files with 3326 additions and 794 deletions

View file

@ -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:

View file

@ -2,7 +2,7 @@ name: CI/CD Go Verification
on:
push:
branches: [ main, dev ]
branches: [ main, dev, ai-main ]
pull_request:
branches: [ main ]

View file

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

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -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
}

View file

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

View file

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

View file

@ -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 {

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

View file

@ -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
}

View file

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

View file

@ -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 ----

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

View file

@ -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

View 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
}

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

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

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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
package store

View file

@ -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) {

View file

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

View file

@ -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

View file

@ -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,

View file

@ -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")
}
}

View file

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

View file

@ -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

View file

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

View file

@ -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;

View file

@ -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;