naco-api/internal/server/twitter_analytics_test.go
Nacorid 90af0ec4fc 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

162 lines
5.6 KiB
Go

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