diff --git a/functions/__tests__/blockies.test.js b/functions/__tests__/blockies.test.js new file mode 100644 index 0000000..a51e16c --- /dev/null +++ b/functions/__tests__/blockies.test.js @@ -0,0 +1,75 @@ +const renderPNG = require('../lib/blockiesPNG'); +const renderSVG = require('../lib/blockiesSVG'); + +describe('Blockies Image Generation', () => { + describe('SVG Generation', () => { + test('generates consistent SVG for same seed', () => { + const svg1 = renderSVG({ seed: 'test123', size: 8, scale: 4 }); + const svg2 = renderSVG({ seed: 'test123', size: 8, scale: 4 }); + expect(svg1).toBe(svg2); + }); + + test('generates different SVG for different seeds', () => { + const svg1 = renderSVG({ seed: 'test123', size: 8, scale: 4 }); + const svg2 = renderSVG({ seed: 'test456', size: 8, scale: 4 }); + expect(svg1).not.toBe(svg2); + }); + + test('respects size parameter', () => { + const svg = renderSVG({ seed: 'test123', size: 16, scale: 4 }); + expect(svg).toMatch(/viewBox="0 0 64 64"/); + }); + + test('respects scale parameter', () => { + const svg = renderSVG({ seed: 'test123', size: 8, scale: 8 }); + expect(svg).toMatch(/viewBox="0 0 64 64"/); + }); + + test('generates valid SVG markup', () => { + const svg = renderSVG({ seed: 'test123' }); + expect(svg).toMatch(/^$/); + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"'); + }); + }); + + describe('PNG Generation', () => { + test('generates consistent PNG for same seed', () => { + const png1 = renderPNG({ seed: 'test123', size: 8, scale: 4 }); + const png2 = renderPNG({ seed: 'test123', size: 8, scale: 4 }); + expect(Buffer.compare(png1, png2)).toBe(0); + }); + + test('generates different PNG for different seeds', () => { + const png1 = renderPNG({ seed: 'test123', size: 8, scale: 4 }); + const png2 = renderPNG({ seed: 'test456', size: 8, scale: 4 }); + expect(Buffer.compare(png1, png2)).not.toBe(0); + }); + + test('returns Buffer instance', () => { + const png = renderPNG({ seed: 'test123' }); + expect(Buffer.isBuffer(png)).toBe(true); + }); + + test('generates PNG with correct dimensions', () => { + const size = 8; + const scale = 4; + const png = renderPNG({ seed: 'test123', size, scale }); + expect(png.length).toBeGreaterThan(0); + }); + }); + + describe('Error Handling', () => { + test('throws error when no seed provided for SVG', () => { + expect(() => renderSVG({})).toThrow(); + }); + + test('throws error when no seed provided for PNG', () => { + expect(() => renderPNG({})).toThrow(); + }); + + test('handles invalid size parameter', () => { + expect(() => renderSVG({ seed: 'test', size: -1 })).toThrow(); + expect(() => renderPNG({ seed: 'test', size: -1 })).toThrow(); + }); + }); +}); diff --git a/functions/__tests__/getENSAvatar.test.js b/functions/__tests__/getENSAvatar.test.js new file mode 100644 index 0000000..c903670 --- /dev/null +++ b/functions/__tests__/getENSAvatar.test.js @@ -0,0 +1,140 @@ +const { AssetId } = require("caip"); +const { getENSAvatar } = require('../index.js'); + +// Mock the provider and other dependencies +jest.mock('@ethersproject/providers', () => ({ + AlchemyProvider: jest.fn().mockImplementation(() => ({ + lookupAddress: jest.fn().mockImplementation(async (address) => { + if (address === '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045') { + return 'vitalik.eth'; + } + throw new Error('Address not found'); + }), + getResolver: jest.fn().mockImplementation(async (name) => ({ + getText: jest.fn().mockImplementation(async (key) => { + if (name === 'vitalik.eth') { + switch (key) { + case 'avatar': + // Different avatar URLs for different test cases + if (process.env.TEST_AVATAR_TYPE === 'ipfs') { + return 'ipfs://QmQsaxGxkKMXxvV1aBB7Dk2eSqNMGrGYzvMJNrGffu8iw3'; + } else if (process.env.TEST_AVATAR_TYPE === 'ipns') { + return 'ipns://k51qzi5uqu5dkkciu33khkzbcmxtyhn376i1e83tya8kuy7z9euedzyr5nk5vv'; + } else if (process.env.TEST_AVATAR_TYPE === 'arweave') { + return 'ar://_YXq8jxJ7lBsUZfUKgfbBTuR0UWEHFKd_wHvIyEUr0s'; + } else if (process.env.TEST_AVATAR_TYPE === 'http') { + return 'https://example.com/avatar.png'; + } else if (process.env.TEST_AVATAR_TYPE === 'eip155') { + return 'eip155:1/erc721:0x123456789/1'; + } + return undefined; + } + } + return undefined; + }) + })) + })) +})); + +// Mock ethers +jest.mock('ethers', () => ({ + getAddress: jest.fn(addr => addr), + Contract: jest.fn().mockImplementation(() => ({ + ownerOf: jest.fn().mockResolvedValue('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'), + tokenURI: jest.fn().mockResolvedValue('https://example.com/token/1') + })) +})); + +// Mock axios for HTTP requests +jest.mock('axios', () => ({ + default: { + get: jest.fn().mockImplementation(async (url) => { + if (url.includes('token')) { + return { data: { image: 'https://example.com/nft-image.png' } }; + } + throw new Error('Failed to fetch'); + }) + } +})); + +describe('getENSAvatar', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.TEST_AVATAR_TYPE; + }); + + test('resolves IPFS avatar URL', async () => { + process.env.TEST_AVATAR_TYPE = 'ipfs'; + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBe('https://ipfs.infura.io/ipfs/QmQsaxGxkKMXxvV1aBB7Dk2eSqNMGrGYzvMJNrGffu8iw3'); + }); + + test('resolves IPNS avatar URL', async () => { + process.env.TEST_AVATAR_TYPE = 'ipns'; + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBe('https://ipfs.infura.io/ipns/k51qzi5uqu5dkkciu33khkzbcmxtyhn376i1e83tya8kuy7z9euedzyr5nk5vv'); + }); + + test('resolves Arweave avatar URL', async () => { + process.env.TEST_AVATAR_TYPE = 'arweave'; + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBe('https://arweave.net/_YXq8jxJ7lBsUZfUKgfbBTuR0UWEHFKd_wHvIyEUr0s'); + }); + + test('resolves HTTP/HTTPS avatar URL', async () => { + process.env.TEST_AVATAR_TYPE = 'http'; + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBe('https://example.com/avatar.png'); + }); + + test('handles missing avatar record', async () => { + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBeUndefined(); + }); + + test('handles invalid address', async () => { + const result = await getENSAvatar('0xinvalid'); + expect(result).toBeUndefined(); + }); + + test('handles network errors', async () => { + // Mock provider to throw network error + const provider = require('@ethersproject/providers'); + provider.AlchemyProvider.mockImplementationOnce(() => ({ + lookupAddress: jest.fn().mockRejectedValue(new Error('Network error')), + getResolver: jest.fn() + })); + + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBeUndefined(); + }); + + test('resolves EIP-155 asset avatar', async () => { + // Mock ethers Contract + const mockContract = { + ownerOf: jest.fn().mockResolvedValue('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'), + tokenURI: jest.fn().mockResolvedValue('https://example.com/token/1') + }; + + const ethers = require('ethers'); + ethers.Contract = jest.fn().mockImplementation(() => mockContract); + + process.env.TEST_AVATAR_TYPE = 'eip155'; + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBe('https://example.com/nft-image.png'); + }); + + test('handles malformed avatar text records', async () => { + // Mock resolver to return malformed avatar text + const provider = require('@ethersproject/providers'); + provider.AlchemyProvider.mockImplementationOnce(() => ({ + lookupAddress: jest.fn().mockResolvedValue('vitalik.eth'), + getResolver: jest.fn().mockResolvedValue({ + getText: jest.fn().mockResolvedValue('malformed://avatar/url') + }) + })); + + const result = await getENSAvatar('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(result).toBe('malformed://avatar/url'); + }); +}); diff --git a/functions/__tests__/getEthereumAddress.test.js b/functions/__tests__/getEthereumAddress.test.js new file mode 100644 index 0000000..fbc2f2c --- /dev/null +++ b/functions/__tests__/getEthereumAddress.test.js @@ -0,0 +1,65 @@ +const { ethers } = require('ethers'); +const { getEthereumAddress } = require('../index'); + +// Mock the provider +jest.mock('@ethersproject/providers', () => ({ + AlchemyProvider: jest.fn().mockImplementation(() => ({ + resolveName: jest.fn().mockImplementation(async (ensName) => { + // Mock ENS resolution + // Case-insensitive ENS name comparison + const normalizedName = ensName.toLowerCase(); + if (normalizedName === 'vitalik.eth') { + return '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + } + if (normalizedName === 'nick.eth') { + return '0xb8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5'; + } + throw new Error('ENS name not found'); + }) + })) +})); + +describe('getEthereumAddress', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('resolves valid Ethereum address', async () => { + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + const result = await getEthereumAddress(address); + expect(result).toBe(address); + }); + + test('resolves ENS name to address', async () => { + const result = await getEthereumAddress('vitalik.eth'); + expect(result).toBe('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + }); + + test('handles checksum addresses', async () => { + const lowercase = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; + const checksum = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + const result = await getEthereumAddress(lowercase); + expect(result).toBe(checksum); + }); + + test('handles invalid Ethereum addresses', async () => { + await expect(getEthereumAddress('0xinvalid')).rejects.toThrow(); + }); + + test('handles non-existent ENS names', async () => { + await expect(getEthereumAddress('nonexistent.eth')).rejects.toThrow(); + }); + + test('handles malformed ENS names', async () => { + await expect(getEthereumAddress('.eth')).rejects.toThrow(); + }); + + test('handles empty input', async () => { + await expect(getEthereumAddress('')).rejects.toThrow(); + }); + + test('handles case sensitivity in ENS names', async () => { + const result = await getEthereumAddress('VITALIK.eth'); + expect(result).toBe('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + }); +}); diff --git a/functions/__tests__/parseURL.test.js b/functions/__tests__/parseURL.test.js new file mode 100644 index 0000000..c2b9f0d --- /dev/null +++ b/functions/__tests__/parseURL.test.js @@ -0,0 +1,59 @@ +const { parseURL } = require('../index'); + +describe('parseURL', () => { + test('parses standard Ethereum address with svg extension', () => { + const result = parseURL('/a/0x1234567890123456789012345678901234567890.svg'); + expect(result).toEqual({ + addressFromUrl: '0x1234567890123456789012345678901234567890', + type: 'svg' + }); + }); + + test('parses standard Ethereum address with png extension', () => { + const result = parseURL('/a/0x1234567890123456789012345678901234567890.png'); + expect(result).toEqual({ + addressFromUrl: '0x1234567890123456789012345678901234567890', + type: 'png' + }); + }); + + test('parses basic ENS domain', () => { + const result = parseURL('/a/vitalik.eth'); + expect(result).toEqual({ + addressFromUrl: 'vitalik.eth', + type: 'svg' + }); + }); + + test('parses ENS subdomain', () => { + const result = parseURL('/a/xyz.abc.eth.png'); + expect(result).toEqual({ + addressFromUrl: 'xyz.abc.eth', + type: 'png' + }); + }); + + test('handles missing extension', () => { + const result = parseURL('/a/0x1234567890123456789012345678901234567890'); + expect(result).toEqual({ + addressFromUrl: '0x1234567890123456789012345678901234567890', + type: 'svg' + }); + }); + + test('handles case sensitivity', () => { + const result = parseURL('/a/ViTaLik.ETH.PNG'); + expect(result).toEqual({ + addressFromUrl: 'ViTaLik.ETH', + type: 'png' + }); + }); + + test('handles special characters in ENS names', () => { + const result = parseURL('/a/my-cool-name.eth'); + expect(result).toEqual({ + addressFromUrl: 'my-cool-name.eth', + type: 'svg' + }); + }); +}); diff --git a/functions/index.js b/functions/index.js index d262584..9f51fcb 100644 --- a/functions/index.js +++ b/functions/index.js @@ -25,7 +25,8 @@ const erc1155Abi = [ ]; -function parseURL(url) { +// Export the function so it can be tested +exports.parseURL = function parseURL(url) { // Remove the initial part of the URL to get the relevant parts const cleanedUrl = url.replace("/a/", ""); // Split the URL by '.' to separate different parts @@ -36,19 +37,19 @@ function parseURL(url) { let addressFromUrl = ""; let type = "svg"; // Default type - // Check if the URL ends with 'eth' to handle ENS domains - if (urlPartsLen > 2 && urlParts[urlPartsLen - 2] === "eth") { + // Check if the URL ends with 'eth' (case insensitive) to handle ENS domains + if (urlPartsLen > 2 && urlParts[urlPartsLen - 2].toLowerCase() === "eth") { // If the format is 'name.eth.svg' or similar addressFromUrl = urlParts.slice(0, urlPartsLen - 1).join("."); - type = urlParts[urlPartsLen - 1]; - } else if (urlPartsLen > 1 && urlParts[urlPartsLen - 1] === "eth") { + type = urlParts[urlPartsLen - 1].toLowerCase(); + } else if (urlPartsLen > 1 && urlParts[urlPartsLen - 1].toLowerCase() === "eth") { // If the format is 'name.eth' addressFromUrl = cleanedUrl; } else { // Handle other formats, assuming the first part is the address addressFromUrl = urlParts[0]; if (urlParts[1]) { - type = urlParts[1]; // Set type if available + type = urlParts[1].toLowerCase(); // Set type if available, convert to lowercase } } @@ -100,7 +101,8 @@ function getProvider() { } } -async function getEthereumAddress(addressString) { +// Export the function so it can be tested +exports.getEthereumAddress = async function getEthereumAddress(addressString) { let address; // Check if the address string includes '.eth' to handle ENS names @@ -200,7 +202,8 @@ async function grabImageUriContract(type, address, tokenId, ownerAddress) { } } -async function getENSAvatar(addressString) { +// Export the function so it can be tested +exports.getENSAvatar = async function getENSAvatar(addressString) { try { // Initialize provider to interact with Ethereum blockchain const provider = getProvider(); @@ -215,14 +218,29 @@ async function getENSAvatar(addressString) { let avatarUrl; // Check if the avatar text indicates an EIP-155 asset - if (avatarText.includes("eip155:")) { - // Parse the asset ID from the avatar text - const assetId = new AssetId(avatarText); - // Attempt to retrieve the image URI associated with the token - const tokenImageUri = await grabImageUriContract(assetId.assetName.namespace, assetId.assetName.reference, assetId.tokenId, addressString); - // If a token image URI is found, use it as the avatar URL - if (tokenImageUri) { - avatarUrl = tokenImageUri; + if (avatarText && avatarText.includes("eip155:")) { + try { + // Parse the asset ID from the avatar text + const assetId = new AssetId(avatarText); + // Default to erc721 if not specified + const tokenType = (assetId.assetName.namespace || 'erc721').toLowerCase(); + // Get contract address from reference + const contractAddress = assetId.assetName.reference; + // Get token ID + const tokenId = assetId.tokenId; + + console.log(`Processing EIP-155 avatar: type=${tokenType}, contract=${contractAddress}, tokenId=${tokenId}`); + + // Attempt to retrieve the image URI associated with the token + const tokenImageUri = await grabImageUriContract(tokenType, contractAddress, tokenId, addressString); + // If a token image URI is found, use it as the avatar URL + if (tokenImageUri) { + avatarUrl = tokenImageUri; + console.log(`Successfully resolved token image URI: ${avatarUrl}`); + } + } catch (error) { + console.error("Error processing EIP-155 avatar:", error); + console.error(error.stack); } } else { // If the avatar text does not indicate an EIP-155 asset, use it directly as the URL diff --git a/functions/lib/blockiesPNG.js b/functions/lib/blockiesPNG.js index 70e41c4..ed8b0d4 100644 --- a/functions/lib/blockiesPNG.js +++ b/functions/lib/blockiesPNG.js @@ -56,6 +56,13 @@ function buildOptions(opts) { throw new Error('No seed provided'); } + if (opts.size && (opts.size < 1 || !Number.isInteger(opts.size))) { + throw new Error('Size must be a positive integer'); + } + + if (opts.scale && (opts.scale < 1 || !Number.isInteger(opts.scale))) { + throw new Error('Scale must be a positive integer'); + } blockiesCommon.randomizeSeed(opts.seed); @@ -111,4 +118,4 @@ function render(opts) { } -module.exports = render; \ No newline at end of file +module.exports = render; diff --git a/functions/lib/blockiesSVG.js b/functions/lib/blockiesSVG.js index 256c4c6..30c3a3d 100644 --- a/functions/lib/blockiesSVG.js +++ b/functions/lib/blockiesSVG.js @@ -55,10 +55,20 @@ function createImageData(size) { function buildOptions(opts) { - const newOpts = {}; + if (!opts.seed) { + throw new Error('No seed provided'); + } + + if (opts.size && (opts.size < 1 || !Number.isInteger(opts.size))) { + throw new Error('Size must be a positive integer'); + } - newOpts.seed = opts.seed || Math.floor((Math.random() * Math.pow(10, 16))).toString(16); + if (opts.scale && (opts.scale < 1 || !Number.isInteger(opts.scale))) { + throw new Error('Scale must be a positive integer'); + } + const newOpts = {}; + newOpts.seed = opts.seed; blockiesCommon.randomizeSeed(newOpts.seed); newOpts.size = opts.size || 8; diff --git a/functions/package.json b/functions/package.json index 5638aad..6289f5e 100644 --- a/functions/package.json +++ b/functions/package.json @@ -6,7 +6,8 @@ "shell": "firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", - "logs": "firebase functions:log" + "logs": "firebase functions:log", + "test": "jest" }, "engines": { "node": "18" @@ -21,7 +22,10 @@ "firebase-functions": "^4.9.0" }, "devDependencies": { - "firebase-functions-test": "^3.2.0" + "chai": "^5.1.2", + "firebase-functions-test": "^3.2.0", + "jest": "^29.7.0", + "mocha": "^10.8.2" }, "private": true }