Skip to content

Commit

Permalink
Ens hook unittest (#280)
Browse files Browse the repository at this point in the history
* unit test for ens hook

* new safeUtil for mocking a connected gnosis safe

* complete test coverage for ens hook

Co-authored-by: schmanu <schmanu@users.noreply.github.com>
  • Loading branch information
schmanu and schmanu authored Nov 17, 2021
1 parent 4b96f28 commit 384bc5f
Show file tree
Hide file tree
Showing 7 changed files with 394 additions and 5 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@testing-library/jest-dom": "^5.15.0",
"@testing-library/react": "^12.1.2",
"@typechain/ethers-v5": "^7.1.2",
"@types/chai": "^4.2.22",
"@types/chai-as-promised": "^7.1.4",
Expand Down
236 changes: 236 additions & 0 deletions src/__tests__/hooks/ens.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { Web3Provider } from "@ethersproject/providers";
import SafeProvider from "@gnosis.pm/safe-apps-react-sdk";
import { render, RenderResult, screen } from "@testing-library/react";
import { ethers } from "ethers";
import React, { useEffect, useState } from "react";
import { unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import { useEnsResolver } from "../../hooks/ens";
import { sendSafeInfo, setupMocksForSafeProvider } from "../../test/safeUtil";
import { testData } from "../../test/util";

type TestENSComponentProps = { ensNamesToResolve?: string[]; addressesToLookup?: string[] };
/**
* Small component which executes some hook functions and puts the results in the dom.
*/
const TestENSComponent = (props: TestENSComponentProps): JSX.Element => {
const { ensNamesToResolve, addressesToLookup } = props;
const ensResolver = useEnsResolver();
const [isEnsEnabled, setIsEnsEnabled] = useState<boolean | undefined>(undefined);
const [resolvedNames, setResolvedNames] = useState<Array<string | null> | undefined>(undefined);
const [lookedUpAddresses, setLookedUpAddresses] = useState<Array<string | null> | undefined>(undefined);

useEffect(() => {
const fetchData = async () => {
ensResolver.isEnsEnabled().then((result) => {
act(() => {
setIsEnsEnabled(result);
});
});
if (addressesToLookup) {
const results: Array<string | null> = [];
for (const address of addressesToLookup) {
results.push(await ensResolver.lookupAddress(address));
}
setLookedUpAddresses(results);
}
if (ensNamesToResolve) {
const results: Array<string | null> = [];
for (const name of ensNamesToResolve) {
results.push(await ensResolver.resolveName(name));
}
setResolvedNames(results);
}
};
fetchData();
}, [addressesToLookup, ensNamesToResolve, ensResolver]);

return (
<div>
{typeof isEnsEnabled !== "undefined" ? <div data-testid="isEnsEnabled">{isEnsEnabled.toString()}</div> : <></>}
{typeof resolvedNames !== "undefined" ? (
resolvedNames.map((resolvedName, idx) => (
<div key={idx} data-testid="resolvedName">
{resolvedName}
</div>
))
) : (
<></>
)}
{typeof lookedUpAddresses !== "undefined" ? (
lookedUpAddresses.map((lookedUpAddress, idx) => (
<div key={idx} data-testid="lookedUpAddress">
{lookedUpAddress}
</div>
))
) : (
<></>
)}
</div>
);
};

const renderTestComponent = (container: HTMLElement, props: TestENSComponentProps = {}) =>
render(
<SafeProvider loader={<div>loading...</div>}>
<TestENSComponent addressesToLookup={props.addressesToLookup} ensNamesToResolve={props.ensNamesToResolve} />
</SafeProvider>,
{ container },
);

let container: HTMLDivElement | null = null;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
setupMocksForSafeProvider();
});

afterEach(() => {
jest.clearAllMocks();
if (container) {
unmountComponentAtNode(container);
container.remove();
container = null;
}
});

test("isEnsEnabled with ens capable network", async () => {
const fakeWeb3Provider: any = {
getNetwork: () => {
return Promise.resolve({ chainId: 4, network: "rinkeby", ensAddress: "0x00000000000001" });
},
};

jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider);
let renderedContainer: undefined | RenderResult;
act(() => {
if (container !== null) {
renderedContainer = renderTestComponent(container);
}
});

sendSafeInfo();

expect(renderedContainer).toBeTruthy();
const ensEnabledElement = await screen.findByTestId("isEnsEnabled");
expect(ensEnabledElement?.innerHTML).toBe("true");
});

