📄 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