naco-api/internal/nostr/client_test.go
Nacorid 4f107da9ee
fix: harden Nostr client relay handling and error propagation
- budget relay queries across a shared 20s deadline (5s per relay) and
  dedupe events by ID across relays
- surface 'could not connect to any relay' when no query succeeded and
  abort early on relay-connect and stream-cancel errors
- guard zero/negative limits across all fetchers
- GetActor falls through relays and rejects empty profile metadata;
  SearchActors propagates relay errors instead of silently returning none
- NewNostrClient validates relay ws://wss:// schemes, trims, and dedupes
2026-09-09 23:17:56 +00:00

305 lines
8.2 KiB
Go

package nostr
import (
"context"
"strings"
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
)
func TestNewNostrClientEmptyRelays(t *testing.T) {
_, err := NewNostrClient([]string{})
if err == nil {
t.Fatal("expected error for empty relays")
}
if !strings.Contains(err.Error(), "at least one relay") {
t.Fatalf("expected 'at least one relay' error, got %v", err)
}
}
func TestResolvePubkeyVariants(t *testing.T) {
// Create a valid pubkey for testing using Generate()
sk := nostr.Generate()
pk := sk.Public()
npub := nip19.EncodeNpub(pk)
hex := pk.Hex()
tests := []struct {
name string
input string
wantErr bool
}{
{"npub encoding", npub, false},
{"64-char hex", hex, false},
{"invalid npub prefix", "nsec" + npub[4:], true},
{"too short hex", hex[:32], true},
{"random string", "not-a-key", true},
{"empty string", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := resolvePubkey(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
}
})
}
}
func TestResolveEventIDVariants(t *testing.T) {
var eid nostr.ID
for i := range eid {
eid[i] = byte(i + 1)
}
hex := eid.Hex()
tests := []struct {
name string
input string
wantErr bool
}{
{"64-char hex", hex, false},
{"too short hex", hex[:32], true},
{"random string", "not-an-id", true},
{"empty string", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := resolveEventID(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
}
})
}
}
func TestGetPostInvalidID(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetPost(context.Background(), "invalid-id")
if err == nil {
t.Fatal("expected error for invalid event ID")
}
if !strings.Contains(err.Error(), "invalid nostr event id") {
t.Fatalf("expected 'invalid nostr event id' error, got %v", err)
}
}
func TestGetActorInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetActor(context.Background(), "invalid-author")
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestGetLatestNPostsInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetLatestNPosts(context.Background(), "invalid-author", 10)
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestGetPostsInTimeframeInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err = c.GetPostsInTimeframe(context.Background(), "invalid-author", start, end, 10)
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestRelayConnectionFailureReturnsError(t *testing.T) {
// When no relay can be reached, GetLatestNPosts/GetPostsInTimeframe must
// report the connection failure rather than silently returning an empty feed.
c, err := NewNostrClient([]string{
"wss://invalid1.relay.invalid",
"wss://invalid2.relay.invalid",
"wss://invalid3.relay.invalid",
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
_, err = c.GetLatestNPosts(ctx, npub, 10)
if err == nil {
t.Fatal("expected error when no relay is reachable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err = c.GetPostsInTimeframe(ctx, npub, start, end, 10)
if err == nil {
t.Fatal("expected error when no relay is reachable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestNewNostrClientInvalidScheme(t *testing.T) {
_, err := NewNostrClient([]string{"http://relay.example"})
if err == nil {
t.Fatal("expected error for non-websocket relay URL")
}
if !strings.Contains(err.Error(), "invalid relay URL") {
t.Fatalf("expected 'invalid relay URL' error, got %v", err)
}
// Blank and duplicate entries are dropped, the scheme is validated.
c, err := NewNostrClient([]string{
"wss://relay.a",
" wss://relay.a ",
"ws://relay.b",
" ",
})
if err != nil {
t.Fatal(err)
}
if len(c.relays) != 2 {
t.Fatalf("expected 2 unique relays after cleaning, got %d: %v", len(c.relays), c.relays)
}
}
func TestGetLatestNPostsZeroOrNegativeLimit(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
if posts, err := c.GetLatestNPosts(context.Background(), npub, 0); err != nil || posts != nil {
t.Fatalf("expected nil, nil for zero limit, got %v, %v", posts, err)
}
if posts, err := c.GetLatestNPosts(context.Background(), npub, -5); err != nil || posts != nil {
t.Fatalf("expected nil, nil for negative limit, got %v, %v", posts, err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
if posts, err := c.GetPostsInTimeframe(context.Background(), npub, start, end, 0); err != nil || posts != nil {
t.Fatalf("expected nil, nil for zero timeframe limit, got %v, %v", posts, err)
}
}
func TestSearchActors(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
// A non-key query (e.g. a display name) is a valid no-match, not an error.
results, err := c.SearchActors(ctx, "alice", 1)
if err != nil {
t.Fatalf("expected no error for non-key query, got %v", err)
}
if results != nil {
t.Fatalf("expected nil results for non-key query, got %v", results)
}
// A valid pubkey that fails on every relay must surface the outage.
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
if _, err := c.SearchActors(ctx, npub, 1); err == nil {
t.Fatal("expected error when no relay is reachable")
} else if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestGetPostWithHexID(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
var eid nostr.ID
for i := range eid {
eid[i] = byte(i + 1)
}
hex := eid.Hex()
_, err = c.GetPost(context.Background(), hex)
if err == nil {
t.Fatal("expected error when relay unavailable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestGetActorWithHexPubkey(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
sk := nostr.Generate()
hex := sk.Public().Hex()
_, err = c.GetActor(context.Background(), hex)
if err == nil {
t.Fatal("expected error when relay unavailable")
}
// GetActor reports a total relay outage as a connection failure rather than
// pretending the author has no metadata.
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}