test("isEnsEnabled with non ens network", async () => {
const fakeWeb3Provider: any = {
getNetwork: () => {
return Promise.resolve({ chainId: 9, network: "randomnetwork" });
},
};

jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider);
let renderedContainer: undefined | RenderResult;
act(() => {
if (container !== null) {
renderedContainer = renderTestComponent(container);
}
});

sendSafeInfo();

expect(renderedContainer).toBeTruthy();
const ensEnabledElement = await screen.findByTestId("isEnsEnabled");
expect(ensEnabledElement?.innerHTML).toBe("false");
});

/**
* we render the test component twice with the same props and check, that the web3Provider functions get called only once.
*/
test("resolving an address and lookups are cached", async () => {
const resolveName = jest.fn(async (name) => {
if ((await name) === "test.eth") {
return Promise.resolve(testData.addresses.receiver1);
} else {
return Promise.resolve(null);
}
});
const lookupAddress = jest.fn(async (address) => {
if ((await address) === testData.addresses.receiver1) {
return Promise.resolve("test.eth");
} else {
return address;
}
});
const fakeWeb3Provider: Partial<Web3Provider> = {
getNetwork: () =>
Promise.resolve({ chainId: 4, network: "rinkeby", _defaultProvider: () => null, name: "rinkeby" }),
resolveName: (name) => resolveName(name),
lookupAddress: (address) => lookupAddress(address),
};

jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider as any);
let renderedContainer;
act(() => {
if (container !== null) {
renderedContainer = renderTestComponent(container, {
addressesToLookup: [testData.addresses.receiver1, testData.addresses.receiver1],
ensNamesToResolve: ["test.eth", "test.eth"],
});
}
});

sendSafeInfo();

expect(renderedContainer).toBeTruthy();

const resolvedNameElement = await screen.findAllByTestId("resolvedName");
expect(resolvedNameElement.map((value) => value.innerHTML)).toEqual([
testData.addresses.receiver1,
testData.addresses.receiver1,
]);

const lookedUpAddressElement = await screen.findAllByTestId("lookedUpAddress");
expect(lookedUpAddressElement.map((value) => value.innerHTML)).toEqual(["test.eth", "test.eth"]);

expect(lookupAddress).toHaveBeenCalledTimes(1);
expect(resolveName).toHaveBeenCalledTimes(1);
});

/**
* we render the test component twice with the same props and check, that the web3Provider functions get called only once.
*/
test("null lookups / resolved addresses are cached", async () => {
const resolveName = jest.fn(async (name) => {
return Promise.resolve(null);
});
const lookupAddress = jest.fn(async (address) => {
return Promise.resolve(null);
});
const fakeWeb3Provider: Partial<Web3Provider> = {
getNetwork: () =>
Promise.resolve({ chainId: 4, network: "rinkeby", _defaultProvider: () => null, name: "rinkeby" }),
resolveName: (name) => resolveName(name),
lookupAddress: (address) => lookupAddress(address),
};

jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider as any);
let renderedContainer;
act(() => {
if (container !== null) {
renderedContainer = renderTestComponent(container, {
addressesToLookup: [testData.addresses.receiver3, testData.addresses.receiver3],
ensNamesToResolve: ["unknown.eth", "unknown.eth"],
});
}
});

sendSafeInfo();

expect(renderedContainer).toBeTruthy();

const resolvedNameElement = await screen.findAllByTestId("resolvedName");
expect(resolvedNameElement.map((value) => value.innerHTML)).toEqual(["", ""]);

const lookedUpAddressElement = await screen.findAllByTestId("lookedUpAddress");
expect(lookedUpAddressElement.map((value) => value.innerHTML)).toEqual(["", ""]);

expect(lookupAddress).toHaveBeenCalledTimes(1);
expect(resolveName).toHaveBeenCalledTimes(1);
});
4 changes: 2 additions & 2 deletions src/hooks/ens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ export interface EnsResolver {
export const useEnsResolver: () => EnsResolver = () => {
const { safe, sdk } = useSafeAppsSDK();
const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]);

