Skip to content

Commit

Permalink
feat: client sdk - e2e test setup with random wallet (#173)
Browse files Browse the repository at this point in the history
  • Loading branch information
krzysu authored Apr 11, 2023
1 parent b67b911 commit c95dafd
Show file tree
Hide file tree
Showing 35 changed files with 611 additions and 306 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ jobs:
SDK_GATED_TEST_PK: ${{secrets.SDK_GATED_TEST_PK}}
SDK_GATED_TEST_PROFILE_ID: ${{secrets.SDK_GATED_TEST_PROFILE_ID}}
SDK_GATED_TEST_PUBLICATION_ID: ${{secrets.SDK_GATED_TEST_PUBLICATION_ID}}
CLIENT_TEST_WALLET_PRIVATE_KEY: ${{secrets.CLIENT_TEST_WALLET_PRIVATE_KEY}}
TESTING_ENV_URL: ${{secrets.TESTING_ENV_URL}}
TESTING_HEADER_KEY: ${{secrets.TESTING_HEADER_KEY}}
TESTING_HEADER_VALUE: ${{secrets.TESTING_HEADER_VALUE}}

- run: pnpm lint:examples
4 changes: 3 additions & 1 deletion packages/client/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
CLIENT_TEST_WALLET_PRIVATE_KEY=
TESTING_ENV_URL=
TESTING_HEADER_KEY=
TESTING_HEADER_VALUE=
8 changes: 0 additions & 8 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,3 @@
The official framework-agnostic Lens API Client.

This package enables you to interact with the Lens API via a type safe interface that abstracts away some of the GraphQL intricacies.

## Running tests

Tests are using a real wallet created from a private key stored in `.env` file. Test are run against Mumbai Sandbox API. The private key is set as a secret for Github Actions (our CI). Ask if you need it for local tests.

```
CLIENT_TEST_WALLET_PRIVATE_KEY=
```
2 changes: 1 addition & 1 deletion packages/client/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export default {
testEnvironment: 'node',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
transformIgnorePatterns: [`/node_modules/(?!@lens-protocol/*)`],
testTimeout: 15000,
testTimeout: 30000,
};
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"dependencies": {
"@lens-protocol/shared-kernel": "workspace:*",
"@lens-protocol/storage": "workspace:*",
"dotenv": "^16.0.3",
"graphql": "^16.6.0",
"graphql-request": "^5.1.0",
"graphql-tag": "^2.12.6",
Expand All @@ -58,7 +59,6 @@
"@lens-protocol/tsconfig": "workspace:*",
"@types/jest": "29.2.3",
"@types/node": "^18.14.0",
"dotenv": "^16.0.3",
"eslint": "^8.34.0",
"ethers": "^5.7.2",
"jest": "^29.4.3",
Expand Down
8 changes: 4 additions & 4 deletions packages/client/src/LensClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import {
Stats,
Transaction,
} from '.';
import { mumbaiSandbox } from './consts/environments';
import { buildTestEnvironment } from './__helpers__';

const testConfig = {
environment: mumbaiSandbox,
environment: buildTestEnvironment(),
};

describe(`Given the ${LensClient.name} configured for sandbox`, () => {
describe(`Given the ${LensClient.name} configured for the test environment`, () => {
const client = new LensClient(testConfig);

describe(`when accessing the ${Explore.name} module`, () => {
Expand Down Expand Up @@ -115,7 +115,7 @@ describe(`Given the ${LensClient.name} configured for sandbox`, () => {
describe(`Given storage and two ${LensClient.name} instances sharing the same storage`, () => {
const storage = new InMemoryStorageProvider();
const config = {
environment: mumbaiSandbox,
environment: buildTestEnvironment(),
storage,
};
const client1 = new LensClient(config);
Expand Down
12 changes: 12 additions & 0 deletions packages/client/src/__helpers__/buildTestEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { invariant } from '@lens-protocol/shared-kernel';
import * as dotenv from 'dotenv';

import { Environment } from '../consts/environments';

dotenv.config();

export const buildTestEnvironment = (): Environment => {
invariant(process.env.TESTING_ENV_URL, 'TESTING_ENV_URL is not defined in .env file');

return new Environment('testing', process.env.TESTING_ENV_URL);
};
226 changes: 226 additions & 0 deletions packages/client/src/__helpers__/describeAuthenticatedScenario.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/* eslint-disable no-console */
import { invariant, never } from '@lens-protocol/shared-kernel';
import { Wallet } from 'ethers';

import { Authentication } from '../authentication';
import { Profile } from '../profile';
import { isRelayerResult, Transaction } from '../transaction';
import { buildTestEnvironment } from './buildTestEnvironment';
import { signAndBroadcast } from './signAndBroadcast';

const testConfig = {
environment: buildTestEnvironment(),
};

export type TestSetup = {
authentication: Authentication;
profileId: string;
wallet: Wallet;
walletAddress: string;
};

export type SetupOptions = {
withNewProfile?: boolean;
withDispatcher?: boolean;
};

const defaultOptions: SetupOptions = {
withNewProfile: false,
withDispatcher: false,
};

type GetTestSetupFn = () => TestSetup;

export const describeAuthenticatedScenario =
(options?: SetupOptions) => (callback: (f: GetTestSetupFn) => void) => {
const { withNewProfile, withDispatcher } = { ...defaultOptions, ...options };

invariant(
!(withDispatcher && !withNewProfile),
'Wrong SetupOptions: dispatcher can only be added together with profile',
);

const testHandle = Date.now().toString();
const wallet = Wallet.createRandom();

const authentication = new Authentication(testConfig);
const profile = new Profile(testConfig, authentication);
const transaction = new Transaction(testConfig, authentication);

let _walletAddress: string;
let _testProfileId: string;

beforeAll(async () => {
// authenticate
const address = await wallet.getAddress();
const challenge = await authentication.generateChallenge(address);
const signature = await wallet.signMessage(challenge);
await authentication.authenticate(address, signature);

// create a new profile
if (withNewProfile) {
await createProfile({
handle: testHandle,
walletAddress: address,
profile,
transaction,
});
}

// find test profileId
const testProfileId = withNewProfile
? await findProfileId({
handle: testHandle,
walletAddress: address,
profile,
})
: '';

if (withDispatcher && testProfileId) {
await enableDispatcher({
profileId: testProfileId,
wallet,
profile,
transaction,
});
}

// store all at the end
_walletAddress = address;
_testProfileId = testProfileId || '';
});

afterAll(async () => {
if (!_testProfileId) {
return;
}

await burnProfile({
profileId: _testProfileId,
handle: testHandle,
wallet,
walletAddress: _walletAddress,
profile,
transaction,
});
});

describe(buildDescribeName(options), () =>
callback(() => ({
authentication,
profileId: _testProfileId,
wallet,
walletAddress: _walletAddress,
})),
);
};

function buildDescribeName(options?: SetupOptions): string {
if (!options) {
return 'and the instance is authenticated with a random wallet';
}
if (options.withNewProfile && options.withDispatcher) {
return 'and the instance is authenticated with a random wallet with a profile and dispatcher';
}
if (options.withNewProfile) {
return 'and the instance is authenticated with a random wallet with a profile';
}

never('withDispatcher cannot be used without withNewProfile');
}

type CreateProfile = {
handle: string;
walletAddress: string;
profile: Profile;
transaction: Transaction;
};

async function createProfile({ handle, walletAddress, profile, transaction }: CreateProfile) {
console.log(`Creating a new profile for ${walletAddress} with handle ${handle}`);

const profileCreateResult = await profile.create({ handle });

const value = profileCreateResult.unwrap();
if (!isRelayerResult(value)) {
throw new Error(`Profile creation error: ${value.reason}`);
}

// wait in a loop
await transaction.waitForIsIndexed(value.txId);
}

type FindProfileId = {
handle: string;
walletAddress: string;
profile: Profile;
};

async function findProfileId({ handle, walletAddress, profile }: FindProfileId) {
const allOwnedProfiles = await profile.fetchAll({
ownedBy: [walletAddress],
limit: 20,
});

// console.log(allOwnedProfiles.items.map((i) => ({ id: i.id, handle: i.handle })));

const testProfile = allOwnedProfiles.items.find((item) => item.handle === `${handle}.test`);
return testProfile?.id;
}

type EnableDispatcher = {
profileId: string;
wallet: Wallet;
profile: Profile;
transaction: Transaction;
};

async function enableDispatcher({ profileId, wallet, profile, transaction }: EnableDispatcher) {
console.log(`Enabling dispatcher for profileId ${profileId}`);

const setDispatcherTypedDataResult = await profile.createSetDispatcherTypedData({
profileId,
});
const txId = await signAndBroadcast(transaction, wallet, setDispatcherTypedDataResult);

if (!txId) {
throw Error('Enabling dispatcher failed');
}
// wait in a loop
await transaction.waitForIsIndexed(txId);
}

type BurnProfile = {
profileId: string;
handle: string;
wallet: Wallet;
walletAddress: string;
profile: Profile;
transaction: Transaction;
};

async function burnProfile({
profileId,
handle,
wallet,
walletAddress,
profile,
transaction,
}: BurnProfile) {
console.log('All tests finished, burning the test profile', {
profileId,
handle,
});

const burnProfileTypedDataResult = await profile.createBurnProfileTypedData({
profileId,
});

const txId = await signAndBroadcast(transaction, wallet, burnProfileTypedDataResult);

if (!txId) {
throw Error('Profile burn failed');
}

console.log(`Profile ${profileId} owned by wallet ${walletAddress} is burned in a txId ${txId}`);
}
6 changes: 6 additions & 0 deletions packages/client/src/__helpers__/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './buildTestEnvironment';
export * from './describeAuthenticatedScenario';

export const existingPublicationId = '0x01aa-0x16';
export const existingProfileId = '0x0185';
export const altProfileId = '0x0186';
30 changes: 30 additions & 0 deletions packages/client/src/__helpers__/signAndBroadcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IEquatableError, Result } from '@lens-protocol/shared-kernel';
import { Wallet } from 'ethers';

import type { TypedDataResponse } from '../consts/types';
import { isRelayerResult, Transaction } from '../transaction';

export async function signAndBroadcast<
T extends TypedDataResponse,
E extends IEquatableError<string, string>,
>(transaction: Transaction, wallet: Wallet, result: Result<T, E>) {
const data = result.unwrap();

const signedTypedData = await wallet._signTypedData(
data.typedData.domain,
data.typedData.types,
data.typedData.value,
);

const broadcastResult = await transaction.broadcast({
id: data.id,
signature: signedTypedData,
});

const value = broadcastResult.unwrap();
if (!isRelayerResult(value)) {
throw new Error(`Transaction broadcast error: ${value.reason}`);
}

return value.txId;
}
26 changes: 26 additions & 0 deletions packages/client/src/__mocks__/graphql-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { invariant } from '@lens-protocol/shared-kernel';
import * as dotenv from 'dotenv';

dotenv.config();

function buildTestHeaders() {
invariant(process.env.TESTING_HEADER_KEY, 'TESTING_HEADER_KEY is not defined in .env file');
invariant(process.env.TESTING_HEADER_VALUE, 'TESTING_HEADER_VALUE is not defined in .env file');

return {
[process.env.TESTING_HEADER_KEY]: process.env.TESTING_HEADER_VALUE,
};
}

const actual = jest.requireActual('graphql-request') as unknown;

// eslint-disable-next-line
// @ts-ignore
class MockGraphQLClient extends actual.GraphQLClient {
constructor(url: string) {
// eslint-disable-next-line
super(url, { headers: buildTestHeaders });
}
}

export const GraphQLClient = MockGraphQLClient;
Loading

0 comments on commit c95dafd

Please sign in to comment.