375 lines
14 KiB
Go
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)
|
|
}
|
|
}
|