📄 src/web3/subscription/SubscriptionClient.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2
3const mocks = vi.hoisted(() => ({
4 getEvmClient: vi.fn(),
5 multicallGetSubscriptionCounts: vi.fn(),
6 walkGraph: vi.fn(),
7 fetchPatches: vi.fn(),
8 patchToEvmPayload: vi.fn(),
9 deserializePatches: vi.fn(),
10 applyRestoredPatches: vi.fn(),
11 getAllPatchesByUsername: vi.fn(),
12}));
13
14vi.mock('../client/registry', () => ({ getEvmClient: mocks.getEvmClient }));
15vi.mock('./contract', () => ({ multicallGetSubscriptionCounts: mocks.multicallGetSubscriptionCounts }));
16vi.mock('./graph-walk', () => ({ walkGraph: mocks.walkGraph }));
17vi.mock('./patch-resolver', () => ({ fetchPatches: mocks.fetchPatches }));
18vi.mock('./patch-codec', () => ({ patchToEvmPayload: mocks.patchToEvmPayload }));
19vi.mock('@the_library/public/models', () => ({
20 deserializePatches: mocks.deserializePatches,
21 applyRestoredPatches: mocks.applyRestoredPatches,
22 CURRENT_VERSION: 2,
23}));
24vi.mock('../../models/indexdb/patch_storage', () => ({
25 getAllPatchesByUsername: mocks.getAllPatchesByUsername,
26}));
27
28import { SubscriptionClient } from './SubscriptionClient';
29import type { SubscriptionGraph, SyncCursor } from './types';
30
31function fakeClient(overrides: any = {}) {
32 return {
33 ready: Promise.resolve(),
34 chainId: 1116,
35 environment: 'production',
36 subscriptionManager: {
37 getSubscriptions: vi.fn().mockResolvedValue([]),
38 scoreOf: vi.fn().mockResolvedValue(0n),
39 ...(overrides.subscriptionManager ?? {}),
40 },
41 bouncerStorage: {
42 usernameToUserId: vi.fn().mockResolvedValue(0),
43 userIdToUsername: vi.fn().mockResolvedValue(''),
44 ...(overrides.bouncerStorage ?? {}),
45 },
46 ...overrides,
47 };
48}
49
50let client: ReturnType<typeof fakeClient>;
51
52beforeEach(() => {
53 Object.values(mocks).forEach((m) => m.mockReset());
54 client = fakeClient();
55 mocks.getEvmClient.mockReturnValue(client);
56});
57
58function makeGraph(nodes: Array<[number, number[]]>, rootUserId = nodes[0][0], truncated = false): SubscriptionGraph {
59 const map = new Map(nodes.map(([userId, subscriptions]) => [userId, { userId, subscriptions }]));
60 return { rootUserId, nodes: map, truncated };
61}
62
63describe('SubscriptionClient constructor', () => {
64 it('resolves the EvmClient via getEvmClient with the configured chain/environment', () => {
65 new SubscriptionClient({ chainId: 1114, environment: 'staging' });
66 expect(mocks.getEvmClient).toHaveBeenCalledWith(1114, 'staging');
67 });
68
69 it('defaults environment to production when not specified', () => {
70 new SubscriptionClient({ chainId: 1116 });
71 expect(mocks.getEvmClient).toHaveBeenCalledWith(1116, 'production');
72 });
73});
74
75describe('SubscriptionClient.walkGraph', () => {
76 it('awaits client.ready and merges call-site opts over configured defaults', async () => {
77 mocks.walkGraph.mockResolvedValue(makeGraph([[1, []]]));
78 const sc = new SubscriptionClient({ chainId: 1116, maxWalkNodes: 100, maxDepth: 3 });
79
80 await sc.walkGraph(1, { maxDepth: 5 });
81 expect(mocks.walkGraph).toHaveBeenCalledWith(client, 1, { maxNodes: 100, maxDepth: 5 });
82 });
83
84 it('falls back to configured defaults when no per-call opts are given', async () => {
85 mocks.walkGraph.mockResolvedValue(makeGraph([[1, []]]));
86 const sc = new SubscriptionClient({ chainId: 1116, maxWalkNodes: 50, maxDepth: 2 });
87
88 await sc.walkGraph(1);
89 expect(mocks.walkGraph).toHaveBeenCalledWith(client, 1, { maxNodes: 50, maxDepth: 2 });
90 });
91});
92
93describe('SubscriptionClient.sync', () => {
94 it('builds countSnapshot from the walked graph and patchIndex as the max toIndex per user', async () => {
95 mocks.walkGraph.mockResolvedValue(makeGraph([[1, [2, 3]], [2, []], [3, []]]));
96 mocks.fetchPatches.mockResolvedValue([
97 { userId: 1, fromIndex: 0, toIndex: 5, raw: {} },
98 { userId: 1, fromIndex: 5, toIndex: 3, raw: {} }, // out-of-order/lower toIndex, max() must win
99 { userId: 2, fromIndex: 0, toIndex: 1, raw: {} },
100 ]);
101 const sc = new SubscriptionClient({ chainId: 1116 });
102
103 const result = await sc.sync(1);
104
105 expect(result.cursor.countSnapshot).toEqual(new Map([[1, 2], [2, 0], [3, 0]]));
106 expect(result.cursor.patchIndex).toEqual(new Map([[1, 5], [2, 1], [3, 0]]));
107 expect(result.truncated).toBe(false);
108 expect(result.cursor.rootUserId).toBe(1);
109 });
110
111 it('propagates graph.truncated onto the sync result', async () => {
112 mocks.walkGraph.mockResolvedValue(makeGraph([[1, []]], 1, true));
113 mocks.fetchPatches.mockResolvedValue([]);
114 const sc = new SubscriptionClient({ chainId: 1116 });
115
116 const result = await sc.sync(1);
117 expect(result.truncated).toBe(true);
118 });
119});
120
121describe('SubscriptionClient.syncSince', () => {
122 function cursorFor(nodes: Array<[number, number[]]>, counts: Array<[number, number]>, patchIndex: Array<[number, number]> = []): SyncCursor {
123 return {
124 rootUserId: nodes[0][0],
125 knownNodes: new Map(nodes.map(([userId, subscriptions]) => [userId, { userId, subscriptions }])),
126 countSnapshot: new Map(counts),
127 patchIndex: new Map(patchIndex),
128 };
129 }
130
131 it('does not re-walk any node when no subscription counts changed', async () => {
132 const cursor = cursorFor([[1, [2]], [2, []]], [[1, 1], [2, 0]]);
133 mocks.multicallGetSubscriptionCounts.mockResolvedValue(new Map([[1, 1], [2, 0]]));
134 mocks.fetchPatches.mockResolvedValue([]);
135 const sc = new SubscriptionClient({ chainId: 1116 });
136
137 const result = await sc.syncSince(cursor);
138
139 expect(mocks.walkGraph).not.toHaveBeenCalled();
140 expect(client.subscriptionManager.getSubscriptions).not.toHaveBeenCalled();
141 expect(result.cursor.knownNodes).toEqual(cursor.knownNodes);
142 });
143
144 it('re-fetches subscriptions and deep-walks newly discovered nodes for a changed user', async () => {
145 const cursor = cursorFor([[1, [2]], [2, []]], [[1, 1], [2, 0]]);
146 // user 1's count changed from 1 -> 2 (gained a new subscription: user 3)
147 mocks.multicallGetSubscriptionCounts.mockResolvedValue(new Map([[1, 2], [2, 0]]));
148 client.subscriptionManager.getSubscriptions.mockImplementation(async (userId: number) =>
149 userId === 1 ? [2, 3] : []
150 );
151 mocks.walkGraph.mockResolvedValue(makeGraph([[3, []], [4, []]], 3, true));
152 mocks.fetchPatches.mockResolvedValue([]);
153 const sc = new SubscriptionClient({ chainId: 1116 });
154
155 const result = await sc.syncSince(cursor);
156
157 expect(mocks.walkGraph).toHaveBeenCalledWith(client, 3, { maxNodes: undefined, maxDepth: undefined });
158 expect(result.cursor.knownNodes.get(1)).toEqual({ userId: 1, subscriptions: [2, 3] });
159 expect(result.cursor.knownNodes.get(3)).toEqual({ userId: 3, subscriptions: [] });
160 expect(result.cursor.knownNodes.get(4)).toEqual({ userId: 4, subscriptions: [] });
161 expect(result.truncated).toBe(true);
162 });
163
164 it('does not re-walk a newly-subscribed-to node that is already known', async () => {
165 const cursor = cursorFor([[1, [2]], [2, []]], [[1, 1], [2, 0]]);
166 mocks.multicallGetSubscriptionCounts.mockResolvedValue(new Map([[1, 2], [2, 0]]));
167 client.subscriptionManager.getSubscriptions.mockImplementation(async (userId: number) =>
168 userId === 1 ? [2, /* already known */ 2] : []
169 );
170 mocks.fetchPatches.mockResolvedValue([]);
171 const sc = new SubscriptionClient({ chainId: 1116 });
172
173 await sc.syncSince(cursor);
174 expect(mocks.walkGraph).not.toHaveBeenCalled();
175 });
176
177 it('ensures the cursor rootUserId is present in the resulting graph even if it dropped out of knownNodes', async () => {
178 const cursor: SyncCursor = {
179 rootUserId: 99,
180 knownNodes: new Map(),
181 countSnapshot: new Map(),
182 patchIndex: new Map(),
183 };
184 mocks.multicallGetSubscriptionCounts.mockResolvedValue(new Map());
185 client.subscriptionManager.getSubscriptions.mockResolvedValue([7]);
186 mocks.fetchPatches.mockResolvedValue([]);
187 const sc = new SubscriptionClient({ chainId: 1116 });
188
189 const result = await sc.syncSince(cursor);
190 expect(result.cursor.knownNodes.get(99)).toEqual({ userId: 99, subscriptions: [7] });
191 });
192
193 it('carries forward existing patchIndex entries and only bumps ones with newer patches', async () => {
194 const cursor = cursorFor([[1, []]], [[1, 0]], [[1, 10]]);
195 mocks.multicallGetSubscriptionCounts.mockResolvedValue(new Map([[1, 0]]));
196 mocks.fetchPatches.mockResolvedValue([{ userId: 1, fromIndex: 10, toIndex: 15, raw: {} }]);
197 const sc = new SubscriptionClient({ chainId: 1116 });
198
199 const result = await sc.syncSince(cursor);
200 expect(result.cursor.patchIndex.get(1)).toBe(15);
201 expect(mocks.fetchPatches).toHaveBeenCalledWith(client, expect.anything(), cursor.patchIndex, undefined);
202 });
203});
204
205describe('SubscriptionClient.getSubscriptions / getScore', () => {
206 it('getSubscriptions awaits ready and delegates to subscriptionManager', async () => {
207 client.subscriptionManager.getSubscriptions.mockResolvedValue([5, 6]);
208 const sc = new SubscriptionClient({ chainId: 1116 });
209 await expect(sc.getSubscriptions(1)).resolves.toEqual([5, 6]);
210 });
211
212 it('getScore awaits ready and delegates to subscriptionManager.scoreOf', async () => {
213 client.subscriptionManager.scoreOf.mockResolvedValue(42n);
214 const sc = new SubscriptionClient({ chainId: 1116 });
215 await expect(sc.getScore(1)).resolves.toBe(42n);
216 });
217});
218
219describe('SubscriptionClient.loadLibraryByUsername', () => {
220 it('logs and returns without syncing when the username is not found on-chain (userId 0)', async () => {
221 client.bouncerStorage.usernameToUserId.mockResolvedValue(0);
222 const sc = new SubscriptionClient({ chainId: 1116 });
223
224 await sc.loadLibraryByUsername('ghost');
225 expect(mocks.walkGraph).not.toHaveBeenCalled();
226 });
227
228 it('happy path: resolves userId, syncs, and applies each patch under its own author username', async () => {
229 client.bouncerStorage.usernameToUserId.mockResolvedValue(7);
230 client.bouncerStorage.userIdToUsername.mockImplementation(async (id: number) => (id === 7 ? 'alice' : ''));
231 mocks.walkGraph.mockResolvedValue(makeGraph([[7, []]], 7));
232 mocks.fetchPatches.mockResolvedValue([{ userId: 7, fromIndex: 0, toIndex: 1, raw: { foo: 'bar' } }]);
233 mocks.patchToEvmPayload.mockReturnValue({ payload: true });
234 mocks.getAllPatchesByUsername.mockResolvedValue(['stored-patch']);
235 const sc = new SubscriptionClient({ chainId: 1116 });
236
237 await sc.loadLibraryByUsername('alice');
238
239 expect(mocks.deserializePatches).toHaveBeenCalledWith('alice', { payload: true });
240 expect(mocks.getAllPatchesByUsername).toHaveBeenCalledWith('alice');
241 expect(mocks.applyRestoredPatches).toHaveBeenCalledWith(['stored-patch'], 'alice');
242 });
243
244 it('skips a patch whose author userId has no resolvable username, but still applies the rest', async () => {
245 client.bouncerStorage.usernameToUserId.mockResolvedValue(7);
246 client.bouncerStorage.userIdToUsername.mockImplementation(async (id: number) => (id === 7 ? 'alice' : id === 8 ? 'bob' : ''));
247 mocks.walkGraph.mockResolvedValue(makeGraph([[7, [8, 9]], [8, []], [9, []]], 7));
248 mocks.fetchPatches.mockResolvedValue([
249 { userId: 9, fromIndex: 0, toIndex: 1, raw: {} }, // userId 9 has no username -> skip
250 { userId: 8, fromIndex: 0, toIndex: 1, raw: {} },
251 ]);
252 mocks.patchToEvmPayload.mockReturnValue({});
253 mocks.getAllPatchesByUsername.mockResolvedValue([]);
254 const sc = new SubscriptionClient({ chainId: 1116 });
255
256 await sc.loadLibraryByUsername('alice');
257
258 expect(mocks.deserializePatches).toHaveBeenCalledTimes(1);
259 expect(mocks.deserializePatches).toHaveBeenCalledWith('bob', {});
260 });
261
262 it('continues applying remaining patches when one patch throws mid-loop', async () => {
263 client.bouncerStorage.usernameToUserId.mockResolvedValue(7);
264 client.bouncerStorage.userIdToUsername.mockImplementation(async (id: number) => (id === 7 ? 'alice' : id === 8 ? 'bob' : ''));
265 mocks.walkGraph.mockResolvedValue(makeGraph([[7, [8]], [8, []]], 7));
266 mocks.fetchPatches.mockResolvedValue([
267 { userId: 7, fromIndex: 0, toIndex: 1, raw: {} },
268 { userId: 8, fromIndex: 0, toIndex: 1, raw: {} },
269 ]);
270 mocks.patchToEvmPayload.mockReturnValue({});
271 mocks.deserializePatches.mockImplementation(async (username: string) => {
272 if (username === 'alice') throw new Error('deserialize failed');
273 });
274 mocks.getAllPatchesByUsername.mockResolvedValue([]);
275 const sc = new SubscriptionClient({ chainId: 1116 });
276
277 await sc.loadLibraryByUsername('alice');
278
279 expect(mocks.deserializePatches).toHaveBeenCalledTimes(2);
280 expect(mocks.applyRestoredPatches).toHaveBeenCalledTimes(1);
281 expect(mocks.applyRestoredPatches).toHaveBeenCalledWith([], 'bob');
282 });
283
284 it('does not throw when client.ready rejects (top-level catch swallows the error)', async () => {
285 client.ready = Promise.reject(new Error('registry offline'));
286 const sc = new SubscriptionClient({ chainId: 1116 });
287
288 await expect(sc.loadLibraryByUsername('alice')).resolves.toBeUndefined();
289 });
290});
291