package store import ( "os" "path/filepath" "reflect" "sort" "testing" "git.smesh.lol/torly/pkg/nostr" ) func pubkeyOf(t *testing.T, seed byte) string { t.Helper() pub := testKey(seed) hex, err := nostr.PubKeyHex(pub) if err != nil { t.Fatal(err) } return hex } func graphQuery(seed string, depth int, edge, direction string) *nostr.GraphQuery { return &nostr.GraphQuery{Seed: seed, Depth: depth, Edge: edge, Direction: direction} } func levelsEqual(t *testing.T, got, want [][]string) { t.Helper() for _, level := range got { sort.Strings(level) } for _, level := range want { sort.Strings(level) } if !reflect.DeepEqual(got, want) { t.Fatalf("levels:\n got %v\nwant %v", got, want) } } func TestGraphPubkeyPubkey(t *testing.T) { e, _ := openTest(t) alice, bob, carol, dave := pubkeyOf(t, 1), pubkeyOf(t, 2), pubkeyOf(t, 3), pubkeyOf(t, 4) save(t, e, signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}, {"p", carol}}, "")) save(t, e, signedEvent(t, 2, 3, 101, nostr.Tags{{"p", carol}}, "")) save(t, e, signedEvent(t, 3, 3, 102, nostr.Tags{{"p", dave}}, "")) res, err := e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{bob, carol}}) if res.TotalPubkeys != 2 || res.TotalEvents != 0 { t.Fatalf("totals: %+v", res) } res, err = e.TraverseGraph(graphQuery(alice, 2, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{bob, carol}, {dave}}) res, err = e.TraverseGraph(graphQuery(carol, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirIn)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{alice, bob}}) res, err = e.TraverseGraph(graphQuery(carol, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirBoth)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{alice, bob, dave}}) // A missing seed is an empty result, not an error. res, err = e.TraverseGraph(graphQuery(pubkeyOf(t, 99), 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil || res.TotalPubkeys != 0 { t.Fatalf("missing seed: %+v %v", res, err) } } func TestGraphReplacementRemovesOldEdges(t *testing.T) { e, _ := openTest(t) alice, bob, carol := pubkeyOf(t, 1), pubkeyOf(t, 2), pubkeyOf(t, 3) save(t, e, signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}}, "")) save(t, e, signedEvent(t, 1, 3, 200, nostr.Tags{{"p", carol}}, "")) res, err := e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{carol}}) } func TestGraphPubkeyEvent(t *testing.T) { e, _ := openTest(t) alice := pubkeyOf(t, 1) note := signedEvent(t, 1, 1, 100, nil, "alice note") mention := signedEvent(t, 2, 1, 101, nostr.Tags{{"p", alice}, {"e", nostr.Hex(note.ID[:])}}, "hi alice") save(t, e, note) save(t, e, mention) res, err := e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyEvent, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.EventsByDepth, [][]string{{nostr.Hex(note.ID[:])}}) res, err = e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyEvent, nostr.GraphDirIn)) if err != nil { t.Fatal(err) } levelsEqual(t, res.EventsByDepth, [][]string{{nostr.Hex(mention.ID[:])}}) res, err = e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyEvent, nostr.GraphDirBoth)) if err != nil { t.Fatal(err) } levelsEqual(t, res.EventsByDepth, [][]string{{nostr.Hex(note.ID[:]), nostr.Hex(mention.ID[:])}}) } func TestGraphThreadBackfillsOutOfOrderReplies(t *testing.T) { e, _ := openTest(t) alice, bob := pubkeyOf(t, 1), pubkeyOf(t, 2) root := signedEvent(t, 1, 1, 100, nil, "root") reply := signedEvent(t, 2, 1, 101, nostr.Tags{{"e", nostr.Hex(root.ID[:])}, {"p", alice}}, "reply") deep := signedEvent(t, 3, 1, 102, nostr.Tags{{"e", nostr.Hex(reply.ID[:])}, {"p", bob}}, "deep reply") // Save the chain in the worst possible order: the deepest reply first and // the root last. The backfill must still connect both hops. save(t, e, deep) save(t, e, reply) save(t, e, root) res, err := e.TraverseGraph(graphQuery(alice, 3, nostr.GraphEdgeEventEvent, nostr.GraphDirBoth)) if err != nil { t.Fatal(err) } levelsEqual(t, res.EventsByDepth, [][]string{ {nostr.Hex(reply.ID[:])}, {nostr.Hex(deep.ID[:])}, }) res, err = e.TraverseGraph(graphQuery(bob, 2, nostr.GraphEdgeEventEvent, nostr.GraphDirIn)) if err != nil { t.Fatal(err) } levelsEqual(t, res.EventsByDepth, [][]string{{nostr.Hex(deep.ID[:])}}) // The authored roots themselves are not part of the result. for _, level := range res.EventsByDepth { for _, id := range level { if id == nostr.Hex(root.ID[:]) || id == nostr.Hex(reply.ID[:]) { t.Fatalf("root leaked into result: %v", res.EventsByDepth) } } } } func TestGraphDeletionRemovesEdges(t *testing.T) { e, _ := openTest(t) bob := pubkeyOf(t, 2) follow := signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}}, "") save(t, e, follow) if ok, err := e.Delete(follow.ID[:], follow.PubKey[:]); err != nil || !ok { t.Fatalf("delete: %v %v", ok, err) } res, err := e.TraverseGraph(graphQuery(pubkeyOf(t, 1), 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } if res.TotalPubkeys != 0 { t.Fatalf("edges survived deletion: %+v", res) } root := signedEvent(t, 1, 1, 200, nil, "root") reply := signedEvent(t, 2, 1, 201, nostr.Tags{{"e", nostr.Hex(root.ID[:])}}, "reply") save(t, e, root) save(t, e, reply) if ok, err := e.Delete(root.ID[:], root.PubKey[:]); err != nil || !ok { t.Fatalf("delete root: %v %v", ok, err) } res, err = e.TraverseGraph(graphQuery(pubkeyOf(t, 1), 2, nostr.GraphEdgeEventEvent, nostr.GraphDirBoth)) if err != nil { t.Fatal(err) } if res.TotalEvents != 0 { t.Fatalf("thread edges survived deletion: %+v", res) } } func TestGraphSurvivesReopen(t *testing.T) { e, dir := openTest(t) alice, bob := pubkeyOf(t, 1), pubkeyOf(t, 2) save(t, e, signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}}, "")) if err := e.Flush(); err != nil { t.Fatal(err) } if err := e.Close(); err != nil { t.Fatal(err) } reopened, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } defer reopened.Close() if err := reopened.RebuildGraph(); err != nil { t.Fatal(err) } res, err := reopened.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{bob}}) } func TestGraphRebuildStoreWithoutGraphIndexesExplicitly(t *testing.T) { e, dir := openTest(t) alice, bob := pubkeyOf(t, 1), pubkeyOf(t, 2) save(t, e, signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}}, "")) if err := e.Flush(); err != nil { t.Fatal(err) } if err := e.Close(); err != nil { t.Fatal(err) } for _, name := range []string{"pks", "spk", "epg", "peg", "eeg", "gee", "ppg", "gpp"} { if err := os.Remove(filepath.Join(dir, "idx", name+".dat")); err != nil { t.Fatal(err) } } reopened, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } defer reopened.Close() if err := reopened.RebuildGraph(); err != nil { t.Fatal(err) } res, err := reopened.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{bob}}) } func TestBulkImportRebuildGraph(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{BulkImport: true}) if err != nil { t.Fatal(err) } defer e.Close() alice, bob, carol := pubkeyOf(t, 1), pubkeyOf(t, 2), pubkeyOf(t, 3) save(t, e, signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}}, "")) save(t, e, signedEvent(t, 1, 3, 200, nostr.Tags{{"p", carol}}, "")) res, err := e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } if res.TotalPubkeys != 0 { t.Fatalf("graph was written during bulk import: %+v", res) } if err := e.RebuildGraph(); err != nil { t.Fatal(err) } res, err = e.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{carol}}) all, err := e.Query(&nostr.Filter{}) if err != nil { t.Fatal(err) } if len(all) != 1 { t.Fatalf("bulk replacement kept %d events, want 1", len(all)) } } func TestBulkImportSeedsReplacementSlots(t *testing.T) { dir := t.TempDir() normal, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } alice, bob, carol := pubkeyOf(t, 1), pubkeyOf(t, 2), pubkeyOf(t, 3) save(t, normal, signedEvent(t, 1, 3, 100, nostr.Tags{{"p", bob}}, "")) if err := normal.Close(); err != nil { t.Fatal(err) } bulk, err := Open(dir, Options{BulkImport: true}) if err != nil { t.Fatal(err) } if ok, err := bulk.SaveEvent(signedEvent(t, 1, 3, 200, nostr.Tags{{"p", carol}}, "")); err != nil || !ok { t.Fatalf("bulk replace: %v %v", ok, err) } if err := bulk.RebuildGraph(); err != nil { t.Fatal(err) } all, err := bulk.Query(&nostr.Filter{}) if err != nil { t.Fatal(err) } if len(all) != 1 { t.Fatalf("replacement kept %d events, want 1", len(all)) } res, err := bulk.TraverseGraph(graphQuery(alice, 1, nostr.GraphEdgePubkeyPubkey, nostr.GraphDirOut)) if err != nil { t.Fatal(err) } levelsEqual(t, res.PubkeysByDepth, [][]string{{carol}}) if err := bulk.Close(); err != nil { t.Fatal(err) } }