graph-kind-filter_test.go raw
1 //go:build !(js && wasm)
2
3 package database
4
5 import (
6 "context"
7 "sort"
8 "testing"
9
10 "git.smesh.lol/orly/pkg/database/indexes/types"
11 "git.smesh.lol/orly/pkg/nostr/encoders/event"
12 "git.smesh.lol/orly/pkg/nostr/encoders/hex"
13 "git.smesh.lol/orly/pkg/nostr/encoders/tag"
14 )
15
16 // kindFilterTestFixture holds all pubkeys and their hex strings for the test graph:
17 //
18 // pkA --kind3--> pkB, pkE (pkA follows pkB and pkE)
19 // pkA --kind10000--> pkC (pkA mutes pkC)
20 // pkA --kind1984--> pkD (pkA reports pkD)
21 // pkF --kind3--> pkB (pkF also follows pkB)
22 type kindFilterTestFixture struct {
23 db *D
24 ctx context.Context
25 pkAHex string
26 pkBHex string
27 pkCHex string
28 pkDHex string
29 pkEHex string
30 pkFHex string
31 }
32
33 // resolveSerials converts a slice of pubkey serials to a set of hex strings.
34 func resolveSerials(t *testing.T, db *D, serials []*types.Uint40) map[string]bool {
35 t.Helper()
36 m := make(map[string]bool, len(serials))
37 for _, s := range serials {
38 h, err := db.GetPubkeyHexFromSerial(s)
39 if err != nil {
40 t.Errorf("GetPubkeyHexFromSerial: %v", err)
41 continue
42 }
43 m[h] = true
44 }
45 return m
46 }
47
48 func setupKindFilterFixture(t *testing.T) *kindFilterTestFixture {
49 t.Helper()
50
51 ctx, cancel := context.WithCancel(context.Background())
52 t.Cleanup(cancel)
53
54 db, err := New(ctx, cancel, t.TempDir(), "off")
55 if err != nil {
56 t.Fatalf("New db: %v", err)
57 }
58 t.Cleanup(func() { db.Close() })
59
60 pkA, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000001")
61 pkB, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000002")
62 pkC, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000003")
63 pkD, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000004")
64 pkE, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000005")
65 pkF, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000006")
66
67 makeEvent := func(idByte byte, author []byte, kind uint16, targets ...[]byte) *event.E {
68 id := make([]byte, 32)
69 id[0] = idByte
70 sig := make([]byte, 64)
71 sig[0] = idByte
72 ptags := make([]*tag.T, 0, len(targets))
73 for _, tgt := range targets {
74 ptags = append(ptags, tag.NewFromAny("p", hex.Enc(tgt)))
75 }
76 return &event.E{
77 ID: id,
78 Pubkey: author,
79 CreatedAt: int64(1000000 + int(idByte)),
80 Kind: kind,
81 Content: []byte(""),
82 Sig: sig,
83 Tags: tag.NewS(ptags...),
84 }
85 }
86
87 for _, ev := range []*event.E{
88 makeEvent(0x01, pkA, 3, pkB, pkE), // pkA kind-3 follows pkB, pkE
89 makeEvent(0x02, pkA, 10000, pkC), // pkA kind-10000 mutes pkC
90 makeEvent(0x03, pkA, 1984, pkD), // pkA kind-1984 reports pkD
91 makeEvent(0x04, pkF, 3, pkB), // pkF kind-3 follows pkB
92 } {
93 if _, err := db.SaveEvent(ctx, ev); err != nil {
94 t.Fatalf("SaveEvent kind=%d: %v", ev.Kind, err)
95 }
96 }
97
98 return &kindFilterTestFixture{
99 db: db,
100 ctx: ctx,
101 pkAHex: hex.Enc(pkA),
102 pkBHex: hex.Enc(pkB),
103 pkCHex: hex.Enc(pkC),
104 pkDHex: hex.Enc(pkD),
105 pkEHex: hex.Enc(pkE),
106 pkFHex: hex.Enc(pkF),
107 }
108 }
109
110 // TestGetFollowsByKindViaPPG verifies the forward kind-filtered PPG scan.
111 func TestGetFollowsByKindViaPPG(t *testing.T) {
112 f := setupKindFilterFixture(t)
113 db := f.db
114
115 pkASerial, err := db.PubkeyHexToSerial(f.pkAHex)
116 if err != nil {
117 t.Fatalf("PubkeyHexToSerial(A): %v", err)
118 }
119
120 t.Run("GetFollowsViaPPG returns all kinds", func(t *testing.T) {
121 serials, err := db.GetFollowsViaPPG(pkASerial)
122 if err != nil {
123 t.Fatalf("GetFollowsViaPPG: %v", err)
124 }
125 got := make(map[string]bool, len(serials))
126 for _, s := range serials {
127 h, err := db.GetPubkeyHexFromSerial(s)
128 if err != nil {
129 t.Errorf("GetPubkeyHexFromSerial: %v", err)
130 continue
131 }
132 got[h] = true
133 }
134 want := map[string]bool{
135 f.pkBHex: true, // kind-3
136 f.pkEHex: true, // kind-3
137 f.pkCHex: true, // kind-10000
138 f.pkDHex: true, // kind-1984
139 }
140 if len(got) != len(want) {
141 t.Errorf("GetFollowsViaPPG: got %d results, want %d; got=%v want=%v", len(got), len(want), got, want)
142 return
143 }
144 for pk := range want {
145 if !got[pk] {
146 t.Errorf("GetFollowsViaPPG: missing expected pubkey %s", pk[:8])
147 }
148 }
149 })
150
151 t.Run("GetFollowsByKindViaPPG kind-3 returns only follows", func(t *testing.T) {
152 serials, err := db.GetFollowsByKindViaPPG(pkASerial, 3)
153 if err != nil {
154 t.Fatalf("GetFollowsByKindViaPPG(3): %v", err)
155 }
156 got := make(map[string]bool, len(serials))
157 for _, s := range serials {
158 h, err := db.GetPubkeyHexFromSerial(s)
159 if err != nil {
160 t.Errorf("GetPubkeyHexFromSerial: %v", err)
161 continue
162 }
163 got[h] = true
164 }
165 want := map[string]bool{f.pkBHex: true, f.pkEHex: true}
166 if len(got) != len(want) {
167 t.Errorf("GetFollowsByKindViaPPG(3): got %d, want 2; got=%v", len(got), got)
168 return
169 }
170 for pk := range want {
171 if !got[pk] {
172 t.Errorf("GetFollowsByKindViaPPG(3): missing %s", pk[:8])
173 }
174 }
175 // Must NOT contain muted or reported pubkeys
176 if got[f.pkCHex] {
177 t.Errorf("GetFollowsByKindViaPPG(3): should NOT return muted pubkey C")
178 }
179 if got[f.pkDHex] {
180 t.Errorf("GetFollowsByKindViaPPG(3): should NOT return reported pubkey D")
181 }
182 })
183
184 t.Run("GetFollowsByKindViaPPG kind-10000 returns only mutes", func(t *testing.T) {
185 serials, err := db.GetFollowsByKindViaPPG(pkASerial, 10000)
186 if err != nil {
187 t.Fatalf("GetFollowsByKindViaPPG(10000): %v", err)
188 }
189 got := make(map[string]bool, len(serials))
190 for _, s := range serials {
191 h, err := db.GetPubkeyHexFromSerial(s)
192 if err != nil {
193 continue
194 }
195 got[h] = true
196 }
197 if len(got) != 1 || !got[f.pkCHex] {
198 t.Errorf("GetFollowsByKindViaPPG(10000): want [C], got %v", got)
199 }
200 })
201
202 t.Run("GetFollowsByKindViaPPG kind-1984 returns only reports", func(t *testing.T) {
203 serials, err := db.GetFollowsByKindViaPPG(pkASerial, 1984)
204 if err != nil {
205 t.Fatalf("GetFollowsByKindViaPPG(1984): %v", err)
206 }
207 got := make(map[string]bool, len(serials))
208 for _, s := range serials {
209 h, err := db.GetPubkeyHexFromSerial(s)
210 if err != nil {
211 continue
212 }
213 got[h] = true
214 }
215 if len(got) != 1 || !got[f.pkDHex] {
216 t.Errorf("GetFollowsByKindViaPPG(1984): want [D], got %v", got)
217 }
218 })
219
220 t.Run("GetFollowsByKindViaPPG unknown kind returns empty", func(t *testing.T) {
221 serials, err := db.GetFollowsByKindViaPPG(pkASerial, 9999)
222 if err != nil {
223 t.Fatalf("GetFollowsByKindViaPPG(9999): %v", err)
224 }
225 if len(serials) != 0 {
226 t.Errorf("GetFollowsByKindViaPPG(9999): want empty, got %d results", len(serials))
227 }
228 })
229 }
230
231 // TestGetFollowersByKindViaGPP verifies the reverse kind-filtered GPP scan.
232 func TestGetFollowersByKindViaGPP(t *testing.T) {
233 f := setupKindFilterFixture(t)
234 db := f.db
235
236 pkBSerial, err := db.PubkeyHexToSerial(f.pkBHex)
237 if err != nil {
238 t.Fatalf("PubkeyHexToSerial(B): %v", err)
239 }
240 pkCSerial, err := db.PubkeyHexToSerial(f.pkCHex)
241 if err != nil {
242 t.Fatalf("PubkeyHexToSerial(C): %v", err)
243 }
244 pkDSerial, err := db.PubkeyHexToSerial(f.pkDHex)
245 if err != nil {
246 t.Fatalf("PubkeyHexToSerial(D): %v", err)
247 }
248
249 t.Run("GetFollowersViaGPP(B) returns all kinds including A and F", func(t *testing.T) {
250 serials, err := db.GetFollowersViaGPP(pkBSerial)
251 if err != nil {
252 t.Fatalf("GetFollowersViaGPP(B): %v", err)
253 }
254 got := make(map[string]bool, len(serials))
255 for _, s := range serials {
256 h, err := db.GetPubkeyHexFromSerial(s)
257 if err != nil {
258 continue
259 }
260 got[h] = true
261 }
262 if !got[f.pkAHex] {
263 t.Errorf("GetFollowersViaGPP(B): missing A")
264 }
265 if !got[f.pkFHex] {
266 t.Errorf("GetFollowersViaGPP(B): missing F")
267 }
268 })
269
270 t.Run("GetFollowersByKindViaGPP(B, 3) returns A and F", func(t *testing.T) {
271 serials, err := db.GetFollowersByKindViaGPP(pkBSerial, 3)
272 if err != nil {
273 t.Fatalf("GetFollowersByKindViaGPP(B,3): %v", err)
274 }
275 got := make(map[string]bool, len(serials))
276 for _, s := range serials {
277 h, err := db.GetPubkeyHexFromSerial(s)
278 if err != nil {
279 continue
280 }
281 got[h] = true
282 }
283 if len(got) != 2 {
284 t.Errorf("GetFollowersByKindViaGPP(B,3): want 2, got %d: %v", len(got), got)
285 return
286 }
287 if !got[f.pkAHex] {
288 t.Errorf("GetFollowersByKindViaGPP(B,3): missing A")
289 }
290 if !got[f.pkFHex] {
291 t.Errorf("GetFollowersByKindViaGPP(B,3): missing F")
292 }
293 })
294
295 t.Run("GetFollowersByKindViaGPP(B, 10000) returns empty — nobody mutes B", func(t *testing.T) {
296 serials, err := db.GetFollowersByKindViaGPP(pkBSerial, 10000)
297 if err != nil {
298 t.Fatalf("GetFollowersByKindViaGPP(B,10000): %v", err)
299 }
300 if len(serials) != 0 {
301 t.Errorf("GetFollowersByKindViaGPP(B,10000): want empty, got %d", len(serials))
302 }
303 })
304
305 t.Run("GetFollowersByKindViaGPP(C, 10000) returns A — A mutes C", func(t *testing.T) {
306 serials, err := db.GetFollowersByKindViaGPP(pkCSerial, 10000)
307 if err != nil {
308 t.Fatalf("GetFollowersByKindViaGPP(C,10000): %v", err)
309 }
310 got := make(map[string]bool, len(serials))
311 for _, s := range serials {
312 h, err := db.GetPubkeyHexFromSerial(s)
313 if err != nil {
314 continue
315 }
316 got[h] = true
317 }
318 if len(got) != 1 || !got[f.pkAHex] {
319 t.Errorf("GetFollowersByKindViaGPP(C,10000): want [A], got %v", got)
320 }
321 })
322
323 t.Run("GetFollowersByKindViaGPP(C, 3) returns empty — A only mutes C, does not follow", func(t *testing.T) {
324 serials, err := db.GetFollowersByKindViaGPP(pkCSerial, 3)
325 if err != nil {
326 t.Fatalf("GetFollowersByKindViaGPP(C,3): %v", err)
327 }
328 if len(serials) != 0 {
329 got := make([]string, 0, len(serials))
330 for _, s := range serials {
331 h, _ := db.GetPubkeyHexFromSerial(s)
332 got = append(got, h[:8])
333 }
334 t.Errorf("GetFollowersByKindViaGPP(C,3): want empty (A mutes C but does not kind-3 follow C), got %v", got)
335 }
336 })
337
338 t.Run("GetFollowersByKindViaGPP(D, 1984) returns A — A reports D", func(t *testing.T) {
339 serials, err := db.GetFollowersByKindViaGPP(pkDSerial, 1984)
340 if err != nil {
341 t.Fatalf("GetFollowersByKindViaGPP(D,1984): %v", err)
342 }
343 got := make(map[string]bool, len(serials))
344 for _, s := range serials {
345 h, err := db.GetPubkeyHexFromSerial(s)
346 if err != nil {
347 continue
348 }
349 got[h] = true
350 }
351 if len(got) != 1 || !got[f.pkAHex] {
352 t.Errorf("GetFollowersByKindViaGPP(D,1984): want [A], got %v", got)
353 }
354 })
355
356 t.Run("GetFollowersByKindViaGPP(D, 3) returns empty — A only reports D", func(t *testing.T) {
357 serials, err := db.GetFollowersByKindViaGPP(pkDSerial, 3)
358 if err != nil {
359 t.Fatalf("GetFollowersByKindViaGPP(D,3): %v", err)
360 }
361 if len(serials) != 0 {
362 t.Errorf("GetFollowersByKindViaGPP(D,3): want empty, got %d", len(serials))
363 }
364 })
365 }
366
367 // TestKindFilterIsolation is the key regression test for the grapevine zero-influence bug.
368 //
369 // The bug: GetFollowersViaGPP (all kinds) fed getFollowers() in the engine.
370 // When pkA both follows AND mutes pkX (different targets here, but same mechanism),
371 // a node that is only muted by pkA should NOT appear as a kind-3 follower.
372 // This test proves the kind-3 filtered path is correctly isolated from mutes/reports.
373 func TestKindFilterIsolation(t *testing.T) {
374 f := setupKindFilterFixture(t)
375 db := f.db
376
377 pkBSerial, _ := db.PubkeyHexToSerial(f.pkBHex)
378 pkCSerial, _ := db.PubkeyHexToSerial(f.pkCHex)
379 pkDSerial, _ := db.PubkeyHexToSerial(f.pkDHex)
380
381 // Followers of B via kind-3 should be {A, F} — not empty
382 followersOfB, err := db.GetFollowersByKindViaGPP(pkBSerial, 3)
383 if err != nil {
384 t.Fatalf("GetFollowersByKindViaGPP(B,3): %v", err)
385 }
386 if len(followersOfB) != 2 {
387 t.Errorf("B should have 2 kind-3 followers (A and F), got %d", len(followersOfB))
388 }
389
390 // C is only muted, not followed — kind-3 followers of C must be empty
391 kind3FollowersOfC, err := db.GetFollowersByKindViaGPP(pkCSerial, 3)
392 if err != nil {
393 t.Fatalf("GetFollowersByKindViaGPP(C,3): %v", err)
394 }
395 if len(kind3FollowersOfC) != 0 {
396 t.Errorf("C has no kind-3 followers (A only mutes C), but got %d results — this is the double-count bug", len(kind3FollowersOfC))
397 }
398
399 // Muters of C must be {A}
400 mutersOfC, err := db.GetFollowersByKindViaGPP(pkCSerial, 10000)
401 if err != nil {
402 t.Fatalf("GetFollowersByKindViaGPP(C,10000): %v", err)
403 }
404 if len(mutersOfC) != 1 {
405 t.Errorf("C should have 1 muter (A), got %d", len(mutersOfC))
406 }
407
408 // D is only reported, not followed
409 kind3FollowersOfD, err := db.GetFollowersByKindViaGPP(pkDSerial, 3)
410 if err != nil {
411 t.Fatalf("GetFollowersByKindViaGPP(D,3): %v", err)
412 }
413 if len(kind3FollowersOfD) != 0 {
414 t.Errorf("D has no kind-3 followers (A only reports D), but got %d results", len(kind3FollowersOfD))
415 }
416
417 // kind-3 follows of A via PPG: only B and E — not C (muted) or D (reported)
418 pkASerial, _ := db.PubkeyHexToSerial(f.pkAHex)
419 kind3FollowsOfA, err := db.GetFollowsByKindViaPPG(pkASerial, 3)
420 if err != nil {
421 t.Fatalf("GetFollowsByKindViaPPG(A,3): %v", err)
422 }
423 if len(kind3FollowsOfA) != 2 {
424 got := make([]string, 0, len(kind3FollowsOfA))
425 for _, s := range kind3FollowsOfA {
426 h, _ := db.GetPubkeyHexFromSerial(s)
427 got = append(got, h[:8])
428 }
429 sort.Strings(got)
430 t.Errorf("A has 2 kind-3 follows (B,E), got %d: %v — mutes/reports must not bleed into kind-3", len(kind3FollowsOfA), got)
431 }
432 }
433