- 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
261 lines
11 KiB
Go
261 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/api/bsky"
|
|
"github.com/nacorid/naco-api/internal/activitypub"
|
|
"github.com/nacorid/naco-api/internal/nostr"
|
|
)
|
|
|
|
// ---- validation matrix ----
|
|
|
|
func TestGetPostValidation(t *testing.T) {
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
|
|
if _, err := callTool(s, "getPost", GetPostRequest{Author: "", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "author") {
|
|
t.Fatalf("expected author error, got %v", err)
|
|
}
|
|
if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: ""}); err == nil || !strings.Contains(err.Error(), "rkey") {
|
|
t.Fatalf("expected rkey error, got %v", err)
|
|
}
|
|
if _, err := callTool(s, "getPost", "not-an-object"); err == nil {
|
|
t.Fatal("expected invalid arguments error")
|
|
}
|
|
// Unparseable handle -> determinePlatform error
|
|
if _, err := callTool(s, "getPost", GetPostRequest{Author: "justhandle", RecordKey: "k"}); err == nil {
|
|
t.Fatal("expected determinePlatform error")
|
|
}
|
|
}
|
|
|
|
func TestGetLatestPostsValidation(t *testing.T) {
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
|
|
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") {
|
|
t.Fatalf("expected author error, got %v", err)
|
|
}
|
|
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") {
|
|
t.Fatalf("expected limit error, got %v", err)
|
|
}
|
|
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: -3}); err == nil {
|
|
t.Fatal("expected negative limit error")
|
|
}
|
|
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") {
|
|
t.Fatalf("expected max-limit error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetPostsInTimeValidation(t *testing.T) {
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
|
|
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") {
|
|
t.Fatalf("expected author error, got %v", err)
|
|
}
|
|
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") {
|
|
t.Fatalf("expected limit error, got %v", err)
|
|
}
|
|
// getPostsInTime now rejects over-max limit for consistency with getLatestPosts.
|
|
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") {
|
|
t.Fatalf("expected max-limit error, got %v", err)
|
|
}
|
|
// Bad start time.
|
|
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "not-a-time", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "startTime") {
|
|
t.Fatalf("expected startTime error, got %v", err)
|
|
}
|
|
// Bad end time reports the correct field name.
|
|
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "not-a-time", Limit: 5}); err == nil || !strings.Contains(err.Error(), "endTime") {
|
|
t.Fatalf("expected endTime error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetPostsInTimeSwapsReversedRange(t *testing.T) {
|
|
// Nostr fake returns posts regardless of args; the point is that a reversed
|
|
// range is accepted (swapped) rather than rejected.
|
|
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npub1x", Content: "hi", CreatedAt: time.Now()}}}
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
|
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{
|
|
Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
StartTime: "2024-12-31T00:00:00Z",
|
|
EndTime: "2024-01-01T00:00:00Z",
|
|
Limit: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected reversed range to be swapped and succeed, got %v", err)
|
|
}
|
|
if res == nil || len(res.Content) == 0 {
|
|
t.Fatal("expected a result")
|
|
}
|
|
}
|
|
|
|
func TestGetFeedValidation(t *testing.T) {
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
if _, err := callTool(s, "getFeed", GetFeedRequest{URI: ""}); err == nil || !strings.Contains(err.Error(), "URI") {
|
|
t.Fatalf("expected URI error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetActorValidation(t *testing.T) {
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
if _, err := callTool(s, "getActor", GetActorRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") {
|
|
t.Fatalf("expected author error, got %v", err)
|
|
}
|
|
// Bare handle is unparseable -> determinePlatform error.
|
|
if _, err := callTool(s, "getActor", GetActorRequest{Author: "justhandle"}); err == nil {
|
|
t.Fatal("expected determinePlatform error")
|
|
}
|
|
}
|
|
|
|
func TestSearchActorsValidation(t *testing.T) {
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: ""}); err == nil || !strings.Contains(err.Error(), "query") {
|
|
t.Fatalf("expected query error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- twitter policy blocks ----
|
|
|
|
func TestTwitterPolicyBlocksAllTools(t *testing.T) {
|
|
cases := []struct {
|
|
tool string
|
|
args any
|
|
}{
|
|
{"getPost", GetPostRequest{Author: "@alice", RecordKey: "k"}},
|
|
{"getLatestPosts", GetLatestPostsRequest{Author: "@alice", Limit: 5}},
|
|
{"getPostsInTime", GetPostsInTimeRequest{Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}},
|
|
{"getFeed", GetFeedRequest{URI: "@alice"}},
|
|
{"getActor", GetActorRequest{Author: "@alice"}},
|
|
}
|
|
for _, c := range cases {
|
|
tw := &fakeTwitter{}
|
|
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
|
|
_, err := callTool(s, c.tool, c.args)
|
|
if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") {
|
|
t.Fatalf("%s: expected twitter_unavailable, got %v", c.tool, err)
|
|
}
|
|
if tw.calls != 0 {
|
|
t.Fatalf("%s: backend should not be called, got %d calls", c.tool, tw.calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- backend error propagation ----
|
|
|
|
func TestBackendErrorPropagation(t *testing.T) {
|
|
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
|
|
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "boom") {
|
|
t.Fatalf("expected backend error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetActorBackendError(t *testing.T) {
|
|
ap := &fakeAP{err: fmt.Errorf("boom")}
|
|
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
|
if _, err := callTool(s, "getActor", GetActorRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "boom") {
|
|
t.Fatalf("expected backend error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- getPost success paths ----
|
|
|
|
func TestGetPostBlueskyAndNostr(t *testing.T) {
|
|
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky")}
|
|
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"})
|
|
if err != nil {
|
|
t.Fatalf("getPost bluesky error: %v", err)
|
|
}
|
|
if !strings.Contains(textOf(t, res), "hello bluesky") {
|
|
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
|
|
}
|
|
if bsky.calls != 1 {
|
|
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
|
}
|
|
|
|
nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}}
|
|
s2 := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
|
res2, err := callTool(s2, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"})
|
|
if err != nil {
|
|
t.Fatalf("getPost nostr error: %v", err)
|
|
}
|
|
if !strings.Contains(textOf(t, res2), "nostr hello") {
|
|
t.Fatalf("unexpected nostr post text: %s", textOf(t, res2))
|
|
}
|
|
}
|
|
|
|
func TestGetLatestPostsEmptyResult(t *testing.T) {
|
|
ap := &fakeAP{}
|
|
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
|
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
|
|
if err != nil {
|
|
t.Fatalf("getLatestPosts error: %v", err)
|
|
}
|
|
var out GetLatestPostsResponse
|
|
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(out.Posts) != 0 {
|
|
t.Fatalf("expected 0 posts, got %d", len(out.Posts))
|
|
}
|
|
if out.Author != "@a@mastodon.social" {
|
|
t.Fatalf("expected author fallback to request author, got %q", out.Author)
|
|
}
|
|
}
|
|
|
|
func TestGetLatestPostsBlueskyNilPostSkipped(t *testing.T) {
|
|
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
|
|
{Post: bskyPostView("kept")},
|
|
{Post: nil},
|
|
}}
|
|
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5})
|
|
if err != nil {
|
|
t.Fatalf("getLatestPosts error: %v", err)
|
|
}
|
|
var out GetLatestPostsResponse
|
|
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(out.Posts) != 1 {
|
|
t.Fatalf("expected 1 post after skipping nil, got %d", len(out.Posts))
|
|
}
|
|
}
|
|
|
|
func TestGetFeedNonBlueskyFallback(t *testing.T) {
|
|
// A non-Bluesky feed URI is treated as an author handle and falls back to
|
|
// fetchLatestPosts (evaluated via the ActivityPub backend here).
|
|
ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fallback post", CreatedAt: time.Now()}}}
|
|
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
|
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "@a@mastodon.social"})
|
|
if err != nil {
|
|
t.Fatalf("getFeed fallback error: %v", err)
|
|
}
|
|
if !strings.Contains(textOf(t, res), "fallback post") {
|
|
t.Fatalf("expected fallback post, got %s", textOf(t, res))
|
|
}
|
|
}
|
|
|
|
func TestGetFeedEmptyResult(t *testing.T) {
|
|
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: nil}}
|
|
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
|
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
|
|
if err != nil {
|
|
t.Fatalf("getFeed error: %v", err)
|
|
}
|
|
var out GetFeedResponse
|
|
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(out.Posts) != 0 {
|
|
t.Fatalf("expected 0 feed posts, got %d", len(out.Posts))
|
|
}
|
|
}
|
|
|
|
func jsonUnmarshal(t *testing.T, in string, out any) error {
|
|
t.Helper()
|
|
return json.Unmarshal([]byte(in), out)
|
|
}
|