package store import ( "bytes" "encoding/binary" "fmt" "hash/crc32" "os" "path/filepath" "testing" "time" "git.smesh.lol/torly/pkg/nostr" ) func testKey(seed byte) [32]byte { var sk [32]byte sk[31] = seed return sk } func signedEvent(t *testing.T, seed byte, kind int, created int64, tags nostr.Tags, content string) *nostr.Event { t.Helper() ev := &nostr.Event{Kind: kind, CreatedAt: created, Tags: tags, Content: content} if err := ev.Sign(testKey(seed)); err != nil { t.Fatalf("sign: %v", err) } return ev } func openTest(t *testing.T) (*Engine, string) { t.Helper() dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { e.Close() }) return e, dir } func save(t *testing.T, e *Engine, ev *nostr.Event) { t.Helper() ok, err := e.SaveEvent(ev) if err != nil { t.Fatalf("save kind=%d: %v", ev.Kind, err) } if !ok { t.Fatal("save reported not stored") } } func ids(evs []*nostr.Event) []string { out := make([]string, len(evs)) for i, ev := range evs { out[i] = nostr.Hex(ev.ID[:])[:12] } return out } func TestSaveAndQuery(t *testing.T) { e, _ := openTest(t) now := int64(1700000000) ev1 := signedEvent(t, 1, 1, now, nostr.Tags{{"t", "nostr"}, {"p", "abc"}}, "hello world") ev2 := signedEvent(t, 2, 1, now+10, nostr.Tags{{"t", "go"}}, "second event") ev3 := signedEvent(t, 1, 0, now+20, nil, `{"name":"alice"}`) ev4 := signedEvent(t, 3, 7, now+30, nil, "reaction") for _, ev := range []*nostr.Event{ev1, ev2, ev3, ev4} { save(t, e, ev) } // Duplicate is rejected. if ok, err := e.SaveEvent(ev1); ok || err != ErrDuplicate { t.Fatalf("duplicate: ok=%v err=%v", ok, err) } got, err := e.Query(&nostr.Filter{Kinds: []int{1}}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("kinds=1: got %d events", len(got)) } if got[0].ID != ev2.ID { t.Fatalf("newest first: got %x want %x", got[0].ID, ev2.ID) } got, err = e.Query(&nostr.Filter{Authors: []string{nostr.Hex(ev1.PubKey[:])}}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("author: got %d, want 2", len(got)) } got, err = e.Query(&nostr.Filter{Tags: map[string][]string{"t": {"nostr"}}}) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].ID != ev1.ID { t.Fatalf("tag query: got %v", ids(got)) } got, err = e.Query(&nostr.Filter{IDs: []string{nostr.Hex(ev3.ID[:])}}) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].ID != ev3.ID { t.Fatalf("id query: got %v", ids(got)) } got, err = e.Query(&nostr.Filter{}) if err != nil { t.Fatal(err) } if len(got) != 4 { t.Fatalf("all: got %d, want 4", len(got)) } got, err = e.Query(&nostr.Filter{Limit: 2}) if err != nil { t.Fatal(err) } if len(got) != 2 || got[0].ID != ev4.ID { t.Fatalf("limit: got %v", ids(got)) } if n, approx, err := e.Count(&nostr.Filter{Kinds: []int{1}}); err != nil || n != 2 || approx { t.Fatalf("count: n=%d approx=%v err=%v", n, approx, err) } } func TestSearch(t *testing.T) { e, _ := openTest(t) ev1 := signedEvent(t, 1, 1, 100, nil, "The quick brown fox jumps") ev2 := signedEvent(t, 1, 1, 200, nil, "A slow blue fox sleeps") for _, ev := range []*nostr.Event{ev1, ev2} { save(t, e, ev) } got, err := e.Query(&nostr.Filter{Search: "fox"}) if err != nil || len(got) != 2 { t.Fatalf("fox: got %d err=%v", len(got), err) } got, err = e.Query(&nostr.Filter{Search: "quick brown"}) if err != nil || len(got) != 1 || got[0].ID != ev1.ID { t.Fatalf("quick brown: got %v err=%v", ids(got), err) } got, err = e.Query(&nostr.Filter{Search: "quick slow"}) if err != nil || len(got) != 0 { t.Fatalf("quick slow: got %d err=%v", len(got), err) } } func TestReplaceable(t *testing.T) { e, _ := openTest(t) older := signedEvent(t, 1, 0, 100, nil, `{"name":"old"}`) newer := signedEvent(t, 1, 0, 200, nil, `{"name":"new"}`) save(t, e, older) save(t, e, newer) got, err := e.Query(&nostr.Filter{Kinds: []int{0}}) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].ID != newer.ID { t.Fatalf("replaceable: got %v", ids(got)) } // Re-publishing the older event is refused. if ok, err := e.SaveEvent(older); ok || err != ErrReplaced { t.Fatalf("older republish: ok=%v err=%v", ok, err) } if _, err := e.GetByID(older.ID[:]); err != ErrNotFound { t.Fatalf("older should be gone, got %v", err) } } func TestAddressable(t *testing.T) { e, _ := openTest(t) a := signedEvent(t, 1, 30023, 100, nostr.Tags{{"d", "post-1"}}, "first") b := signedEvent(t, 1, 30023, 200, nostr.Tags{{"d", "post-1"}}, "second") c := signedEvent(t, 1, 30023, 150, nostr.Tags{{"d", "post-2"}}, "other") for _, ev := range []*nostr.Event{a, b, c} { save(t, e, ev) } got, err := e.Query(&nostr.Filter{Kinds: []int{30023}}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("addressable: got %d events, want 2 (one per d tag)", len(got)) } for _, ev := range got { if ev.DTag() == "post-1" && ev.ID != b.ID { t.Fatalf("post-1 should be the newer event") } } } func TestDeletion(t *testing.T) { e, _ := openTest(t) target := signedEvent(t, 1, 1, 100, nil, "delete me") save(t, e, target) // Another author cannot delete it. otherDel := signedEvent(t, 2, 5, 110, nostr.Tags{{"e", nostr.Hex(target.ID[:])}}, "") save(t, e, otherDel) if _, err := e.GetByID(target.ID[:]); err != nil { t.Fatalf("target should survive a foreign deletion: %v", err) } del := signedEvent(t, 1, 5, 120, nostr.Tags{{"e", nostr.Hex(target.ID[:])}}, "") save(t, e, del) if _, err := e.GetByID(target.ID[:]); err != ErrNotFound { t.Fatalf("target should be deleted, got %v", err) } // The deletion event itself remains. got, err := e.Query(&nostr.Filter{Kinds: []int{5}}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("deletion events: got %d, want 2", len(got)) } } func TestDeletionByAddress(t *testing.T) { e, _ := openTest(t) ev := signedEvent(t, 1, 30023, 100, nostr.Tags{{"d", "slug"}}, "article") save(t, e, ev) addr := fmt.Sprintf("30023:%s:slug", nostr.Hex(ev.PubKey[:])) del := signedEvent(t, 1, 5, 120, nostr.Tags{{"a", addr}}, "") save(t, e, del) if _, err := e.GetByID(ev.ID[:]); err != ErrNotFound { t.Fatalf("address deletion failed: %v", err) } } func TestExpiration(t *testing.T) { e, _ := openTest(t) now := time.Now().Unix() expired := signedEvent(t, 1, 1, 100, nostr.Tags{{"expiration", fmt.Sprint(now - 10)}}, "old") live := signedEvent(t, 1, 1, 100, nostr.Tags{{"expiration", fmt.Sprint(now + 100000)}}, "live") save(t, e, expired) save(t, e, live) n, err := e.PurgeExpired(now) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("purged %d, want 1", n) } got, err := e.Query(&nostr.Filter{Kinds: []int{1}}) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].ID != live.ID { t.Fatalf("after purge: %v", ids(got)) } } func TestReopenPersists(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } evs := make([]*nostr.Event, 0, 20) for i := 0; i < 20; i++ { ev := signedEvent(t, byte(i%3+1), 1, int64(1000+i), nostr.Tags{{"t", "persist"}}, fmt.Sprintf("event %d", i)) save(t, e, ev) evs = append(evs, ev) } if err := e.Close(); err != nil { t.Fatal(err) } e2, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } defer e2.Close() got, err := e2.Query(&nostr.Filter{Tags: map[string][]string{"t": {"persist"}}}) if err != nil { t.Fatal(err) } if len(got) != len(evs) { t.Fatalf("after reopen: got %d events, want %d", len(got), len(evs)) } for i := range evs { if got[len(got)-1-i].ID != evs[i].ID { t.Fatalf("order mismatch at %d", i) } } } func TestRecoveryFromWALWithoutCheckpoint(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } var want []*nostr.Event for i := 0; i < 10; i++ { ev := signedEvent(t, 1, 1, int64(2000+i), nostr.Tags{{"t", "wal"}}, "wal replay") save(t, e, ev) want = append(want, ev) } // Copy the directory while the engine is open: this is the state a crash // leaves behind after the WAL writes but before a checkpoint. copyDir := t.TempDir() copyTree(t, dir, copyDir) e.Close() e2, err := Open(copyDir, Options{}) if err != nil { t.Fatal(err) } defer e2.Close() got, err := e2.Query(&nostr.Filter{Tags: map[string][]string{"t": {"wal"}}}) if err != nil { t.Fatal(err) } if len(got) != len(want) { t.Fatalf("recovered %d events, want %d", len(got), len(want)) } } func copyTree(t *testing.T, src, dst string) { t.Helper() err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, 0o755) } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(target, data, 0o644) }) if err != nil { t.Fatal(err) } } func TestTombstoneSurvivesReopen(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } target := signedEvent(t, 1, 1, 100, nil, "gone") save(t, e, target) del := signedEvent(t, 1, 5, 110, nostr.Tags{{"e", nostr.Hex(target.ID[:])}}, "") save(t, e, del) e.Close() e2, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } defer e2.Close() if _, err := e2.GetByID(target.ID[:]); err != ErrNotFound { t.Fatalf("deleted event returned after reopen: %v", err) } } func TestEventEncodingRoundTrip(t *testing.T) { ev := signedEvent(t, 1, 30023, 12345, nostr.Tags{{"d", "x"}, {"t", "tag"}, {"expiration", "99999"}}, "content \"quoted\"\n") data := encodeEvent(ev) got, err := decodeEvent(data) if err != nil { t.Fatal(err) } if !bytes.Equal(got.ID[:], ev.ID[:]) || got.Content != ev.Content || len(got.Tags) != len(ev.Tags) { t.Fatalf("round trip mismatch: %+v vs %+v", got, ev) } if _, err := decodeEvent(data[:len(data)-1]); err == nil { t.Fatal("truncated record should not decode") } } // TestMultipleRunsSurviveReopen guards the index run model: each QuickFlush // writes a separate sorted run, and a merge must not assume that concatenated // runs are globally sorted. func TestMultipleRunsSurviveReopen(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } ev1 := signedEvent(t, 1, 1, 100, nostr.Tags{{"t", "zeta"}}, "first run") save(t, e, ev1) // A query calls flushPending, turning the buffered records into a run. if _, err := e.Query(&nostr.Filter{Kinds: []int{1}}); err != nil { t.Fatal(err) } ev2 := signedEvent(t, 2, 1, 200, nostr.Tags{{"t", "alpha"}}, "second run") save(t, e, ev2) if _, err := e.Query(&nostr.Filter{Kinds: []int{1}}); err != nil { t.Fatal(err) } // Both events must be visible before the merge as well. got, err := e.Query(&nostr.Filter{Tags: map[string][]string{"t": {"zeta"}}}) if err != nil || len(got) != 1 || got[0].ID != ev1.ID { t.Fatalf("pre-merge tag query: %v err=%v", ids(got), err) } if err := e.Close(); err != nil { t.Fatal(err) } e2, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } defer e2.Close() got, err = e2.Query(&nostr.Filter{Kinds: []int{1}}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("after reopen: got %d events, want 2", len(got)) } got, err = e2.Query(&nostr.Filter{Tags: map[string][]string{"t": {"alpha"}}}) if err != nil || len(got) != 1 || got[0].ID != ev2.ID { t.Fatalf("alpha: %v err=%v", ids(got), err) } } func TestTombstonedRepublishRejected(t *testing.T) { e, _ := openTest(t) target := signedEvent(t, 1, 1, 100, nil, "will be deleted") save(t, e, target) del := signedEvent(t, 1, 5, 110, nostr.Tags{{"e", nostr.Hex(target.ID[:])}}, "") save(t, e, del) if ok, err := e.SaveEvent(target); ok || err != ErrDeleted { t.Fatalf("republish of deleted event: ok=%v err=%v", ok, err) } } func TestDeleteAuthorization(t *testing.T) { e, _ := openTest(t) target := signedEvent(t, 1, 1, 100, nil, "delete authorization") save(t, e, target) other := signedEvent(t, 2, 1, 100, nil, "other").PubKey if ok, err := e.Delete(target.ID[:], other[:]); ok || err != nil { t.Fatalf("foreign delete: ok=%v err=%v", ok, err) } if ok, err := e.Delete(make([]byte, 32), target.PubKey[:]); ok || err != nil { t.Fatalf("missing delete: ok=%v err=%v", ok, err) } if ok, err := e.Delete(target.ID[:], target.PubKey[:]); !ok || err != nil { t.Fatalf("owner delete: ok=%v err=%v", ok, err) } } func TestEmptyStore(t *testing.T) { e, _ := openTest(t) got, err := e.Query(&nostr.Filter{}) if err != nil || len(got) != 0 { t.Fatalf("query: %d %v", len(got), err) } n, approx, err := e.Count(&nostr.Filter{}) if err != nil || n != 0 || approx { t.Fatalf("count: %d %v %v", n, approx, err) } if _, err := e.GetByID(make([]byte, 32)); err != ErrNotFound { t.Fatalf("get missing: %v", err) } } func TestResultCapAndApproximateCount(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{MaxResults: 2}) if err != nil { t.Fatal(err) } defer e.Close() for i := 0; i < 4; i++ { save(t, e, signedEvent(t, 1, 1, int64(100+i), nil, "capped")) } got, err := e.Query(&nostr.Filter{}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("query cap: got %d, want 2", len(got)) } if got[0].CreatedAt != 103 || got[1].CreatedAt != 102 { t.Fatalf("cap keeps newest: %d, %d", got[0].CreatedAt, got[1].CreatedAt) } n, approx, err := e.Count(&nostr.Filter{}) if err != nil || n != 2 || !approx { t.Fatalf("count cap: n=%d approx=%v err=%v", n, approx, err) } } func TestCombinedFilterPaths(t *testing.T) { e, _ := openTest(t) tagX := nostr.Tags{{"t", "x"}, {"p", "abc"}} ev1 := signedEvent(t, 1, 1, 100, tagX, "one") ev2 := signedEvent(t, 2, 1, 110, nostr.Tags{{"t", "x"}}, "two") ev3 := signedEvent(t, 1, 2, 120, tagX, "three") ev4 := signedEvent(t, 1, 1, 130, nostr.Tags{{"t", "y"}}, "four") for _, ev := range []*nostr.Event{ev1, ev2, ev3, ev4} { save(t, e, ev) } author1 := nostr.Hex(ev1.PubKey[:]) cases := []struct { name string f *nostr.Filter want []*nostr.Event }{ {"tags+kinds", &nostr.Filter{Kinds: []int{1}, Tags: map[string][]string{"t": {"x"}}}, []*nostr.Event{ev1, ev2}}, {"tags+authors", &nostr.Filter{Authors: []string{author1}, Tags: map[string][]string{"t": {"x"}}}, []*nostr.Event{ev1, ev3}}, {"tags+kinds+authors", &nostr.Filter{Kinds: []int{1}, Authors: []string{author1}, Tags: map[string][]string{"t": {"x"}}}, []*nostr.Event{ev1}}, {"kinds+authors", &nostr.Filter{Kinds: []int{1}, Authors: []string{author1}}, []*nostr.Event{ev1, ev4}}, {"multi tag values", &nostr.Filter{Tags: map[string][]string{"t": {"x", "y"}}}, []*nostr.Event{ev1, ev2, ev3, ev4}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := e.Query(tc.f) if err != nil { t.Fatal(err) } if len(got) != len(tc.want) { t.Fatalf("got %d events (%v), want %d", len(got), ids(got), len(tc.want)) } for _, want := range tc.want { found := false for _, ev := range got { if ev.ID == want.ID { found = true } } if !found { t.Fatalf("missing event %x in %v", want.ID[:4], ids(got)) } } }) } } func TestSearchIgnoresShortWords(t *testing.T) { e, _ := openTest(t) save(t, e, signedEvent(t, 1, 1, 100, nil, "ab cd efgh")) got, err := e.Query(&nostr.Filter{Search: "ab"}) if err != nil { t.Fatal(err) } if len(got) != 0 { t.Fatalf("short word matched: %d", len(got)) } got, err = e.Query(&nostr.Filter{Search: "efgh"}) if err != nil || len(got) != 1 { t.Fatalf("long word: %d %v", len(got), err) } } func TestTornWALTailIsRepaired(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } for i := 0; i < 3; i++ { save(t, e, signedEvent(t, byte(i+1), 1, int64(100+i), nostr.Tags{{"t", "torn"}}, "torn tail")) } broken := t.TempDir() copyTree(t, dir, broken) e.Close() // Chop the last record in half, as a crash mid-write would. walPath := filepath.Join(broken, "wal", "seg-000.dat") info, err := os.Stat(walPath) if err != nil { t.Fatal(err) } if err := os.Truncate(walPath, info.Size()-5); err != nil { t.Fatal(err) } e2, err := Open(broken, Options{}) if err != nil { t.Fatalf("open after torn write: %v", err) } defer e2.Close() got, err := e2.Query(&nostr.Filter{Tags: map[string][]string{"t": {"torn"}}}) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("recovered %d events, want the 2 complete ones", len(got)) } } func TestStaleCheckpointTriggersRebuild(t *testing.T) { dir := t.TempDir() e, err := Open(dir, Options{}) if err != nil { t.Fatal(err) } ev := signedEvent(t, 1, 1, 100, nostr.Tags{{"t", "stale"}}, "stale checkpoint") save(t, e, ev) if err := e.Close(); err != nil { t.Fatal(err) } // Write a valid checkpoint with a position past the end of the WAL. buf := make([]byte, 24) copy(buf, "TCK1") binary.BigEndian.PutUint64(buf[4:12], 1<<40) binary.BigEndian.PutUint64(buf[12:20], 1) binary.BigEndian.PutUint32(buf[20:24], crc32.ChecksumIEEE(buf[:20])) if err := os.WriteFile(filepath.Join(dir, "checkpoint.dat"), buf, 0o644); err != nil { t.Fatal(err) } e2, err := Open(dir, Options{}) if err != nil { t.Fatalf("open with stale checkpoint: %v", err) } defer e2.Close() if _, err := e2.GetByID(ev.ID[:]); err != nil { t.Fatalf("rebuild lost event: %v", err) } } func TestQueryLimitReturnsNewest(t *testing.T) { e, _ := openTest(t) const base = int64(1700000000) var kind1 []*nostr.Event for i := 0; i < 40; i++ { tags := nostr.Tags{{"t", "limit"}} ev := signedEvent(t, byte(1+i%3), 1, base+int64(i), tags, fmt.Sprintf("event %d", i)) save(t, e, ev) kind1 = append(kind1, ev) } other := signedEvent(t, 9, 7, base+1000, nil, "+") save(t, e, other) check := func(name string, f *nostr.Filter, want []*nostr.Event) { t.Helper() got, err := e.Query(f) if err != nil { t.Fatalf("%s: %v", name, err) } if len(got) != len(want) { t.Fatalf("%s: got %d events, want %d (%v)", name, len(got), len(want), ids(got)) } for i := range want { if got[i].ID != want[i].ID { t.Fatalf("%s: position %d got %s want %s", name, i, ids(got)[i], ids(want)[i]) } } } check("kind limit", &nostr.Filter{Kinds: []int{1}, Limit: 3}, []*nostr.Event{kind1[39], kind1[38], kind1[37]}) check("all limit", &nostr.Filter{Limit: 2}, []*nostr.Event{other, kind1[39]}) check("author limit", &nostr.Filter{Authors: []string{nostr.Hex(kind1[39].PubKey[:])}, Limit: 2}, []*nostr.Event{kind1[39], kind1[36]}) check("tag limit", &nostr.Filter{Tags: map[string][]string{"t": {"limit"}}, Limit: 2}, []*nostr.Event{kind1[39], kind1[38]}) check("since limit", &nostr.Filter{Kinds: []int{1}, Since: base + 20, Limit: 2}, []*nostr.Event{kind1[39], kind1[38]}) }