diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts index d39c5d9dd6..d95fbda020 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts @@ -1,6 +1,11 @@ import { createMock } from '@golevelup/ts-jest'; import { HMToken__factory } from '@human-protocol/core/typechain-types'; -import { Encryption, EscrowClient, OperatorUtils } from '@human-protocol/sdk'; +import { + Encryption, + EncryptionUtils, + EscrowClient, + OperatorUtils, +} from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; @@ -69,7 +74,6 @@ jest.mock('minio', () => { describe('JobService', () => { let jobService: JobService; - let web3Service: Web3Service; let storageService: StorageService; let jobRepository: JobRepository; let assignmentRepository: AssignmentRepository; @@ -136,7 +140,6 @@ describe('JobService', () => { }).compile(); jobService = moduleRef.get(JobService); - web3Service = moduleRef.get(Web3Service); storageService = moduleRef.get(StorageService); jobRepository = moduleRef.get(JobRepository); assignmentRepository = @@ -144,6 +147,11 @@ describe('JobService', () => { webhookRepository = moduleRef.get(WebhookRepository); }); + afterEach(() => { + (jobService as any).manifestCache.clear(); + jest.clearAllMocks(); + }); + describe('createJob', () => { beforeAll(async () => { jest.spyOn(jobRepository, 'createUnique'); @@ -497,7 +505,6 @@ describe('JobService', () => { .mockResolvedValue(solutionsUrl); await jobService.solveJob(assignment.id, 'solution'); - expect(web3Service.getSigner).toHaveBeenCalledWith(chainId); expect(webhookRepository.createUnique).toHaveBeenCalledWith({ escrowAddress, chainId, @@ -515,7 +522,6 @@ describe('JobService', () => { await expect(jobService.solveJob(1, 'solution')).rejects.toThrow( new ConflictError(ErrorAssignment.InvalidStatus), ); - expect(web3Service.getSigner).toHaveBeenCalledWith(chainId); }); it('should fail if user is not assigned to the job', async () => { @@ -556,7 +562,6 @@ describe('JobService', () => { await expect(jobService.solveJob(1, 'solution')).rejects.toThrow( 'This job has already been completed', ); - expect(web3Service.getSigner).toHaveBeenCalledWith(chainId); }); it('should fail if user has already submitted a solution', async () => { @@ -589,7 +594,100 @@ describe('JobService', () => { await expect(jobService.solveJob(1, 'solution')).rejects.toThrow( new ValidationError(ErrorJob.SolutionAlreadySubmitted), ); - expect(web3Service.getSigner).toHaveBeenCalledWith(chainId); + }); + }); + + describe('getManifest', () => { + const downloadFileFromUrlMock = jest.mocked(downloadFileFromUrl); + + it('should fetch and parse a non encrypted manifest', async () => { + const manifest: ManifestDto = { + requesterTitle: 'Example Title', + requesterDescription: 'Example Description', + submissionsRequired: 5, + fundAmount: 100, + }; + + downloadFileFromUrlMock.mockResolvedValue(JSON.stringify(manifest)); + EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(false); + + const result = await jobService.getManifest( + chainId, + escrowAddress, + MOCK_MANIFEST_URL, + ); + + expect(result).toEqual(manifest); + expect(Encryption.build).not.toHaveBeenCalled(); + }); + + it('should fetch and decrypt an encrypted manifest', async () => { + const manifest: ManifestDto = { + requesterTitle: 'Example Title', + requesterDescription: 'Example Description', + submissionsRequired: 5, + fundAmount: 100, + }; + + downloadFileFromUrlMock.mockResolvedValue('encrypted-content'); + EncryptionUtils.isEncrypted = jest.fn().mockReturnValue(true); + (Encryption.build as any).mockImplementation(() => ({ + decrypt: jest.fn().mockResolvedValue(JSON.stringify(manifest)), + })); + + const result = await jobService.getManifest( + chainId, + escrowAddress, + MOCK_MANIFEST_URL, + ); + + expect(result).toEqual(manifest); + expect(Encryption.build).toHaveBeenCalled(); + }); + + it('should cache the manifest in memory for repeated requests', async () => { + const manifest: ManifestDto = { + requesterTitle: 'Example Title', + requesterDescription: 'Example Description', + submissionsRequired: 5, + fundAmount: 100, + }; + + downloadFileFromUrlMock.mockResolvedValue(manifest); + + const firstManifest = await jobService.getManifest( + chainId, + escrowAddress, + MOCK_MANIFEST_URL, + ); + const secondManifest = await jobService.getManifest( + chainId, + escrowAddress, + MOCK_MANIFEST_URL, + ); + + expect(firstManifest).toEqual(manifest); + expect(secondManifest).toEqual(manifest); + expect(downloadFileFromUrlMock).toHaveBeenCalledTimes(1); + }); + + it('should retry downloading the manifest after a failed request', async () => { + downloadFileFromUrlMock.mockRejectedValue( + new Error('Storage file not found'), + ); + jest + .spyOn(webhookRepository, 'createUnique') + .mockResolvedValue({} as any); + + await expect( + jobService.getManifest(chainId, escrowAddress, MOCK_MANIFEST_URL), + ).rejects.toThrow(ErrorJob.ManifestNotFound); + await expect( + jobService.getManifest(chainId, escrowAddress, MOCK_MANIFEST_URL), + ).rejects.toThrow(ErrorJob.ManifestNotFound); + + expect(downloadFileFromUrlMock).toHaveBeenCalledTimes(2); + expect(webhookRepository.createUnique).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts index 27646027f3..1c60b0df81 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts @@ -43,6 +43,8 @@ import { JobRepository } from './job.repository'; @Injectable() export class JobService { + private readonly manifestCache = new Map>(); + constructor( private readonly pgpConfigService: PGPConfigService, public readonly jobRepository: JobRepository, @@ -344,6 +346,32 @@ export class JobService { chainId: number, escrowAddress: string, manifestUrl: string, + ): Promise { + const cacheKey = `${chainId}:${escrowAddress}`; + + const cachedManifest = this.manifestCache.get(cacheKey); + if (cachedManifest) { + return cachedManifest; + } + + const manifestRequest = this.fetchManifest( + chainId, + escrowAddress, + manifestUrl, + ).catch((error) => { + this.manifestCache.delete(cacheKey); + throw error; + }); + + this.manifestCache.set(cacheKey, manifestRequest); + + return manifestRequest; + } + + private async fetchManifest( + chainId: number, + escrowAddress: string, + manifestUrl: string, ): Promise { let manifest: ManifestDto | null = null;