naco-api/internal/server/validation_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

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