naco-api/internal/server/styleprofile_test.go
Nacorid 7a21437120
Some checks failed
Test Every Endpoint on Base Sepolia / test_endpoints (push) Failing after 6m33s
CI/CD Go Verification / build_and_test (push) Successful in 1m32s
docs: tidy stale comments in analytics, cache version, and style profile test
2026-09-13 22:24:45 +00:00

375 lines
14 KiB
Go

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