Skip to content

Commit 8a346d3

Browse files
authored
Add Basenames Unit Tests (#2893)
1 parent b0df452 commit 8a346d3

File tree

110 files changed

+35521
-116
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+35521
-116
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Mock for ox/BlockOverrides module
2+
module.exports = {
3+
fromRpc: jest.fn(),
4+
toRpc: jest.fn(),
5+
};
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import { GET } from './route';
5+
import satori from 'satori';
6+
import twemoji from 'twemoji';
7+
import { readFile } from 'node:fs/promises';
8+
9+
// Mock satori
10+
jest.mock('satori', () => jest.fn().mockResolvedValue('<svg>mock svg</svg>'));
11+
12+
// Mock twemoji
13+
jest.mock('twemoji', () => ({
14+
convert: {
15+
toCodePoint: jest.fn().mockReturnValue('1f600'),
16+
},
17+
}));
18+
19+
// Mock fs/promises for font loading
20+
jest.mock('node:fs/promises', () => ({
21+
readFile: jest.fn().mockResolvedValue(Buffer.from('mock font data')),
22+
}));
23+
24+
const mockSatori = satori as jest.MockedFunction<typeof satori>;
25+
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
26+
27+
// Mock usernames utils
28+
const mockGetBasenameImage = jest.fn();
29+
const mockGetChainForBasename = jest.fn();
30+
const mockFetchResolverAddress = jest.fn();
31+
jest.mock('apps/web/src/utils/usernames', () => ({
32+
getBasenameImage: (...args: unknown[]) => mockGetBasenameImage(...args) as unknown,
33+
getChainForBasename: (...args: unknown[]) => mockGetChainForBasename(...args) as unknown,
34+
fetchResolverAddress: (...args: unknown[]) => mockFetchResolverAddress(...args) as unknown,
35+
UsernameTextRecordKeys: {
36+
Avatar: 'avatar',
37+
},
38+
}));
39+
40+
// Mock useBasenameChain
41+
const mockGetEnsText = jest.fn();
42+
const mockGetBasenamePublicClient = jest.fn();
43+
jest.mock('apps/web/src/hooks/useBasenameChain', () => ({
44+
getBasenamePublicClient: (...args: unknown[]) => mockGetBasenamePublicClient(...args) as unknown,
45+
}));
46+
47+
// Mock constants
48+
jest.mock('apps/web/src/constants', () => ({
49+
isDevelopment: false,
50+
}));
51+
52+
// Mock urls utility
53+
jest.mock('apps/web/src/utils/urls', () => ({
54+
IsValidIpfsUrl: jest.fn().mockReturnValue(false),
55+
getIpfsGatewayUrl: jest.fn(),
56+
}));
57+
58+
// Mock images utility
59+
jest.mock('apps/web/src/utils/images', () => ({
60+
getCloudinaryMediaUrl: jest.fn(({ media }) => `https://cloudinary.com/${media}`),
61+
}));
62+
63+
// Mock ImageRaw component
64+
jest.mock('apps/web/src/components/ImageRaw', () => ({
65+
__esModule: true,
66+
default: ({ src, alt }: { src: string; alt: string }) => `ImageRaw: ${src} - ${alt}`,
67+
}));
68+
69+
// Mock logger
70+
jest.mock('apps/web/src/utils/logger', () => ({
71+
logger: {
72+
error: jest.fn(),
73+
},
74+
}));
75+
76+
describe('cardImage.svg route', () => {
77+
beforeEach(() => {
78+
jest.clearAllMocks();
79+
80+
// Default mock implementations
81+
mockGetBasenameImage.mockReturnValue({ src: '/default-avatar.png' });
82+
mockGetChainForBasename.mockReturnValue({ id: 8453 });
83+
mockFetchResolverAddress.mockResolvedValue('0x1234567890123456789012345678901234567890');
84+
mockGetBasenamePublicClient.mockReturnValue({
85+
getEnsText: mockGetEnsText,
86+
});
87+
mockGetEnsText.mockResolvedValue(null);
88+
});
89+
90+
describe('GET', () => {
91+
it('should return an SVG response with correct content type', async () => {
92+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
93+
const params = Promise.resolve({ name: 'alice' });
94+
95+
const response = await GET(request, { params });
96+
97+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
98+
});
99+
100+
it('should return SVG content in the response body', async () => {
101+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
102+
const params = Promise.resolve({ name: 'alice' });
103+
104+
const response = await GET(request, { params });
105+
const body = await response.text();
106+
107+
expect(mockSatori).toHaveBeenCalled();
108+
expect(body).toBe('<svg>mock svg</svg>');
109+
});
110+
111+
it('should use username from params', async () => {
112+
const request = new Request('https://www.base.org/api/basenames/testuser/assets/cardImage.svg');
113+
const params = Promise.resolve({ name: 'testuser' });
114+
115+
await GET(request, { params });
116+
117+
expect(mockGetChainForBasename).toHaveBeenCalledWith('testuser');
118+
});
119+
120+
it('should default to "yourname" when name param is missing', async () => {
121+
const request = new Request('https://www.base.org/api/basenames/assets/cardImage.svg');
122+
const params = Promise.resolve({ name: undefined as unknown as string });
123+
124+
await GET(request, { params });
125+
126+
expect(mockGetChainForBasename).toHaveBeenCalledWith('yourname');
127+
});
128+
129+
it('should fetch avatar from ENS text record', async () => {
130+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
131+
const params = Promise.resolve({ name: 'alice' });
132+
133+
await GET(request, { params });
134+
135+
expect(mockGetBasenamePublicClient).toHaveBeenCalledWith(8453);
136+
expect(mockGetEnsText).toHaveBeenCalledWith({
137+
name: 'alice',
138+
key: 'avatar',
139+
universalResolverAddress: '0x1234567890123456789012345678901234567890',
140+
});
141+
});
142+
143+
it('should use default image when no avatar is set', async () => {
144+
mockGetEnsText.mockResolvedValue(null);
145+
146+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
147+
const params = Promise.resolve({ name: 'alice' });
148+
149+
await GET(request, { params });
150+
151+
expect(mockGetBasenameImage).toHaveBeenCalledWith('alice');
152+
});
153+
154+
it('should handle custom avatar URL', async () => {
155+
// eslint-disable-next-line @typescript-eslint/no-require-imports
156+
const { getCloudinaryMediaUrl } = require('apps/web/src/utils/images') as { getCloudinaryMediaUrl: jest.Mock };
157+
mockGetEnsText.mockResolvedValue('https://example.com/avatar.png');
158+
159+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
160+
const params = Promise.resolve({ name: 'alice' });
161+
162+
await GET(request, { params });
163+
164+
expect(getCloudinaryMediaUrl).toHaveBeenCalledWith({
165+
media: 'https://example.com/avatar.png',
166+
format: 'png',
167+
width: 120,
168+
});
169+
});
170+
171+
it('should handle IPFS avatar URL', async () => {
172+
// eslint-disable-next-line @typescript-eslint/no-require-imports
173+
const { IsValidIpfsUrl, getIpfsGatewayUrl } = require('apps/web/src/utils/urls') as { IsValidIpfsUrl: jest.Mock; getIpfsGatewayUrl: jest.Mock };
174+
// eslint-disable-next-line @typescript-eslint/no-require-imports
175+
const { getCloudinaryMediaUrl } = require('apps/web/src/utils/images') as { getCloudinaryMediaUrl: jest.Mock };
176+
IsValidIpfsUrl.mockReturnValue(true);
177+
getIpfsGatewayUrl.mockReturnValue('https://ipfs.io/ipfs/Qm123');
178+
mockGetEnsText.mockResolvedValue('ipfs://Qm123');
179+
180+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
181+
const params = Promise.resolve({ name: 'alice' });
182+
183+
await GET(request, { params });
184+
185+
expect(IsValidIpfsUrl).toHaveBeenCalledWith('ipfs://Qm123');
186+
expect(getIpfsGatewayUrl).toHaveBeenCalledWith('ipfs://Qm123');
187+
expect(getCloudinaryMediaUrl).toHaveBeenCalledWith({
188+
media: 'https://ipfs.io/ipfs/Qm123',
189+
format: 'png',
190+
width: 120,
191+
});
192+
});
193+
194+
it('should fallback to default image when IPFS gateway URL is null', async () => {
195+
// eslint-disable-next-line @typescript-eslint/no-require-imports
196+
const { IsValidIpfsUrl, getIpfsGatewayUrl } = require('apps/web/src/utils/urls') as { IsValidIpfsUrl: jest.Mock; getIpfsGatewayUrl: jest.Mock };
197+
// eslint-disable-next-line @typescript-eslint/no-require-imports
198+
const { getCloudinaryMediaUrl } = require('apps/web/src/utils/images') as { getCloudinaryMediaUrl: jest.Mock };
199+
IsValidIpfsUrl.mockReturnValue(true);
200+
getIpfsGatewayUrl.mockReturnValue(null);
201+
mockGetEnsText.mockResolvedValue('ipfs://Qm123');
202+
203+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
204+
const params = Promise.resolve({ name: 'alice' });
205+
206+
await GET(request, { params });
207+
208+
expect(IsValidIpfsUrl).toHaveBeenCalledWith('ipfs://Qm123');
209+
expect(getIpfsGatewayUrl).toHaveBeenCalledWith('ipfs://Qm123');
210+
// When gateway returns null, image source remains unchanged (default image with base.org domain prefix)
211+
expect(getCloudinaryMediaUrl).toHaveBeenCalledWith({
212+
media: 'https://www.base.org/default-avatar.png',
213+
format: 'png',
214+
width: 120,
215+
});
216+
});
217+
218+
it('should handle errors when fetching avatar gracefully', async () => {
219+
// eslint-disable-next-line @typescript-eslint/no-require-imports
220+
const { logger } = require('apps/web/src/utils/logger') as { logger: { error: jest.Mock } };
221+
const error = new Error('Failed to fetch avatar');
222+
mockGetEnsText.mockRejectedValue(error);
223+
224+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
225+
const params = Promise.resolve({ name: 'alice' });
226+
227+
// Should not throw
228+
const response = await GET(request, { params });
229+
expect(response).toBeDefined();
230+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
231+
232+
expect(logger.error).toHaveBeenCalledWith('Error fetching basename Avatar:', error);
233+
});
234+
235+
it('should use development domain when isDevelopment is true', async () => {
236+
jest.resetModules();
237+
jest.doMock('apps/web/src/constants', () => ({
238+
isDevelopment: true,
239+
}));
240+
241+
// Re-import the module to get fresh mocks
242+
// eslint-disable-next-line @typescript-eslint/no-require-imports
243+
const { GET: GETDev } = require('./route') as { GET: typeof GET };
244+
245+
const request = new Request('http://localhost:3000/api/basenames/alice/assets/cardImage.svg');
246+
const params = Promise.resolve({ name: 'alice' });
247+
248+
await GETDev(request, { params });
249+
250+
// In development mode, the domain should be extracted from the request URL
251+
expect(mockGetBasenameImage).toHaveBeenCalledWith('alice');
252+
253+
// Restore the original mock
254+
jest.resetModules();
255+
jest.doMock('apps/web/src/constants', () => ({
256+
isDevelopment: false,
257+
}));
258+
});
259+
260+
it('should call satori with correct dimensions', async () => {
261+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
262+
const params = Promise.resolve({ name: 'alice' });
263+
264+
await GET(request, { params });
265+
266+
expect(mockSatori).toHaveBeenCalledWith(
267+
expect.anything(),
268+
expect.objectContaining({
269+
width: 1000,
270+
height: 1000,
271+
})
272+
);
273+
});
274+
275+
it('should load custom font for the image', async () => {
276+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
277+
const params = Promise.resolve({ name: 'alice' });
278+
279+
await GET(request, { params });
280+
281+
expect(mockReadFile).toHaveBeenCalled();
282+
expect(mockSatori).toHaveBeenCalledWith(
283+
expect.anything(),
284+
expect.objectContaining({
285+
fonts: expect.arrayContaining([
286+
expect.objectContaining({
287+
name: 'CoinbaseDisplay',
288+
weight: 500,
289+
style: 'normal',
290+
}),
291+
]) as unknown,
292+
})
293+
);
294+
});
295+
296+
it('should handle emoji loading in loadAdditionalAsset', async () => {
297+
// Mock fetch for emoji loading
298+
global.fetch = jest.fn().mockResolvedValue({
299+
text: jest.fn().mockResolvedValue('<svg>emoji svg</svg>'),
300+
});
301+
302+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
303+
const params = Promise.resolve({ name: 'alice' });
304+
305+
await GET(request, { params });
306+
307+
const satoriCall = mockSatori.mock.calls[0];
308+
const loadAdditionalAsset = satoriCall[1].loadAdditionalAsset;
309+
310+
// Test emoji loading
311+
const emojiResult = await loadAdditionalAsset('emoji', '😀');
312+
expect(twemoji.convert.toCodePoint).toHaveBeenCalledWith('😀');
313+
expect(global.fetch).toHaveBeenCalledWith(
314+
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f600.svg'
315+
);
316+
expect(emojiResult).toBe('data:image/svg+xml;base64,' + btoa('<svg>emoji svg</svg>'));
317+
});
318+
319+
it('should return code for non-emoji assets', async () => {
320+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
321+
const params = Promise.resolve({ name: 'alice' });
322+
323+
await GET(request, { params });
324+
325+
const satoriCall = mockSatori.mock.calls[0];
326+
const loadAdditionalAsset = satoriCall[1].loadAdditionalAsset;
327+
328+
// Test non-emoji asset loading
329+
const result = await loadAdditionalAsset('font', 'test');
330+
expect(result).toBe('font');
331+
});
332+
333+
it('should cache emoji fetches', async () => {
334+
global.fetch = jest.fn().mockResolvedValue({
335+
text: jest.fn().mockResolvedValue('<svg>emoji svg</svg>'),
336+
});
337+
338+
const request = new Request('https://www.base.org/api/basenames/alice/assets/cardImage.svg');
339+
const params = Promise.resolve({ name: 'alice' });
340+
341+
await GET(request, { params });
342+
343+
const satoriCall = mockSatori.mock.calls[0];
344+
const loadAdditionalAsset = satoriCall[1].loadAdditionalAsset;
345+
346+
// First call should fetch
347+
await loadAdditionalAsset('emoji', '😀');
348+
const fetchCallCount = (global.fetch as jest.Mock).mock.calls.length;
349+
350+
// Second call with same emoji should use cache
351+
await loadAdditionalAsset('emoji', '😀');
352+
expect((global.fetch as jest.Mock).mock.calls.length).toBe(fetchCallCount);
353+
});
354+
});
355+
});

0 commit comments

Comments
 (0)