const resolveCache = useMemo(() => new Map<string, string | null>(), []);

const lookupCache = useMemo(() => new Map<string, string | null>(), []);

const cachedResolveName = useCallback(
async (ensName: string) => {
const cachedAddress = resolveCache.get(ensName);
const resolvedAddress = cachedAddress ? cachedAddress : await web3Provider.resolveName(ensName);
const resolvedAddress =
typeof cachedAddress !== "undefined" ? cachedAddress : await web3Provider.resolveName(ensName);
if (!resolveCache.has(ensName)) {
resolveCache.set(ensName, resolvedAddress);
}
Expand Down
27 changes: 27 additions & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,30 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom/extend-expect";
import "@testing-library/react";

// we have to mock some crypto functionality of the browser window for the gnosis safe mocks
import crypto from "crypto";

function getRandomValues(buf: Uint8Array) {
if (!(buf instanceof Uint8Array)) {
throw new TypeError("expected Uint8Array");
}
if (buf.length > 65536) {
const e = new Error();
e.message =
"Failed to execute 'getRandomValues' on 'Crypto': The " +
"ArrayBufferView's byte length (" +
buf.length +
") exceeds the " +
"number of bytes of entropy available via this API (65536).";
e.name = "QuotaExceededError";
throw e;
}
const bytes = crypto.randomBytes(buf.length);
buf.set(bytes);
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.crypto = { getRandomValues };
55 changes: 55 additions & 0 deletions src/test/safeUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { SafeInfo } from "@gnosis.pm/safe-apps-sdk";
import React from "react";
import { act } from "react-dom/test-utils";

import { testData } from "./util";

let lastRegisteredEventHandler;
let lastTrackedParentRequestID: string | undefined;

/**
* Registers mocks to specific window events and the useEffect hook in order to capture the postMessage call to the parent window (gnosis safe).
*
* After rendering the SafeProvider the test has to invoke sendSafeInfo to mock send a gnosis safe to the SafeProvider using the captured values.
*
* @see sendSafeInfo
*/
export const setupMocksForSafeProvider = () => {
let postMessageSpy: jest.SpyInstance<void, [message: any, options?: PostMessageOptions]>;
let useEffectSpy: jest.SpyInstance<void, [effect: React.EffectCallback, deps?: React.DependencyList | undefined]>;

useEffectSpy = jest.spyOn(React, "useEffect");
useEffectSpy.mockImplementation((f) => f());
postMessageSpy = jest.spyOn(window.parent, "postMessage");

postMessageSpy.mockImplementation((message: any, options?: PostMessageOptions): Promise<SafeInfo | undefined> => {
if (message.method === "getSafeInfo") {
lastTrackedParentRequestID = message.id;
return Promise.resolve(testData.dummySafeInfo);
}
return Promise.reject("Implementation not mocked");
});

jest.spyOn(window, "addEventListener").mockImplementationOnce((event, handler) => {
if (event === "message") {
lastRegisteredEventHandler = handler;
}
});
};

/**
* Mocks a MessageEvent by calling the previously registered handler.
* This MessageEvent includes the SafeInfo and some required meta information.
*
* @param safeInfo Optional Safe Info data which should be sent to the SafeProvider. By default its the dummySafeInfo from testData.
*/
export const sendSafeInfo = (safeInfo: SafeInfo = testData.dummySafeInfo) => {
act(() => {
// we now send the a fake SafeInfo Object to the Safe Provider
lastRegisteredEventHandler({
data: { ...safeInfo, version: "1.0", id: lastTrackedParentRequestID, success: true },
source: window.parent,
origin: "*",
});
});
};
2 changes: 1 addition & 1 deletion src/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk";
import { TokenInfo } from "../utils";

const dummySafeInfo: SafeInfo = {
safeAddress: "0x123",
safeAddress: "0x1230000000000000000000000000000000000000",
chainId: 4,
threshold: 1,
owners: [],
Expand Down
Loading

0 comments on commit 384bc5f

Please sign in to comment.