diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ad18157 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + branches: main + pull_request: + workflow_dispatch: + +jobs: + test: + name: Test + runs-on: ubuntu-latest-16-core + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up NodeJS + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'npm' + + - run: npm ci + - run: npm run test diff --git a/src/execute/execute.processor.spec.ts b/src/execute/execute.processor.spec.ts new file mode 100644 index 0000000..c41f8e8 --- /dev/null +++ b/src/execute/execute.processor.spec.ts @@ -0,0 +1,112 @@ +import { ConfigService } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +import { Job } from 'bull' +import { ethers } from 'ethers' +import { EventEmitter } from 'stream' + +import { ExecuteDto } from './execute.dto' +import { ExecutionProcessorV1 } from './execute.processor' + +const VALID_PRIVATE_KEY = + '0xc6cbd7d76bc5baca530c875663711b947efa6a86a900a9e8645ce32e5821484e' +const TOPOS_CORE_CONTRACT_ADDRESS = '0x1D7b9f9b1FF6cf0A3BEB0F84fA6F8628E540E97F' +const TOPOS_SUBNET_ENDPOINT = 'topos-subnet-endpoint' + +const validExecuteJob: Partial> = { + data: { + indexOfDataInTxRaw: 4, + messagingContractAddress: '', + subnetId: 'id', + txRaw: '', + txTrieRoot: '', + txTrieMerkleProof: '', + }, + progress: jest.fn(), +} + +const subnetMock = { endpoint: 'endpoint' } +const providerMock = Object.assign(new EventEmitter(), { + getCode: jest.fn().mockResolvedValue('0x123'), +}) +const walletMock = {} +const transactionMock = { wait: jest.fn(() => Promise.resolve({})) } +const contractMock = { + execute: jest.fn().mockResolvedValue(transactionMock), + networkSubnetId: jest.fn().mockResolvedValue(''), + subnets: jest.fn().mockResolvedValue(subnetMock), + txRootToCertId: jest.fn().mockResolvedValue(''), +} + +describe('ExecuteProcessor', () => { + let app: TestingModule + let executeProcessor: ExecutionProcessorV1 + + beforeEach(async () => { + app = await Test.createTestingModule({ + providers: [ExecutionProcessorV1], + }) + .useMocker((token) => { + if (token === ConfigService) { + return { + get: jest.fn().mockImplementation((key: string) => { + switch (key) { + case 'PRIVATE_KEY': + return VALID_PRIVATE_KEY + case 'TOPOS_CORE_CONTRACT_ADDRESS': + return TOPOS_CORE_CONTRACT_ADDRESS + case 'TOPOS_SUBNET_ENDPOINT': + return TOPOS_SUBNET_ENDPOINT + } + }), + } + } + }) + .compile() + + executeProcessor = app.get(ExecutionProcessorV1) + }) + + describe('execute', () => { + it('should go through if processed job is valid', async () => { + const ethersProviderMock = jest + .spyOn(ethers.providers, 'WebSocketProvider') + .mockReturnValue(providerMock) + + const ethersWalletMock = jest + .spyOn(ethers, 'Wallet') + .mockReturnValue(walletMock) + + jest.spyOn(ethers, 'Contract').mockReturnValue(contractMock) + + await executeProcessor.execute( + validExecuteJob as unknown as Job + ) + + expect(ethersProviderMock).toHaveBeenCalledWith( + `ws://${TOPOS_SUBNET_ENDPOINT}/ws` + ) + expect(ethersProviderMock).toHaveBeenCalledWith( + `ws://${subnetMock.endpoint}/ws` + ) + expect(ethersWalletMock).toHaveBeenCalledWith( + VALID_PRIVATE_KEY, + providerMock + ) + + expect(contractMock.txRootToCertId).toHaveBeenCalled() + + expect(validExecuteJob.progress).toHaveBeenCalledWith(50) + + expect(contractMock.execute).toHaveBeenCalledWith( + validExecuteJob.data.indexOfDataInTxRaw, + validExecuteJob.data.txTrieMerkleProof, + validExecuteJob.data.txRaw, + validExecuteJob.data.txTrieRoot, + { + gasLimit: 4_000_000, + } + ) + expect(validExecuteJob.progress).toHaveBeenCalledWith(100) + }) + }) +}) diff --git a/src/execute/execute.processor.specc.ts b/src/execute/execute.processor.specc.ts deleted file mode 100644 index f315480..0000000 --- a/src/execute/execute.processor.specc.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ConfigService } from '@nestjs/config' -import { APP_FILTER } from '@nestjs/core' -import { Test, TestingModule } from '@nestjs/testing' -import { ethers } from 'ethers' -import { EventEmitter } from 'stream' - -import { ToposExecutorContract } from '../abi/ToposExecutorContract' -import { ExecuteDto } from './execute.dto' -import { - CONTRACT_ERRORS, - PROVIDER_ERRORS, - WALLET_ERRORS, -} from './execute.errors' -import { ExecuteServiceV1 } from './execute.service' -import { AllExceptionsFilter } from '../filters/all-exceptions.filter' -import { BullModule } from '@nestjs/bull' - -const VALID_PRIVATE_KEY = - 'c6cbd7d76bc5baca530c875663711b947efa6a86a900a9e8645ce32e5821484e' - -const validExecuteDto: ExecuteDto = { - indexOfDataInTxRaw: 4, - messagingContractAddress: '', - subnetId: '', - txRaw: '', - txTrieRoot: '', - txTrieMerkleProof: '', -} - -const providerMock = new EventEmitter() -const walletMock = {} -const transactionMock = { wait: jest.fn(() => Promise.resolve({})) } -const contractMock = { - execute: jest.fn(() => Promise.resolve(transactionMock)), -} - -describe('AppService', () => { - let app: TestingModule - let executeService: ExecuteServiceV1 - let configService: ConfigService - - beforeEach(async () => { - app = await Test.createTestingModule({ - imports: [BullModule.registerQueue({ name: 'execute' })], - providers: [ - ExecuteServiceV1, - { - provide: APP_FILTER, - useClass: AllExceptionsFilter, - }, - ], - }) - .useMocker((token) => { - if (token === ConfigService) { - return { - get: jest.fn().mockReturnValue(VALID_PRIVATE_KEY), - } - } - }) - .compile() - - executeService = app.get(ExecuteServiceV1) - configService = app.get(ConfigService) - }) - - describe('execute', () => { - it('should correctly call ethers API when input is correct', async () => { - const ethersProviderMock = jest - .spyOn(ethers.providers, 'WebSocketProvider') - .mockReturnValue(providerMock) - - const ethersWalletMock = jest - .spyOn(ethers, 'Wallet') - .mockReturnValue(walletMock) - - const ethersContractMock = jest - .spyOn(ethers, 'Contract') - .mockReturnValue(contractMock) - - await executeService.execute(validExecuteDto) - - expect(ethersProviderMock).toHaveBeenCalledWith( - validExecuteDto.crossSubnetMessage.receivingSubnetEndpoint - ) - - expect(ethersWalletMock).toHaveBeenCalledWith( - VALID_PRIVATE_KEY, - providerMock - ) - - expect(ethersContractMock).toHaveBeenCalledWith( - validExecuteDto.crossSubnetMessage.contractAddress, - ToposExecutorContract, - walletMock - ) - }) - }) - - describe('execute|provider', () => { - it('should throw provider endpoint error if provider emits debug error', async () => { - const executeDto = { - ...validExecuteDto, - crossSubnetMessage: { - ...validExecuteDto.crossSubnetMessage, - receivingSubnetEndpoint: '', - }, - } - - jest - .spyOn(ethers.providers, 'JsonRpcProvider') - .mockImplementation((endpoint: string) => { - const eventEmitter = new EventEmitter() - - if (!endpoint) { - setTimeout(() => { - eventEmitter.emit('debug', { error: {} }) - }, 0) - } - - return eventEmitter - }) - - await expect(executeService.execute(executeDto)).rejects.toEqual( - new Error(PROVIDER_ERRORS.INVALID_ENDPOINT) - ) - }) - }) - - describe('execute|wallet', () => { - it('should throw wallet error if private key is invalid', async () => { - jest - .spyOn(ethers.providers, 'JsonRpcProvider') - .mockReturnValue(providerMock) - - jest.spyOn(ethers, 'Wallet').mockImplementationOnce(() => { - throw new Error() - }) - - await expect(executeService.execute(validExecuteDto)).rejects.toEqual( - new Error(WALLET_ERRORS.INVALID_PRIVATE_KEY) - ) - }) - }) - - describe('execute|contract', () => { - it('should throw contract error if ethers contract API throws', async () => { - jest - .spyOn(ethers.providers, 'JsonRpcProvider') - .mockReturnValue(providerMock) - - jest.spyOn(ethers, 'Wallet').mockReturnValue(walletMock) - - jest.spyOn(ethers, 'Contract').mockImplementation(() => { - throw new Error() - }) - - await expect(executeService.execute(validExecuteDto)).rejects.toEqual( - new Error(CONTRACT_ERRORS.INVALID_CONTRACT) - ) - }) - }) -}) diff --git a/src/execute/execute.service.spec.ts b/src/execute/execute.service.spec.ts index da4666d..055d199 100644 --- a/src/execute/execute.service.spec.ts +++ b/src/execute/execute.service.spec.ts @@ -1,9 +1,11 @@ import { BullModule, getQueueToken } from '@nestjs/bull' import { Test, TestingModule } from '@nestjs/testing' +import { Job } from 'bull' import { ExecuteDto } from './execute.dto' import { ExecuteServiceV1 } from './execute.service' import { ConfigService } from '@nestjs/config' +import { first, firstValueFrom, lastValueFrom } from 'rxjs' const validExecuteDto: ExecuteDto = { indexOfDataInTxRaw: 4, @@ -19,6 +21,25 @@ const VALID_PRIVATE_KEY = const executeQueueMock = { add: jest.fn().mockReturnValue({ id: '', timestamp: 0 }), + getJob: jest.fn().mockImplementation((jobId: string) => + Promise.resolve({ + id: jobId, + failedReason: 'errorMock', + finished: jest.fn().mockResolvedValue({}), + }) + ), + on: jest + .fn() + .mockImplementation( + ( + event: 'progress', + callback: (job: Partial, progress: string) => void + ) => { + const fakeJob = { id: '1' } + callback(fakeJob, '') + } + ), + removeListener: jest.fn(), client: { status: 'ready' }, } @@ -53,10 +74,72 @@ describe('ExecuteService', () => { describe('execute', () => { it('should call queue.add', () => { executeService.execute(validExecuteDto) + expect(executeQueueMock.add).toHaveBeenCalledWith( 'execute', validExecuteDto ) }) }) + + describe('getJobById', () => { + it('should return job', async () => { + const jobId = '1' + const job = await executeService.getJobById(jobId) + + expect(executeQueueMock.getJob).toHaveBeenCalledWith(jobId) + expect(job.id).toBe(jobId) + }) + }) + + describe('subscribeToJobById', () => { + it('should retrieve the correct job', () => { + const jobId = '1' + executeService + .subscribeToJobById(jobId) + .pipe(first()) + .subscribe(() => { + expect(executeQueueMock.getJob).toHaveBeenCalledWith(jobId) + expect(executeQueueMock.on).toHaveBeenCalled() + }) + }) + + it('should first next some progress', async () => { + const jobId = '1' + await expect( + firstValueFrom(executeService.subscribeToJobById(jobId)) + ).resolves.toStrictEqual({ + data: { payload: '', type: 'progress' }, + }) + }) + + it('should then complete', async () => { + const jobId = '1' + await expect( + lastValueFrom(executeService.subscribeToJobById(jobId)) + ).resolves.toStrictEqual({ + data: { payload: {}, type: 'completed' }, + }) + }) + + it('should fail if job finishes with rejection', async () => { + const jobId = '1' + + jest.spyOn(executeQueueMock, 'getJob').mockImplementationOnce( + (jobId: string) => + new Promise((resolve) => { + const job: Partial = { + id: jobId, + failedReason: 'errorMock', + finished: jest.fn().mockRejectedValueOnce(''), + } + resolve(job) + }) + ) + + await expect( + lastValueFrom(executeService.subscribeToJobById(jobId)) + ).rejects.toStrictEqual({ data: 'errorMock' }) + }) + }) }) diff --git a/src/execute/execute.service.ts b/src/execute/execute.service.ts index 132c4af..68eeb27 100644 --- a/src/execute/execute.service.ts +++ b/src/execute/execute.service.ts @@ -6,12 +6,7 @@ import { ethers } from 'ethers' import { Observable } from 'rxjs' import { ExecuteDto } from './execute.dto' -import { - CONTRACT_ERRORS, - QUEUE_ERRORS, - PROVIDER_ERRORS, - WALLET_ERRORS, -} from './execute.errors' +import { QUEUE_ERRORS, WALLET_ERRORS } from './execute.errors' @Injectable() export class ExecuteServiceV1 { @@ -59,7 +54,6 @@ export class ExecuteServiceV1 { } this.executionQueue.on('progress', progressListener) - job .finished() .then((payload) => { @@ -68,7 +62,7 @@ export class ExecuteServiceV1 { subscriber.next({ data: { payload, type: 'completed' } }) subscriber.complete() }) - .catch(() => { + .catch((error) => { const messageEvent: MessageEvent = { data: job.failedReason, }