Skip to content

Commit a80e264

Browse files
test(frontend): Add tests for NFT IDB caching
Add idb-nfts.api.spec.ts covering serialization, deserialization, singleton network restoration, roundtrip correctness, and clear. Extend LoaderNfts.spec.ts with IDB cache integration tests for cache loading, saving, replacement by live data, and edge cases. Made-with: Cursor
1 parent c2c89f4 commit a80e264

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { ETHEREUM_NETWORK } from '$env/networks/networks.eth.env';
2+
import { clearIdbNfts, getIdbAllNfts, setIdbAllNfts } from '$lib/api/idb-nfts.api';
3+
import type { SerializableNft } from '$lib/types/idb-nfts';
4+
import { mockIdentity, mockPrincipal } from '$tests/mocks/identity.mock';
5+
import { mockValidErc1155Nft, mockValidErc721Nft } from '$tests/mocks/nfts.mock';
6+
import * as idbKeyval from 'idb-keyval';
7+
8+
vi.mock('$app/environment', () => ({
9+
browser: true
10+
}));
11+
12+
describe('idb-nfts.api', () => {
13+
const mockNfts = [mockValidErc721Nft, mockValidErc1155Nft];
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
});
18+
19+
describe('setIdbAllNfts', () => {
20+
it('should not set NFTs in IDB if identity is nullish', async () => {
21+
await setIdbAllNfts({ identity: null, nfts: mockNfts });
22+
23+
expect(idbKeyval.set).not.toHaveBeenCalled();
24+
25+
await setIdbAllNfts({ identity: undefined, nfts: mockNfts });
26+
27+
expect(idbKeyval.set).not.toHaveBeenCalled();
28+
});
29+
30+
it('should serialize and store NFTs in IDB', async () => {
31+
await setIdbAllNfts({ identity: mockIdentity, nfts: mockNfts });
32+
33+
expect(idbKeyval.set).toHaveBeenCalledOnce();
34+
35+
const [[key, storedNfts]] = vi.mocked(idbKeyval.set).mock.calls;
36+
37+
expect(key).toBe(mockPrincipal.toText());
38+
39+
const serialized = storedNfts as SerializableNft[];
40+
41+
expect(serialized).toHaveLength(2);
42+
43+
expect(typeof serialized[0].collection.id).toBe('string');
44+
expect(typeof serialized[0].collection.network.id).toBe('string');
45+
expect(serialized[0].collection.id).toBe(`${mockValidErc721Nft.collection.id.description}`);
46+
expect(serialized[0].collection.network.id).toBe(
47+
`${mockValidErc721Nft.collection.network.id.description}`
48+
);
49+
});
50+
51+
it('should handle empty NFTs list', async () => {
52+
await setIdbAllNfts({ identity: mockIdentity, nfts: [] });
53+
54+
expect(idbKeyval.set).toHaveBeenCalledOnce();
55+
56+
const [[, storedNfts]] = vi.mocked(idbKeyval.set).mock.calls;
57+
58+
expect(storedNfts).toEqual([]);
59+
});
60+
61+
it('should preserve non-symbol fields during serialization', async () => {
62+
await setIdbAllNfts({ identity: mockIdentity, nfts: [mockValidErc1155Nft] });
63+
64+
const [[, storedNfts]] = vi.mocked(idbKeyval.set).mock.calls;
65+
const [serialized] = storedNfts as SerializableNft[];
66+
67+
expect(serialized.id).toBe(mockValidErc1155Nft.id);
68+
expect(serialized.name).toBe(mockValidErc1155Nft.name);
69+
expect(serialized.balance).toBe(mockValidErc1155Nft.balance);
70+
expect(serialized.imageUrl).toBe(mockValidErc1155Nft.imageUrl);
71+
expect(serialized.acquiredAt).toEqual(mockValidErc1155Nft.acquiredAt);
72+
expect(serialized.collection.address).toBe(mockValidErc1155Nft.collection.address);
73+
expect(serialized.collection.name).toBe(mockValidErc1155Nft.collection.name);
74+
expect(serialized.collection.symbol).toBe(mockValidErc1155Nft.collection.symbol);
75+
});
76+
});
77+
78+
describe('getIdbAllNfts', () => {
79+
it('should return undefined when no cached data exists', async () => {
80+
vi.mocked(idbKeyval.get).mockResolvedValue(undefined);
81+
82+
const result = await getIdbAllNfts(mockPrincipal);
83+
84+
expect(result).toBeUndefined();
85+
expect(idbKeyval.get).toHaveBeenCalledWith(mockPrincipal.toText(), expect.any(Object));
86+
});
87+
88+
it('should deserialize NFTs restoring Symbol IDs', async () => {
89+
const serialized: SerializableNft[] = [
90+
{
91+
...mockValidErc721Nft,
92+
collection: {
93+
...mockValidErc721Nft.collection,
94+
id: `${mockValidErc721Nft.collection.id.description}`,
95+
network: {
96+
...mockValidErc721Nft.collection.network,
97+
id: `${mockValidErc721Nft.collection.network.id.description}`
98+
}
99+
}
100+
}
101+
];
102+
103+
vi.mocked(idbKeyval.get).mockResolvedValue(serialized);
104+
105+
const result = await getIdbAllNfts(mockPrincipal);
106+
107+
expect(result).toHaveLength(1);
108+
expect(typeof result?.[0].collection.id).toBe('symbol');
109+
expect(typeof result?.[0].collection.network.id).toBe('symbol');
110+
expect(result?.[0].collection.id.description).toBe(
111+
mockValidErc721Nft.collection.id.description
112+
);
113+
});
114+
115+
it('should restore singleton Network for known networks', async () => {
116+
const serialized: SerializableNft[] = [
117+
{
118+
...mockValidErc721Nft,
119+
collection: {
120+
...mockValidErc721Nft.collection,
121+
id: `${mockValidErc721Nft.collection.id.description}`,
122+
network: {
123+
...ETHEREUM_NETWORK,
124+
id: `${ETHEREUM_NETWORK.id.description}`
125+
}
126+
}
127+
}
128+
];
129+
130+
vi.mocked(idbKeyval.get).mockResolvedValue(serialized);
131+
132+
const result = await getIdbAllNfts(mockPrincipal);
133+
134+
expect(result?.[0].collection.network.id).toBe(ETHEREUM_NETWORK.id);
135+
});
136+
137+
it('should preserve non-symbol fields during deserialization', async () => {
138+
const serialized: SerializableNft[] = [
139+
{
140+
...mockValidErc1155Nft,
141+
collection: {
142+
...mockValidErc1155Nft.collection,
143+
id: `${mockValidErc1155Nft.collection.id.description}`,
144+
network: {
145+
...mockValidErc1155Nft.collection.network,
146+
id: `${mockValidErc1155Nft.collection.network.id.description}`
147+
}
148+
}
149+
}
150+
];
151+
152+
vi.mocked(idbKeyval.get).mockResolvedValue(serialized);
153+
154+
const result = await getIdbAllNfts(mockPrincipal);
155+
const [nft] = result ?? [];
156+
157+
expect(nft.id).toBe(mockValidErc1155Nft.id);
158+
expect(nft.name).toBe(mockValidErc1155Nft.name);
159+
expect(nft.balance).toBe(mockValidErc1155Nft.balance);
160+
expect(nft.acquiredAt).toEqual(mockValidErc1155Nft.acquiredAt);
161+
expect(nft.collection.address).toBe(mockValidErc1155Nft.collection.address);
162+
expect(nft.collection.name).toBe(mockValidErc1155Nft.collection.name);
163+
});
164+
165+
it('should roundtrip serialize then deserialize correctly', async () => {
166+
await setIdbAllNfts({ identity: mockIdentity, nfts: mockNfts });
167+
168+
const [[, storedNfts]] = vi.mocked(idbKeyval.set).mock.calls;
169+
170+
vi.mocked(idbKeyval.get).mockResolvedValue(storedNfts);
171+
172+
const result = await getIdbAllNfts(mockPrincipal);
173+
174+
expect(result).toHaveLength(mockNfts.length);
175+
176+
result?.forEach((nft, i) => {
177+
expect(nft.id).toBe(mockNfts[i].id);
178+
expect(nft.name).toBe(mockNfts[i].name);
179+
expect(typeof nft.collection.id).toBe('symbol');
180+
expect(typeof nft.collection.network.id).toBe('symbol');
181+
expect(nft.collection.id.description).toBe(mockNfts[i].collection.id.description);
182+
expect(nft.collection.network.id.description).toBe(
183+
mockNfts[i].collection.network.id.description
184+
);
185+
});
186+
});
187+
});
188+
189+
describe('clearIdbNfts', () => {
190+
it('should clear the NFTs IDB store', async () => {
191+
await clearIdbNfts();
192+
193+
expect(idbKeyval.clear).toHaveBeenCalledExactlyOnceWith(expect.any(Object));
194+
});
195+
});
196+
});

