📄 src/web3/subscription/graph-walk.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2import { walkGraph } from './graph-walk';
3
4const { multicallGetSubscriptions } = vi.hoisted(() => ({ multicallGetSubscriptions: vi.fn() }));
5vi.mock('./contract', () => ({ multicallGetSubscriptions }));
6
7beforeEach(() => {
8 multicallGetSubscriptions.mockReset();
9});
10
11function graphOf(adjacency: Record<number, number[]>) {
12 multicallGetSubscriptions.mockImplementation(async (_client: any, userIds: number[]) => {
13 const result = new Map<number, number[]>();
14 for (const id of userIds) result.set(id, adjacency[id] ?? []);
15 return result;
16 });
17}
18
19const fakeClient = {} as any;
20
21describe('walkGraph', () => {
22 it('discovers all reachable nodes in a simple chain', async () => {
23 graphOf({ 1: [2], 2: [3], 3: [] });
24
25 const graph = await walkGraph(fakeClient, 1);
26
27 expect([...graph.nodes.keys()].sort()).toEqual([1, 2, 3]);
28 expect(graph.truncated).toBe(false);
29 });
30
31 it('does not revisit nodes in a cycle', async () => {
32 graphOf({ 1: [2], 2: [1] });
33
34 const graph = await walkGraph(fakeClient, 1);
35
36 expect([...graph.nodes.keys()].sort()).toEqual([1, 2]);
37 // BFS processes one queue-batch per while-loop iteration, not one call per
38 // depth level: [1] is fetched, discovers 2 and enqueues it, then [2] is
39 // fetched on the next iteration and discovers only the already-visited 1.
40 expect(multicallGetSubscriptions).toHaveBeenCalledTimes(2);
41 });
42
43 it('records a node with no subscriptions as an empty array', async () => {
44 graphOf({ 1: [] });
45
46 const graph = await walkGraph(fakeClient, 1);
47
48 expect(graph.nodes.get(1)).toEqual({ userId: 1, subscriptions: [] });
49 });
50
51 it('splits large frontiers into batches of 50', async () => {
52 const adjacency: Record<number, number[]> = { 0: Array.from({ length: 120 }, (_, i) => i + 1) };
53 for (let i = 1; i <= 120; i++) adjacency[i] = [];
54 graphOf(adjacency);
55
56 const graph = await walkGraph(fakeClient, 0);
57
58 expect(graph.nodes.size).toBe(121);
59 // root batch (1) + 120 children split into ceil(120/50) = 3 batches
60 expect(multicallGetSubscriptions).toHaveBeenCalledTimes(4);
61 for (const call of multicallGetSubscriptions.mock.calls) {
62 expect(call[1].length).toBeLessThanOrEqual(50);
63 }
64 });
65
66 it('stops discovering new nodes once maxNodes is reached and reports truncated', async () => {
67 const adjacency: Record<number, number[]> = { 1: [2, 3, 4, 5] };
68 for (const id of [2, 3, 4, 5]) adjacency[id] = [];
69 graphOf(adjacency);
70
71 const graph = await walkGraph(fakeClient, 1, { maxNodes: 2 });
72
73 expect(graph.nodes.size).toBeLessThanOrEqual(2);
74 expect(graph.truncated).toBe(true);
75 });
76
77 it('respects maxDepth by not traversing past the configured depth', async () => {
78 graphOf({ 1: [2], 2: [3], 3: [] });
79
80 const graph = await walkGraph(fakeClient, 1, { maxDepth: 1 });
81
82 expect([...graph.nodes.keys()].sort()).toEqual([1, 2]);
83 });
84
85 it('reports truncated=true when maxDepth cuts off part of the graph (fixed)', async () => {
86 // Regression test: node 3 exists beyond the depth-1 frontier and is never
87 // recorded. truncated must reflect that, not just the maxNodes cap.
88 graphOf({ 1: [2], 2: [3], 3: [] });
89
90 const graph = await walkGraph(fakeClient, 1, { maxDepth: 1 });
91
92 expect(graph.nodes.has(3)).toBe(false);
93 expect(graph.truncated).toBe(true);
94 });
95
96 it('does not report truncated when the walk naturally ends within the depth budget', async () => {
97 // The whole graph is depth <= 1 already, so maxDepth never actually cuts
98 // anything off — truncated should stay false.
99 graphOf({ 1: [2], 2: [] });
100
101 const graph = await walkGraph(fakeClient, 1, { maxDepth: 1 });
102
103 expect([...graph.nodes.keys()].sort()).toEqual([1, 2]);
104 expect(graph.truncated).toBe(false);
105 });
106
107 it('treats maxDepth: 0 as root-only and reports truncated when the root has subscriptions', async () => {
108 graphOf({ 1: [2] });
109
110 const graph = await walkGraph(fakeClient, 1, { maxDepth: 0 });
111
112 expect([...graph.nodes.keys()]).toEqual([1]);
113 expect(graph.truncated).toBe(true);
114 });
115
116 it('does not report truncated when maxDepth: 0 and the root has no subscriptions', async () => {
117 graphOf({ 1: [] });
118
119 const graph = await walkGraph(fakeClient, 1, { maxDepth: 0 });
120
121 expect(graph.truncated).toBe(false);
122 });
123
124 it('does not treat a node reachable via two paths, one over the depth limit, as depth-truncated', async () => {
125 // 3 is reachable at depth 1 (via the direct 1->3 edge) *and* would be
126 // reachable at depth 2 via 1->2->3. Because 3 gets visited through the
127 // depth-1 path first, the depth-2 discovery attempt hits the
128 // `visited.has(sub)` short-circuit (graph-walk.ts:35) before the depth
129 // check ever runs, so this must NOT count as a depth cutoff.
130 graphOf({ 1: [2, 3], 2: [3], 3: [] });
131
132 const graph = await walkGraph(fakeClient, 1, { maxDepth: 1 });
133
134 expect([...graph.nodes.keys()].sort()).toEqual([1, 2, 3]);
135 expect(graph.truncated).toBe(false);
136 });
137
138 it('maxNodes truncation and maxDepth truncation are both reported via the same flag', async () => {
139 const adjacency: Record<number, number[]> = { 1: [2, 3], 2: [4], 3: [] };
140 adjacency[4] = [];
141 graphOf(adjacency);
142
143 // maxNodes caps discovery before depth would even matter here.
144 const graph = await walkGraph(fakeClient, 1, { maxNodes: 2, maxDepth: 5 });
145
146 expect(graph.nodes.size).toBeLessThanOrEqual(2);
147 expect(graph.truncated).toBe(true);
148 });
149
150 it('never sets truncated due to depth when maxDepth is left unspecified, however deep the graph', async () => {
151 graphOf({ 1: [2], 2: [3], 3: [4], 4: [5], 5: [] });
152
153 const graph = await walkGraph(fakeClient, 1);
154
155 expect([...graph.nodes.keys()].sort()).toEqual([1, 2, 3, 4, 5]);
156 expect(graph.truncated).toBe(false);
157 });
158});
159