package server import ( "database/sql" "fmt" "reflect" "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", CreatedAt: time.Now()}, {Author: "@a@mastodon.social", Content: "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 the 30-day stale threshold 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 month) 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(-31 * 24 * time.Hour), Valid: true}, } ap := &fakeAP{posts: []*activitypub.Post{ {Author: "@a@mastodon.social", Content: "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", 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) } } func TestGetStyleProfileFirstEmbeddingEmpty(t *testing.T) { // Regression: an empty embedding returned by Ollama for a non-first post used to // panic AverageVector when the first vector was also empty (accumulator sized to // length 0, later non-empty vector indexed it). Empty vectors are skipped. bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ {Post: bskyPostView("first post")}, {Post: bskyPostView("second 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 !reflect.DeepEqual(out.PersonaEmbedding, []float64{0.1, 0.2}) { t.Fatalf("expected averaged embedding {0.1,0.2}, got %v", out.PersonaEmbedding) } if out.PostCount != 2 { t.Fatalf("expected post count 2, got %d", out.PostCount) } } func TestGetStyleProfileSkipsEmptyTextPosts(t *testing.T) { // A Bluesky post with no text (image/reply-only) must not reach Ollama: its empty // embedding would poison the persona average. It is dropped before embedding. bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ {Post: bskyPostView("")}, {Post: bskyPostView("real post")}, }} oll := &fakeOllama{} 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.PostCount != 1 { t.Fatalf("expected filtered post count 1, got %d", out.PostCount) } if len(out.PersonaEmbedding) != 2 { t.Fatalf("expected 2-dim embedding, got %v", out.PersonaEmbedding) } } // ---- 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)) } } // TestFindSimilarUsersTwitterPostCount verifies the response reports how many // posts went into the query author's style profile when that author is on // Twitter, so upto-scheme settlement can charge the realized Twitter API costs. func TestFindSimilarUsersTwitterPostCount(t *testing.T) { st := newFakeStore() st.similarRows = []store.FindSimilarUsersRow{ {UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80}, } // Serve a fresh cached Twitter profile with 42 analyzed posts. st.styleProfiles["@alice|Twitter"] = store.UserStyleProfile{ UserID: "@alice", Platform: "Twitter", Embedding: "[0.1,0.2]", PostCount: 42, 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: "@alice"}) 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 out.TwitterPostCount != 42 { t.Fatalf("expected TwitterPostCount 42, got %d", out.TwitterPostCount) } // A non-Twitter query author reports 0. st2 := newFakeStore() st2.similarRows = []store.FindSimilarUsersRow{ {UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80}, } st2.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ UserID: "@a@mastodon.social", Platform: "ActivityPub", Embedding: "[0.1,0.2]", PostCount: 9, ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, } s2 := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st2, &fakeOllama{}) res2, err := callTool(s2, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}) if err != nil { t.Fatalf("findSimilarUsers error: %v", err) } var out2 FindSimilarUsersResponse if err := jsonUnmarshal(t, textOf(t, res2), &out2); err != nil { t.Fatalf("unmarshal: %v", err) } if out2.TwitterPostCount != 0 { t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out2.TwitterPostCount) } } 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) } }