package server import ( "context" "fmt" "strings" "testing" "time" "github.com/nacorid/naco-api/internal/activitypub" "github.com/nacorid/naco-api/internal/clustering" "github.com/nacorid/naco-api/internal/twitter" ) 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", CreatedAt: base}, {ID: "p2", Author: "@a@mastodon.social", Content: "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) } } // TestAnalyticsTwitterPostCount verifies the response reports how many posts // were sourced from the Twitter API, so upto-scheme settlement can charge the // realized Twitter API costs. func TestAnalyticsTwitterPostCount(t *testing.T) { tw := &fakeTwitter{posts: []*twitter.Post{ {ID: "t1", Content: "first tweet", CreatedAt: time.Date(2024, 1, 11, 12, 0, 0, 0, time.UTC)}, {ID: "t2", Content: "second tweet", CreatedAt: time.Date(2024, 1, 12, 12, 0, 0, 0, time.UTC)}, {ID: "t3", Content: "third tweet", CreatedAt: time.Date(2024, 1, 13, 12, 0, 0, 0, time.UTC)}, }} st := newFakeStore() s := newServerWith(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, tw, st, &fakeOllama{}) res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Platform: "Twitter", }) 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.TwitterPostCount != 3 { t.Fatalf("expected TwitterPostCount 3, got %d", out.TwitterPostCount) } // A non-Twitter platform reports 0 and the default aggregate excludes // Twitter when the API is not enabled (twitterEnabled=false in tests). res, err = callTool(s, "getEngagementMetrics", AnalyticsRequest{ Author: "@a@mastodon.social", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Platform: "ActivityPub", }) if err != nil { t.Fatalf("getEngagementMetrics error: %v", err) } if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { t.Fatalf("unmarshal: %v", err) } if out.TwitterPostCount != 0 { t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out.TwitterPostCount) } } // ---- 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) } }