src/frontend/src/tests/lib/components/loaders/LoaderNfts.spec.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { erc721CustomTokensStore } from '$eth/stores/erc721-custom-tokens.store'
55
import * as extTokenApi from '$icp/api/ext-v2-token.api';
66
import { extCustomTokensStore } from '$icp/stores/ext-custom-tokens.store';
77
import { mapExtNft } from '$icp/utils/nft.utils';
8+
import * as idbNftsApi from '$lib/api/idb-nfts.api';
89
import LoaderNfts from '$lib/components/loaders/LoaderNfts.svelte';
910
import { ethAddressStore } from '$lib/stores/address.store';
1011
import { nftStore } from '$lib/stores/nft.store';
@@ -294,4 +295,133 @@ describe('LoaderNfts', async () => {
294295
});
295296
});
296297
});
298+
299+
describe('IDB cache', () => {
300+
let getIdbAllNftsSpy: MockInstance;
301+
let setIdbAllNftsSpy: MockInstance;
302+
303+
beforeEach(() => {
304+
getIdbAllNftsSpy = vi.spyOn(idbNftsApi, 'getIdbAllNfts');
305+
setIdbAllNftsSpy = vi.spyOn(idbNftsApi, 'setIdbAllNfts');
306+
307+
getIdbAllNftsSpy.mockResolvedValue(undefined);
308+
setIdbAllNftsSpy.mockResolvedValue(undefined);
309+
});
310+
311+
it('should load cached NFTs from IDB on first render', async () => {
312+
const cachedNfts = [mockErc721Nft1, mockErc721Nft2];
313+
getIdbAllNftsSpy.mockResolvedValue(cachedNfts);
314+
315+
erc721CustomTokensStore.setAll([{ data: mockedEnabledAzukiToken, certified: false }]);
316+
317+
mockGetNftsForOwner.mockResolvedValueOnce([mockErc721Nft1]);
318+
319+
render(LoaderNfts);
320+
321+
await waitFor(() => {
322+
expect(getIdbAllNftsSpy).toHaveBeenCalledExactlyOnceWith(mockPrincipal);
323+
});
324+
});
325+
326+
it('should not call getIdbAllNfts when identity is nullish', async () => {
327+
mockAuthStore(null);
328+
329+
render(LoaderNfts);
330+
331+
await waitFor(() => {
332+
expect(getIdbAllNftsSpy).not.toHaveBeenCalled();
333+
});
334+
});
335+
336+
it('should populate store with cached NFTs before live data arrives', async () => {
337+
const cachedNfts = [mockErc721Nft1, mockErc721Nft2];
338+
getIdbAllNftsSpy.mockResolvedValue(cachedNfts);
339+
340+
erc721CustomTokensStore.setAll([{ data: mockedEnabledAzukiToken, certified: false }]);
341+
342+
mockGetNftsForOwner.mockImplementation(
343+
() =>
344+
new Promise((resolve) => {
345+
setTimeout(() => resolve([mockErc721Nft3]), 100);
346+
})
347+
);
348+
349+
render(LoaderNfts);
350+
351+
await waitFor(() => {
352+
const storeValue = get(nftStore);
353+
354+
expect(storeValue).toEqual(expect.arrayContaining(cachedNfts));
355+
});
356+
});
357+
358+
it('should save NFTs to IDB after live data is loaded', async () => {
359+
getIdbAllNftsSpy.mockResolvedValue(undefined);
360+
361+
erc721CustomTokensStore.setAll([{ data: mockedEnabledAzukiToken, certified: false }]);
362+
363+
mockGetNftsForOwner.mockResolvedValueOnce([mockErc721Nft1]);
364+
365+
render(LoaderNfts);
366+
367+
await waitFor(() => {
368+
expect(setIdbAllNftsSpy).toHaveBeenCalled();
369+
370+
const { calls } = setIdbAllNftsSpy.mock;
371+
const [lastCall] = calls[calls.length - 1];
372+
373+
expect(lastCall.identity).toBe(mockIdentity);
374+
expect(lastCall.nfts.length).toBeGreaterThan(0);
375+
});
376+
});
377+
378+
it('should replace cached NFTs with live data via setAllByNetwork', async () => {
379+
const cachedNft = {
380+
...mockErc721Nft1,
381+
name: 'Cached Name'
382+
};
383+
getIdbAllNftsSpy.mockResolvedValue([cachedNft]);
384+
385+
erc721CustomTokensStore.setAll([{ data: mockedEnabledAzukiToken, certified: false }]);
386+
387+
const liveNft = { ...mockErc721Nft1, name: 'Live Name' };
388+
mockGetNftsForOwner.mockResolvedValueOnce([liveNft]);
389+
390+
render(LoaderNfts);
391+
392+
await waitFor(() => {
393+
const storeValue = get(nftStore);
394+
395+
expect(storeValue).toEqual([liveNft]);
396+
});
397+
});
398+
399+
it('should skip cache loading on subsequent loads', async () => {
400+
getIdbAllNftsSpy.mockResolvedValue([mockErc721Nft1]);
401+
402+
erc721CustomTokensStore.setAll([{ data: mockedEnabledAzukiToken, certified: false }]);
403+
404+
mockGetNftsForOwner.mockResolvedValue([mockErc721Nft1]);
405+
406+
render(LoaderNfts);
407+
408+
await waitFor(() => {
409+
expect(getIdbAllNftsSpy).toHaveBeenCalledOnce();
410+
});
411+
});
412+
413+
it('should handle empty cache gracefully', async () => {
414+
getIdbAllNftsSpy.mockResolvedValue([]);
415+
416+
erc721CustomTokensStore.setAll([{ data: mockedEnabledAzukiToken, certified: false }]);
417+
418+
mockGetNftsForOwner.mockResolvedValueOnce([mockErc721Nft1]);
419+
420+
render(LoaderNfts);
421+
422+
await waitFor(() => {
423+
expect(get(nftStore)).toEqual([mockErc721Nft1]);
424+
});
425+
});
426+
});
297427
});

0 commit comments

Comments
 (0)