naco-api/internal/server/analytics_test.go
Nacorid 2fd4a4ec90
refactor: finish activitypub post migration from Text to Content
Drop the redundant Post.Text/internalPost.Text fields and complete the
rename in server converters and test fixtures so the tree builds again.

- types.go: remove Text from Post and internalPost; toPost only writes
  Author when the account acct is present
- posts_convert.go: apPostsPtrToItems / apPostsToAnalyticsItems read Content
- server tests: drop duplicate Text literal fields (values identical to Content)
2026-09-09 23:17:35 +00:00

318 lines
11 KiB
Go

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