diff --git a/packages/apps/job-launcher/server/src/common/interfaces/job.ts b/packages/apps/job-launcher/server/src/common/interfaces/job.ts index feaedde155..856891207e 100644 --- a/packages/apps/job-launcher/server/src/common/interfaces/job.ts +++ b/packages/apps/job-launcher/server/src/common/interfaces/job.ts @@ -6,11 +6,11 @@ export interface IJob extends IBase { chainId: number; fee: number; fundAmount: number; - escrowAddress: string; + escrowAddress: string | null; manifestUrl: string; manifestHash: string; status: JobStatus; - failedReason?: string; - retriesCount?: number; + failedReason: string | null; + retriesCount: number; waitUntil: Date; } diff --git a/packages/apps/job-launcher/server/src/database/migrations/1748612934037-removeNullableFromJobEntity.ts b/packages/apps/job-launcher/server/src/database/migrations/1748612934037-removeNullableFromJobEntity.ts new file mode 100644 index 0000000000..1463a83345 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1748612934037-removeNullableFromJobEntity.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveNullableFromJobEntity1748612934037 + implements MigrationInterface +{ + name = 'RemoveNullableFromJobEntity1748612934037'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "hmt"."IDX_59f6c552b618c432f019500e7c" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "chain_id" + SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "reputation_oracle" + SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "exchange_oracle" + SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "recording_oracle" + SET NOT NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_59f6c552b618c432f019500e7c" ON "hmt"."jobs" ("chain_id", "escrow_address") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "hmt"."IDX_59f6c552b618c432f019500e7c" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "recording_oracle" DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "exchange_oracle" DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "reputation_oracle" DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "chain_id" DROP NOT NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_59f6c552b618c432f019500e7c" ON "hmt"."jobs" ("chain_id", "escrow_address") + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts index 482d5bc904..abfc46cea1 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts @@ -265,10 +265,11 @@ export class CronJobService { for (const jobEntity of jobEntities) { try { if ( - await this.jobService.isEscrowFunded( + jobEntity.escrowAddress && + (await this.jobService.isEscrowFunded( jobEntity.chainId, jobEntity.escrowAddress, - ) + )) ) { const { amountRefunded } = await this.jobService.processEscrowCancellation(jobEntity); diff --git a/packages/apps/job-launcher/server/src/modules/job/fixtures.ts b/packages/apps/job-launcher/server/src/modules/job/fixtures.ts new file mode 100644 index 0000000000..16089a658c --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/job/fixtures.ts @@ -0,0 +1,161 @@ +import { faker } from '@faker-js/faker'; +import { ChainId } from '@human-protocol/sdk'; +import { + getMockedProvider, + getMockedRegion, +} from '../../../test/fixtures/storage'; +import { + AudinoJobType, + CvatJobType, + EscrowFundToken, + FortuneJobType, + JobCaptchaShapeType, +} from '../../common/enums/job'; +import { PaymentCurrency } from '../../common/enums/payment'; +import { + JobAudinoDto, + JobCaptchaDto, + JobCvatDto, + JobFortuneDto, +} from './job.dto'; +import { JobEntity } from './job.entity'; +import { JobStatus } from '../../common/enums/job'; + +export const createFortuneJobDto = (overrides = {}): JobFortuneDto => ({ + chainId: ChainId.POLYGON_AMOY, + submissionsRequired: faker.number.int({ min: 1, max: 10 }), + requesterTitle: faker.lorem.words(3), + requesterDescription: faker.lorem.sentence(), + paymentAmount: faker.number.float({ min: 1, max: 100, fractionDigits: 6 }), + paymentCurrency: faker.helpers.arrayElement(Object.values(PaymentCurrency)), + escrowFundToken: faker.helpers.arrayElement(Object.values(EscrowFundToken)), + exchangeOracle: faker.finance.ethereumAddress(), + recordingOracle: faker.finance.ethereumAddress(), + reputationOracle: faker.finance.ethereumAddress(), + ...overrides, +}); + +export const createCvatJobDto = (overrides = {}): JobCvatDto => ({ + chainId: ChainId.POLYGON_AMOY, + data: { + dataset: { + provider: getMockedProvider(), + region: getMockedRegion(), + bucketName: faker.lorem.word(), + path: faker.system.filePath(), + }, + }, + labels: [{ name: faker.lorem.word(), nodes: [faker.string.uuid()] }], + requesterDescription: faker.lorem.sentence(), + userGuide: faker.internet.url(), + minQuality: faker.number.float({ min: 0.1, max: 1 }), + groundTruth: { + provider: getMockedProvider(), + region: getMockedRegion(), + bucketName: faker.lorem.word(), + path: faker.system.filePath(), + }, + type: faker.helpers.arrayElement(Object.values(CvatJobType)), + paymentCurrency: faker.helpers.arrayElement(Object.values(PaymentCurrency)), + paymentAmount: faker.number.int({ min: 1, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement(Object.values(EscrowFundToken)), + exchangeOracle: faker.finance.ethereumAddress(), + recordingOracle: faker.finance.ethereumAddress(), + reputationOracle: faker.finance.ethereumAddress(), + ...overrides, +}); + +export const createAudinoJobDto = (overrides = {}): JobAudinoDto => ({ + chainId: ChainId.POLYGON_AMOY, + data: { + dataset: { + provider: getMockedProvider(), + region: getMockedRegion(), + bucketName: faker.lorem.word(), + path: faker.system.filePath(), + }, + }, + groundTruth: { + provider: getMockedProvider(), + region: getMockedRegion(), + bucketName: faker.lorem.word(), + path: faker.system.filePath(), + }, + labels: [{ name: faker.lorem.word() }], + segmentDuration: faker.number.int({ min: 10, max: 100 }), + requesterDescription: faker.lorem.sentence(), + userGuide: faker.internet.url(), + qualifications: [faker.lorem.word()], + minQuality: faker.number.int({ min: 1, max: 100 }), + type: AudinoJobType.AUDIO_TRANSCRIPTION, + paymentCurrency: faker.helpers.arrayElement(Object.values(PaymentCurrency)), + paymentAmount: faker.number.int({ min: 1, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement(Object.values(EscrowFundToken)), + exchangeOracle: faker.finance.ethereumAddress(), + recordingOracle: faker.finance.ethereumAddress(), + reputationOracle: faker.finance.ethereumAddress(), + ...overrides, +}); + +export const createCaptchaJobDto = (overrides = {}): JobCaptchaDto => ({ + chainId: ChainId.POLYGON_AMOY, + data: { + provider: getMockedProvider(), + region: getMockedRegion(), + bucketName: faker.lorem.word(), + path: faker.system.filePath(), + }, + accuracyTarget: faker.number.float({ min: 0.1, max: 1, fractionDigits: 4 }), + minRequests: faker.number.int({ min: 1, max: 5 }), + maxRequests: faker.number.int({ min: 6, max: 10 }), + annotations: { + typeOfJob: faker.helpers.arrayElement(Object.values(JobCaptchaShapeType)), + labelingPrompt: faker.lorem.sentence(), + groundTruths: faker.internet.url(), + exampleImages: [faker.internet.url(), faker.internet.url()], + taskBidPrice: faker.number.float({ min: 0.1, max: 10, fractionDigits: 6 }), + label: faker.lorem.word(), + }, + completionDate: faker.date.future(), + paymentCurrency: faker.helpers.arrayElement(Object.values(PaymentCurrency)), + paymentAmount: faker.number.int({ min: 1, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement(Object.values(EscrowFundToken)), + exchangeOracle: faker.finance.ethereumAddress(), + recordingOracle: faker.finance.ethereumAddress(), + reputationOracle: faker.finance.ethereumAddress(), + advanced: {}, + ...overrides, +}); + +export const createJobEntity = ( + overrides: Partial = {}, +): JobEntity => { + const entity = new JobEntity(); + entity.id = faker.number.int(); + entity.chainId = ChainId.POLYGON_AMOY; + entity.reputationOracle = faker.finance.ethereumAddress(); + entity.exchangeOracle = faker.finance.ethereumAddress(); + entity.recordingOracle = faker.finance.ethereumAddress(); + entity.escrowAddress = faker.finance.ethereumAddress(); + entity.fee = faker.number.float({ min: 0.01, max: 1, fractionDigits: 6 }); + entity.fundAmount = faker.number.float({ + min: 1, + max: 1000, + fractionDigits: 6, + }); + entity.token = faker.helpers.arrayElement( + Object.values(EscrowFundToken), + ) as EscrowFundToken; + entity.manifestUrl = faker.internet.url(); + entity.manifestHash = faker.string.uuid(); + entity.failedReason = null; + entity.requestType = FortuneJobType.FORTUNE; + entity.status = faker.helpers.arrayElement(Object.values(JobStatus)); + entity.userId = faker.number.int(); + entity.payments = []; + entity.contentModerationRequests = []; + entity.retriesCount = faker.number.int({ min: 0, max: 4 }); + entity.waitUntil = faker.date.future(); + Object.assign(entity, overrides); + return entity; +}; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts index df4ff35d7c..3887a7670b 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts @@ -11,20 +11,20 @@ import { ContentModerationRequestEntity } from '../content-moderation/content-mo @Entity({ schema: NS, name: 'jobs' }) @Index(['chainId', 'escrowAddress'], { unique: true }) export class JobEntity extends BaseEntity implements IJob { - @Column({ type: 'int', nullable: true }) + @Column({ type: 'int' }) public chainId: number; - @Column({ type: 'varchar', nullable: true }) + @Column({ type: 'varchar' }) public reputationOracle: string; - @Column({ type: 'varchar', nullable: true }) + @Column({ type: 'varchar' }) public exchangeOracle: string; - @Column({ type: 'varchar', nullable: true }) + @Column({ type: 'varchar' }) public recordingOracle: string; @Column({ type: 'varchar', nullable: true }) - public escrowAddress: string; + public escrowAddress: string | null; @Column({ type: 'decimal', precision: 30, scale: 18 }) public fee: number; @@ -42,7 +42,7 @@ export class JobEntity extends BaseEntity implements IJob { public manifestHash: string; @Column({ type: 'varchar', nullable: true }) - public failedReason: string; + public failedReason: string | null; @Column({ type: 'enum', diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index e411c44396..e1ad3c4e17 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -1,237 +1,1696 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +jest.mock('@human-protocol/sdk'); + +import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; -import { ConfigService } from '@nestjs/config'; +import { + ChainId, + EscrowClient, + EscrowUtils, + KVStoreUtils, + NETWORKS, +} from '@human-protocol/sdk'; +import { EscrowData } from '@human-protocol/sdk/dist/graphql'; import { Test } from '@nestjs/testing'; -import { PaymentCurrency } from '../../common/enums/payment'; +import { ethers, Wallet, ZeroAddress } from 'ethers'; +import { createSignerMock } from '../../../test/fixtures/web3'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { ErrorEscrow, ErrorJob } from '../../common/constants/errors'; import { - JobStatus, + AudinoJobType, + CvatJobType, EscrowFundToken, FortuneJobType, + HCaptchaJobType, + JobStatus, + JobStatusFilter, } from '../../common/enums/job'; -import { MOCK_FILE_HASH, MOCK_FILE_URL } from '../../../test/constants'; -import { PaymentService } from '../payment/payment.service'; -import { Web3Service } from '../web3/web3.service'; -import { JobFortuneDto } from './job.dto'; -import { JobEntity } from './job.entity'; -import { JobRepository } from './job.repository'; -import { WebhookRepository } from '../webhook/webhook.repository'; -import { JobService } from './job.service'; +import { PaymentCurrency } from '../../common/enums/payment'; +import { + EventType, + OracleType, + WebhookStatus, +} from '../../common/enums/webhook'; +import { + ConflictError, + NotFoundError, + ValidationError, +} from '../../common/errors'; +import { div, max, mul } from '../../common/utils/decimal'; +import { getTokenDecimals } from '../../common/utils/tokens'; +import { + createMockAudinoManifest, + createMockCvatManifest, + createMockFortuneManifest, + createMockHcaptchaManifest, +} from '../manifest/fixtures'; +import { ManifestService } from '../manifest/manifest.service'; import { PaymentRepository } from '../payment/payment.repository'; +import { PaymentService } from '../payment/payment.service'; +import { QualificationService } from '../qualification/qualification.service'; +import { RateService } from '../rate/rate.service'; import { RoutingProtocolService } from '../routing-protocol/routing-protocol.service'; import { StorageService } from '../storage/storage.service'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { RateService } from '../rate/rate.service'; -import { QualificationService } from '../qualification/qualification.service'; +import { createUser } from '../user/fixtures'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookRepository } from '../webhook/webhook.repository'; +import { WhitelistEntity } from '../whitelist/whitelist.entity'; import { WhitelistService } from '../whitelist/whitelist.service'; -import { generateRandomEthAddress } from '../../../test/utils/address'; -import { mul } from '../../common/utils/decimal'; -import { ManifestService } from '../manifest/manifest.service'; +import { + createAudinoJobDto, + createCaptchaJobDto, + createCvatJobDto, + createFortuneJobDto, + createJobEntity, +} from './fixtures'; +import { + FortuneFinalResultDto, + GetJobsDto, + JobFortuneDto, + JobQuickLaunchDto, +} from './job.dto'; +import { JobRepository } from './job.repository'; +import { JobService } from './job.service'; + +const mockServerConfigService = createMock(); +const mockQualificationService = createMock(); +const mockWeb3Service = createMock(); +const mockJobRepository = createMock(); +const mockWebhookRepository = createMock(); +const mockPaymentRepository = createMock(); +const mockStorageService = createMock(); +const mockPaymentService = createMock(); +const mockRateService = createMock(); +const mockRoutingProtocolService = createMock(); +const mockManifestService = createMock(); +const mockWhitelistService = createMock(); + +const mockedEscrowClient = jest.mocked(EscrowClient); +const mockedEscrowUtils = jest.mocked(EscrowUtils); +const mockedKVStoreUtils = jest.mocked(KVStoreUtils); describe('JobService', () => { - let jobService: JobService, - paymentService: PaymentService, - jobRepository: JobRepository, - rateService: RateService, - manifestService: ManifestService; + const tokenToUsdRate = 2; + const usdToTokenRate = 0.5; + let jobService: JobService; beforeAll(async () => { - jest - .spyOn(ServerConfigService.prototype, 'minimumFeeUsd', 'get') - .mockReturnValue(0.01); - const moduleRef = await Test.createTestingModule({ providers: [ JobService, { provide: ServerConfigService, - useValue: new ServerConfigService(new ConfigService()), + useValue: mockServerConfigService, }, { provide: QualificationService, - useValue: createMock(), + useValue: mockQualificationService, }, - { provide: Web3Service, useValue: createMock() }, + { provide: Web3Service, useValue: mockWeb3Service }, { provide: RateService, - useValue: { - getRate: jest.fn().mockImplementation((from, to) => { - if (from === PaymentCurrency.USD && to !== PaymentCurrency.USD) - return Promise.resolve(2); - if (from !== PaymentCurrency.USD && to === PaymentCurrency.USD) - return Promise.resolve(0.5); - return Promise.resolve(1); - }), - }, + useValue: mockRateService, }, - { provide: JobRepository, useValue: createMock() }, + { provide: JobRepository, useValue: mockJobRepository }, { provide: WebhookRepository, - useValue: createMock(), + useValue: mockWebhookRepository, }, { provide: PaymentRepository, - useValue: createMock(), + useValue: mockPaymentRepository, }, - { provide: PaymentService, useValue: createMock() }, - { provide: StorageService, useValue: createMock() }, - { provide: WhitelistService, useValue: createMock() }, + { provide: PaymentService, useValue: mockPaymentService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: WhitelistService, useValue: mockWhitelistService }, { provide: RoutingProtocolService, - useValue: createMock(), + useValue: mockRoutingProtocolService, }, { provide: ManifestService, - useValue: createMock(), + useValue: mockManifestService, }, ], }).compile(); jobService = moduleRef.get(JobService); - paymentService = moduleRef.get(PaymentService); - rateService = moduleRef.get(RateService); - jobRepository = moduleRef.get(JobRepository); - manifestService = moduleRef.get(ManifestService); + + (mockServerConfigService['minimumFeeUsd'] as any) = 0.01; + }); + + afterEach(() => { + jest.clearAllMocks(); }); describe('createJob', () => { - const userMock: any = { - id: 1, - whitlisted: true, - stripeCustomerId: 'stripeTest', - }; + const userMock = createUser({ whitelist: new WhitelistEntity() }); describe('Fortune', () => { - describe('Successful job creation', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create a Fortune job successfully paid and funded with the same currency', async () => { - const fortuneJobDto: JobFortuneDto = { - chainId: Math.ceil(Math.random() * 100), - submissionsRequired: Math.ceil(Math.random() * 10), - requesterTitle: `Title ${Math.random().toString(36).substring(7)}`, - requesterDescription: `Description ${Math.random().toString(36).substring(7)}`, - paymentAmount: Math.random() * 100, - paymentCurrency: PaymentCurrency.HMT, - escrowFundToken: EscrowFundToken.HMT, - exchangeOracle: generateRandomEthAddress(), - recordingOracle: generateRandomEthAddress(), - reputationOracle: generateRandomEthAddress(), - }; - - jest.spyOn(manifestService, 'uploadManifest').mockResolvedValueOnce({ - url: MOCK_FILE_URL, - hash: MOCK_FILE_HASH, - }); - const jobEntityMock = createMock(); - jobEntityMock.id = 1; - jest - .spyOn(jobRepository, 'createUnique') - .mockResolvedValueOnce(jobEntityMock); - - await jobService.createJob( - userMock, - FortuneJobType.FORTUNE, - fortuneJobDto, - ); - - expect(paymentService.createWithdrawalPayment).toHaveBeenCalledWith( - userMock.id, - expect.any(Number), - fortuneJobDto.paymentCurrency, - await rateService.getRate( - fortuneJobDto.paymentCurrency, - PaymentCurrency.USD, - ), - ); - expect(jobRepository.updateOne).toHaveBeenCalledWith({ - chainId: fortuneJobDto.chainId, - userId: userMock.id, - manifestUrl: MOCK_FILE_URL, - manifestHash: MOCK_FILE_HASH, - requestType: FortuneJobType.FORTUNE, - fee: expect.any(Number), - fundAmount: fortuneJobDto.paymentAmount, - status: JobStatus.MODERATION_PASSED, - waitUntil: expect.any(Date), - token: fortuneJobDto.escrowFundToken, - exchangeOracle: fortuneJobDto.exchangeOracle, - recordingOracle: fortuneJobDto.recordingOracle, - reputationOracle: fortuneJobDto.reputationOracle, - payments: expect.any(Array), - }); - }); - - it('should create a Fortune job successfully paid and funded with different currencies', async () => { - const fortuneJobDto: JobFortuneDto = { - chainId: Math.ceil(Math.random() * 100), - submissionsRequired: Math.ceil(Math.random() * 10), - requesterTitle: `Title ${Math.random().toString(36).substring(7)}`, - requesterDescription: `Description ${Math.random().toString(36).substring(7)}`, - paymentAmount: Math.random() * 100, - paymentCurrency: PaymentCurrency.USD, - escrowFundToken: EscrowFundToken.HMT, - exchangeOracle: generateRandomEthAddress(), - recordingOracle: generateRandomEthAddress(), - reputationOracle: generateRandomEthAddress(), - }; - - jest.spyOn(manifestService, 'uploadManifest').mockResolvedValueOnce({ - url: MOCK_FILE_URL, - hash: MOCK_FILE_HASH, - }); - const jobEntityMock = createMock(); - jobEntityMock.id = 2; - jest - .spyOn(jobRepository, 'createUnique') - .mockResolvedValueOnce(jobEntityMock); - - const paymentToUsdRate = await rateService.getRate( - fortuneJobDto.paymentCurrency, - PaymentCurrency.USD, - ); - - const usdToTokenRate = await rateService.getRate( - PaymentCurrency.USD, - fortuneJobDto.escrowFundToken, - ); - - await jobService.createJob( - userMock, + it('should create a Fortune job successfully paid and funded with the same currency', async () => { + const fortuneJobDto: JobFortuneDto = createFortuneJobDto({ + paymentCurrency: PaymentCurrency.HMT, + escrowFundToken: EscrowFundToken.HMT, + }); + const fundTokenDecimals = getTokenDecimals( + fortuneJobDto.chainId!, + fortuneJobDto.escrowFundToken, + ); + + const mockManifest = createMockFortuneManifest({ + submissionsRequired: fortuneJobDto.submissionsRequired, + requesterTitle: fortuneJobDto.requesterTitle, + requesterDescription: fortuneJobDto.requesterDescription, + fundAmount: fortuneJobDto.paymentAmount, + }); + mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity(); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + + const result = await jobService.createJob( + userMock, + FortuneJobType.FORTUNE, + fortuneJobDto, + ); + + const paymentCurrencyFee = Number( + max( + div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), + mul(div(1, 100), fortuneJobDto.paymentAmount), + ).toFixed(18), + ); + + expect(result).toBe(jobEntityMock.id); + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + fortuneJobDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + fortuneJobDto.chainId, + FortuneJobType.FORTUNE, + fortuneJobDto.reputationOracle, + fortuneJobDto.exchangeOracle, + fortuneJobDto.recordingOracle, + ); + expect(mockManifestService.createManifest).toHaveBeenCalledWith( + fortuneJobDto, + FortuneJobType.FORTUNE, + fortuneJobDto.paymentAmount, + fundTokenDecimals, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + fortuneJobDto.chainId, + mockManifest, + [ + fortuneJobDto.exchangeOracle, + fortuneJobDto.reputationOracle, + fortuneJobDto.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + fortuneJobDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: fortuneJobDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: FortuneJobType.FORTUNE, + fee: Number( + mul( + mul(paymentCurrencyFee, tokenToUsdRate), + usdToTokenRate, + ).toFixed(fundTokenDecimals), + ), + fundAmount: fortuneJobDto.paymentAmount, + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: fortuneJobDto.escrowFundToken, + exchangeOracle: fortuneJobDto.exchangeOracle, + recordingOracle: fortuneJobDto.recordingOracle, + reputationOracle: fortuneJobDto.reputationOracle, + payments: expect.any(Array), + }); + }); + + it('should create a Fortune job successfully paid and funded with different currencies', async () => { + const fortuneJobDto: JobFortuneDto = createFortuneJobDto({ + paymentCurrency: PaymentCurrency.USD, + escrowFundToken: EscrowFundToken.HMT, + }); + + const fundTokenDecimals = getTokenDecimals( + fortuneJobDto.chainId!, + fortuneJobDto.escrowFundToken, + ); + + const mockManifest = createMockFortuneManifest({ + submissionsRequired: fortuneJobDto.submissionsRequired, + requesterTitle: fortuneJobDto.requesterTitle, + requesterDescription: fortuneJobDto.requesterDescription, + fundAmount: fortuneJobDto.paymentAmount, + }); + mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity(); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + + const result = await jobService.createJob( + userMock, + FortuneJobType.FORTUNE, + fortuneJobDto, + ); + + const paymentCurrencyFee = Number( + max( + div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), + mul(div(1, 100), fortuneJobDto.paymentAmount), + ).toFixed(18), + ); + + expect(result).toBe(jobEntityMock.id); + + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + fortuneJobDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + fortuneJobDto.chainId, + FortuneJobType.FORTUNE, + fortuneJobDto.reputationOracle, + fortuneJobDto.exchangeOracle, + fortuneJobDto.recordingOracle, + ); + expect(mockManifestService.createManifest).toHaveBeenCalledWith( + fortuneJobDto, + FortuneJobType.FORTUNE, + Number(fortuneJobDto.paymentAmount.toFixed(6)), + fundTokenDecimals, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + fortuneJobDto.chainId, + mockManifest, + [ + fortuneJobDto.exchangeOracle, + fortuneJobDto.reputationOracle, + fortuneJobDto.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + fortuneJobDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: fortuneJobDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: FortuneJobType.FORTUNE, + fee: Number( + mul( + mul(paymentCurrencyFee, tokenToUsdRate), + usdToTokenRate, + ).toFixed(fundTokenDecimals), + ), + fundAmount: Number( + mul( + mul(fortuneJobDto.paymentAmount, tokenToUsdRate), + usdToTokenRate, + ).toFixed(6), + ), + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: fortuneJobDto.escrowFundToken, + exchangeOracle: fortuneJobDto.exchangeOracle, + recordingOracle: fortuneJobDto.recordingOracle, + reputationOracle: fortuneJobDto.reputationOracle, + payments: expect.any(Array), + }); + }); + + it('should select the right oracles when no oracle addresses provided', async () => { + const fortuneJobDto: JobFortuneDto = createFortuneJobDto({ + paymentCurrency: EscrowFundToken.HMT, + escrowFundToken: EscrowFundToken.HMT, + exchangeOracle: null, + recordingOracle: null, + reputationOracle: null, + }); + + const fundTokenDecimals = getTokenDecimals( + fortuneJobDto.chainId!, + fortuneJobDto.escrowFundToken, + ); + + const mockManifest = createMockFortuneManifest({ + submissionsRequired: fortuneJobDto.submissionsRequired, + requesterTitle: fortuneJobDto.requesterTitle, + requesterDescription: fortuneJobDto.requesterDescription, + fundAmount: fortuneJobDto.paymentAmount, + }); + mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity(); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + const mockOracles = { + recordingOracle: faker.finance.ethereumAddress(), + exchangeOracle: faker.finance.ethereumAddress(), + reputationOracle: faker.finance.ethereumAddress(), + }; + mockRoutingProtocolService.selectOracles.mockResolvedValueOnce({ + recordingOracle: mockOracles.recordingOracle, + exchangeOracle: mockOracles.exchangeOracle, + reputationOracle: mockOracles.reputationOracle, + }); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + + const result = await jobService.createJob( + userMock, + FortuneJobType.FORTUNE, + fortuneJobDto, + ); + const paymentCurrencyFee = Number( + max( + div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), + mul(div(1, 100), fortuneJobDto.paymentAmount), + ).toFixed(18), + ); + + expect(result).toBe(jobEntityMock.id); + + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + fortuneJobDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).toHaveBeenCalledWith( + fortuneJobDto.chainId, + FortuneJobType.FORTUNE, + ); + expect( + mockRoutingProtocolService.validateOracles, + ).not.toHaveBeenCalled(); + expect(mockManifestService.createManifest).toHaveBeenCalledWith( + fortuneJobDto, + FortuneJobType.FORTUNE, + fortuneJobDto.paymentAmount, + fundTokenDecimals, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + fortuneJobDto.chainId, + mockManifest, + [ + mockOracles.exchangeOracle, + mockOracles.reputationOracle, + mockOracles.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + fortuneJobDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: fortuneJobDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: FortuneJobType.FORTUNE, + fee: Number( + mul( + mul(paymentCurrencyFee, tokenToUsdRate), + usdToTokenRate, + ).toFixed(fundTokenDecimals), + ), + fundAmount: fortuneJobDto.paymentAmount, + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: fortuneJobDto.escrowFundToken, + exchangeOracle: mockOracles.exchangeOracle, + recordingOracle: mockOracles.recordingOracle, + reputationOracle: mockOracles.reputationOracle, + payments: expect.any(Array), + }); + }); + + it('should throw if user is not whitelisted and has no payment method', async () => { + mockWhitelistService.isUserWhitelisted.mockResolvedValueOnce(false); + const fortuneJobDto: JobFortuneDto = createFortuneJobDto(); + await expect( + jobService.createJob( + createUser({ stripeCustomerId: null }), FortuneJobType.FORTUNE, fortuneJobDto, - ); - - expect(paymentService.createWithdrawalPayment).toHaveBeenCalledWith( - userMock.id, - expect.any(Number), - fortuneJobDto.paymentCurrency, - paymentToUsdRate, - ); - expect(jobRepository.updateOne).toHaveBeenCalledWith({ - chainId: fortuneJobDto.chainId, - userId: userMock.id, - manifestUrl: MOCK_FILE_URL, - manifestHash: MOCK_FILE_HASH, - requestType: FortuneJobType.FORTUNE, - fee: expect.any(Number), - fundAmount: Number( - mul( - mul(fortuneJobDto.paymentAmount, paymentToUsdRate), - usdToTokenRate, - ).toFixed(6), - ), - status: JobStatus.MODERATION_PASSED, - waitUntil: expect.any(Date), - token: fortuneJobDto.escrowFundToken, - exchangeOracle: fortuneJobDto.exchangeOracle, - recordingOracle: fortuneJobDto.recordingOracle, - reputationOracle: fortuneJobDto.reputationOracle, - payments: expect.any(Array), - }); + ), + ).rejects.toThrow(new ValidationError(ErrorJob.NotActiveCard)); + }); + + it('should re-throw errors from validateChainId', async () => { + const randomError = new Error(faker.lorem.word()); + mockWeb3Service.validateChainId.mockImplementationOnce(() => { + throw randomError; }); + const dto = createFortuneJobDto(); + await expect( + jobService.createJob(createUser(), FortuneJobType.FORTUNE, dto), + ).rejects.toThrow(randomError); }); }); + + describe('CVAT', () => { + it('should create a CVAT job', async () => { + const cvatJobDto = createCvatJobDto(); + const fundTokenDecimals = getTokenDecimals( + cvatJobDto.chainId!, + cvatJobDto.escrowFundToken, + ); + + const mockManifest = createMockCvatManifest(); + mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity(); + mockJobRepository.createUnique = jest + .fn() + .mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + + await jobService.createJob(userMock, cvatJobDto.type, cvatJobDto); + + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + cvatJobDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + cvatJobDto.chainId, + cvatJobDto.type, + cvatJobDto.reputationOracle, + cvatJobDto.exchangeOracle, + cvatJobDto.recordingOracle, + ); + expect(mockManifestService.createManifest).toHaveBeenCalledWith( + cvatJobDto, + cvatJobDto.type, + cvatJobDto.paymentAmount, + fundTokenDecimals, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + cvatJobDto.chainId, + mockManifest, + [ + cvatJobDto.exchangeOracle, + cvatJobDto.reputationOracle, + cvatJobDto.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + cvatJobDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: cvatJobDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: cvatJobDto.type, + fee: expect.any(Number), + fundAmount: Number( + mul( + mul(cvatJobDto.paymentAmount, tokenToUsdRate), + usdToTokenRate, + ).toFixed(6), + ), + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: cvatJobDto.escrowFundToken, + exchangeOracle: cvatJobDto.exchangeOracle, + recordingOracle: cvatJobDto.recordingOracle, + reputationOracle: cvatJobDto.reputationOracle, + payments: expect.any(Array), + }); + }); + }); + + describe('Audino', () => { + it('should create an Audino job', async () => { + const audinoJobDto = createAudinoJobDto(); + const fundTokenDecimals = getTokenDecimals( + audinoJobDto.chainId!, + audinoJobDto.escrowFundToken, + ); + + const mockManifest = createMockAudinoManifest(); + mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity(); + mockJobRepository.createUnique = jest + .fn() + .mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + + await jobService.createJob( + userMock, + AudinoJobType.AUDIO_TRANSCRIPTION, + audinoJobDto, + ); + + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + audinoJobDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + audinoJobDto.chainId, + audinoJobDto.type, + audinoJobDto.reputationOracle, + audinoJobDto.exchangeOracle, + audinoJobDto.recordingOracle, + ); + expect(mockManifestService.createManifest).toHaveBeenCalledWith( + audinoJobDto, + audinoJobDto.type, + audinoJobDto.paymentAmount, + fundTokenDecimals, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + audinoJobDto.chainId, + mockManifest, + [ + audinoJobDto.exchangeOracle, + audinoJobDto.reputationOracle, + audinoJobDto.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + audinoJobDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: audinoJobDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: audinoJobDto.type, + fee: expect.any(Number), + fundAmount: Number( + mul( + mul(audinoJobDto.paymentAmount, tokenToUsdRate), + usdToTokenRate, + ).toFixed(6), + ), + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: audinoJobDto.escrowFundToken, + exchangeOracle: audinoJobDto.exchangeOracle, + recordingOracle: audinoJobDto.recordingOracle, + reputationOracle: audinoJobDto.reputationOracle, + payments: expect.any(Array), + }); + }); + }); + + describe('HCaptcha', () => { + it('should create an HCaptcha job', async () => { + const captchaJobDto = createCaptchaJobDto(); + const fundTokenDecimals = getTokenDecimals( + captchaJobDto.chainId!, + captchaJobDto.escrowFundToken, + ); + + const mockManifest = createMockHcaptchaManifest(); + mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity(); + mockJobRepository.createUnique = jest + .fn() + .mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + + await jobService.createJob( + userMock, + HCaptchaJobType.HCAPTCHA, + captchaJobDto, + ); + + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + captchaJobDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + captchaJobDto.chainId, + HCaptchaJobType.HCAPTCHA, + captchaJobDto.reputationOracle, + captchaJobDto.exchangeOracle, + captchaJobDto.recordingOracle, + ); + expect(mockManifestService.createManifest).toHaveBeenCalledWith( + captchaJobDto, + HCaptchaJobType.HCAPTCHA, + captchaJobDto.paymentAmount, + fundTokenDecimals, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + captchaJobDto.chainId, + mockManifest, + [ + captchaJobDto.exchangeOracle, + captchaJobDto.reputationOracle, + captchaJobDto.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + captchaJobDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: captchaJobDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: HCaptchaJobType.HCAPTCHA, + fee: expect.any(Number), + fundAmount: Number( + mul( + mul(captchaJobDto.paymentAmount, tokenToUsdRate), + usdToTokenRate, + ).toFixed(6), + ), + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: captchaJobDto.escrowFundToken, + exchangeOracle: captchaJobDto.exchangeOracle, + recordingOracle: captchaJobDto.recordingOracle, + reputationOracle: captchaJobDto.reputationOracle, + payments: expect.any(Array), + }); + }); + }); + + describe('JobQuickLaunchDto', () => { + it('should create a job with quick launch dto', async () => { + const jobQuickLaunchDto = new JobQuickLaunchDto(); + jobQuickLaunchDto.chainId = 1; + jobQuickLaunchDto.manifestUrl = faker.internet.url(); + jobQuickLaunchDto.manifestHash = faker.string.uuid(); + jobQuickLaunchDto.requestType = FortuneJobType.FORTUNE; + jobQuickLaunchDto.paymentCurrency = PaymentCurrency.HMT; + jobQuickLaunchDto.paymentAmount = faker.number.float({ + min: 1, + max: 100, + fractionDigits: 6, + }); + jobQuickLaunchDto.escrowFundToken = EscrowFundToken.HMT; + jobQuickLaunchDto.exchangeOracle = faker.finance.ethereumAddress(); + jobQuickLaunchDto.recordingOracle = faker.finance.ethereumAddress(); + jobQuickLaunchDto.reputationOracle = faker.finance.ethereumAddress(); + + const jobEntityMock = createJobEntity(); + mockJobRepository.createUnique = jest + .fn() + .mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + + await jobService.createJob( + userMock, + FortuneJobType.FORTUNE, + jobQuickLaunchDto, + ); + + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + jobQuickLaunchDto.chainId, + ); + expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + jobQuickLaunchDto.chainId, + FortuneJobType.FORTUNE, + jobQuickLaunchDto.reputationOracle, + jobQuickLaunchDto.exchangeOracle, + jobQuickLaunchDto.recordingOracle, + ); + expect(mockManifestService.createManifest).not.toHaveBeenCalled(); + expect(mockManifestService.uploadManifest).not.toHaveBeenCalled(); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + jobQuickLaunchDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: jobQuickLaunchDto.chainId, + userId: userMock.id, + manifestUrl: jobQuickLaunchDto.manifestUrl, + manifestHash: jobQuickLaunchDto.manifestHash, + requestType: FortuneJobType.FORTUNE, + fee: expect.any(Number), + fundAmount: Number( + mul( + mul(jobQuickLaunchDto.paymentAmount, tokenToUsdRate), + usdToTokenRate, + ).toFixed(6), + ), + status: JobStatus.MODERATION_PASSED, + waitUntil: expect.any(Date), + token: jobQuickLaunchDto.escrowFundToken, + exchangeOracle: jobQuickLaunchDto.exchangeOracle, + recordingOracle: jobQuickLaunchDto.recordingOracle, + reputationOracle: jobQuickLaunchDto.reputationOracle, + payments: expect.any(Array), + }); + }); + }); + }); + + describe('createEscrow', () => { + it('should create an escrow and update job entity', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.MODERATION_PASSED, + token: EscrowFundToken.HMT, + escrowAddress: null, + }); + + const escrowAddress = faker.finance.ethereumAddress(); + mockedEscrowClient.build.mockResolvedValueOnce({ + createEscrow: jest.fn().mockResolvedValueOnce(escrowAddress), + } as unknown as EscrowClient); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + + const result = await jobService.createEscrow(jobEntity); + + expect(mockWeb3Service.getSigner).toHaveBeenCalledWith(jobEntity.chainId); + expect(mockWeb3Service.calculateGasPrice).toHaveBeenCalledWith( + jobEntity.chainId, + ); + expect(result.status).toBe(JobStatus.CREATED); + expect(result.escrowAddress).toBe(escrowAddress); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.CREATED, + escrowAddress, + }); + }); + + it('should throw if escrow address is not returned', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.MODERATION_PASSED, + token: EscrowFundToken.HMT, + escrowAddress: null, + }); + + mockedEscrowClient.build.mockResolvedValueOnce({ + createEscrow: jest.fn().mockResolvedValueOnce(undefined), + } as unknown as EscrowClient); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + + await expect(jobService.createEscrow(jobEntity)).rejects.toThrow( + new ConflictError(ErrorEscrow.NotCreated), + ); + }); + }); + + describe('setupEscrow', () => { + it('should setup escrow and update job status', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.FUNDED, + }); + + const escrowClientMock = { + setup: jest.fn().mockResolvedValueOnce(undefined), + }; + mockedEscrowClient.build.mockResolvedValueOnce( + escrowClientMock as unknown as EscrowClient, + ); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + mockedKVStoreUtils.get.mockImplementation(async (_chainId, address) => { + switch (address) { + case jobEntity.reputationOracle: + return '1'; + case jobEntity.recordingOracle: + return '2'; + case jobEntity.exchangeOracle: + return '3'; + default: + return ''; + } + }); + + const result = await jobService.setupEscrow(jobEntity); + + expect(mockWeb3Service.getSigner).toHaveBeenCalledWith(jobEntity.chainId); + expect(mockWeb3Service.calculateGasPrice).toHaveBeenCalledWith( + jobEntity.chainId, + ); + expect(escrowClientMock.setup).toHaveBeenCalledWith( + jobEntity.escrowAddress, + { + reputationOracle: jobEntity.reputationOracle, + reputationOracleFee: 1n, + recordingOracle: jobEntity.recordingOracle, + recordingOracleFee: 2n, + exchangeOracle: jobEntity.exchangeOracle, + exchangeOracleFee: 3n, + manifestUrl: jobEntity.manifestUrl, + manifestHash: jobEntity.manifestHash, + }, + { gasPrice: 1n }, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.LAUNCHED, + }); + expect(result.status).toBe(JobStatus.LAUNCHED); + expect(mockWebhookRepository.createUnique).toHaveBeenCalledWith({ + escrowAddress: jobEntity.escrowAddress, + chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CREATED, + oracleType: jobEntity.requestType, + hasSignature: true, + status: WebhookStatus.PENDING, + retriesCount: 0, + waitUntil: expect.any(Date), + }); + }); + + it('should throw if escrowClient setup fails', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.FUNDED, + }); + + const signer = createSignerMock() as unknown as Wallet; + mockWeb3Service.getSigner.mockReturnValueOnce(signer); + + const escrowClientMock = { + setup: jest.fn().mockRejectedValueOnce(new Error('Network error')), + }; + mockedEscrowClient.build.mockResolvedValueOnce( + escrowClientMock as unknown as EscrowClient, + ); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + mockedKVStoreUtils.get.mockResolvedValue('1'); + + await expect(jobService.setupEscrow(jobEntity)).rejects.toThrow( + 'Network error', + ); + }); + }); + + describe('fundEscrow', () => { + it('should fund escrow with the correct amount and update status', async () => { + const jobEntityMock = createJobEntity({ + status: JobStatus.PAID, + token: EscrowFundToken.HMT, + }); + mockedKVStoreUtils.get.mockResolvedValue('1'); + mockedEscrowClient.build.mockResolvedValueOnce({ + fund: jest.fn().mockResolvedValue(undefined), + } as unknown as EscrowClient); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + + await jobService.fundEscrow(jobEntityMock); + + expect(mockWeb3Service.getSigner).toHaveBeenCalledWith( + jobEntityMock.chainId, + ); + expect(mockWeb3Service.calculateGasPrice).toHaveBeenCalledWith( + jobEntityMock.chainId, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntityMock, + status: JobStatus.FUNDED, + }); + }); + + it('should throw if escrowClient fund fails', async () => { + const jobEntityMock = createJobEntity({ + status: JobStatus.PAID, + token: EscrowFundToken.HMT, + }); + + mockWeb3Service.getSigner.mockReturnValueOnce( + createSignerMock() as unknown as Wallet, + ); + mockedEscrowClient.build.mockResolvedValueOnce({ + fund: jest.fn().mockRejectedValue(new Error('Network error')), + } as unknown as EscrowClient); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + + await expect(jobService.fundEscrow(jobEntityMock)).rejects.toThrow( + 'Network error', + ); + }); + }); + + describe('requestToCancelJobById', () => { + it('should request to cancel job by id', async () => { + const jobEntityMock = createJobEntity({ status: JobStatus.LAUNCHED }); + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + jobEntityMock, + ); + await expect( + jobService.requestToCancelJobById( + jobEntityMock.userId, + jobEntityMock.id, + ), + ).resolves.toBeUndefined(); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntityMock, + status: JobStatus.TO_CANCEL, + retriesCount: 0, + }); + }); + it('should throw an error if job not found', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce(null); + await expect( + jobService.requestToCancelJobById( + faker.number.int(), + faker.number.int(), + ), + ).rejects.toThrow(new NotFoundError(ErrorJob.NotFound)); + }); + it('should throw an error if job status is invalid', async () => { + const jobEntityMock = createJobEntity({ status: JobStatus.COMPLETED }); + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + jobEntityMock, + ); + await expect( + jobService.requestToCancelJobById( + jobEntityMock.userId, + jobEntityMock.id, + ), + ).rejects.toThrow( + new ValidationError(ErrorJob.InvalidStatusCancellation), + ); + }); + }); + + describe('requestToCancelJobByAddress', () => { + it('should request to cancel job by address', async () => { + const jobEntityMock = createJobEntity({ status: JobStatus.LAUNCHED }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntityMock, + ); + await expect( + jobService.requestToCancelJobByAddress( + jobEntityMock.userId, + ChainId.POLYGON_AMOY, + jobEntityMock.escrowAddress!, + ), + ).resolves.toBeUndefined(); + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + ChainId.POLYGON_AMOY, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntityMock, + status: JobStatus.TO_CANCEL, + retriesCount: 0, + }); + }); + it('should throw an error if job not found', async () => { + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + null, + ); + await expect( + jobService.requestToCancelJobByAddress( + faker.number.int(), + faker.number.int(), + faker.finance.ethereumAddress(), + ), + ).rejects.toThrow(new NotFoundError(ErrorJob.NotFound)); + }); + it('should throw an error if job status is invalid', async () => { + const jobEntityMock = createJobEntity({ status: JobStatus.COMPLETED }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntityMock, + ); + await expect( + jobService.requestToCancelJobByAddress( + jobEntityMock.userId, + ChainId.POLYGON_AMOY, + jobEntityMock.escrowAddress!, + ), + ).rejects.toThrow( + new ValidationError(ErrorJob.InvalidStatusCancellation), + ); + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + ChainId.POLYGON_AMOY, + ); + }); + }); + + describe('getJobsByStatus', () => { + it('should return jobs by status', async () => { + const jobStatus = JobStatus.LAUNCHED; + const jobEntityMock = createJobEntity({ status: jobStatus }); + const getJobsDto: GetJobsDto = { + status: JobStatusFilter.LAUNCHED, + page: 1, + pageSize: 10, + skip: 10, + }; + mockJobRepository.fetchFiltered.mockResolvedValueOnce({ + entities: [jobEntityMock], + itemCount: 1, + }); + const result = await jobService.getJobsByStatus( + getJobsDto, + jobEntityMock.userId, + ); + expect(mockWeb3Service.validateChainId).not.toHaveBeenCalled(); + expect(mockJobRepository.fetchFiltered).toHaveBeenCalledWith( + getJobsDto, + jobEntityMock.userId, + ); + expect(result).toEqual({ + page: getJobsDto.page, + pageSize: getJobsDto.pageSize, + totalPages: 1, + totalResults: 1, + results: [ + { + jobId: jobEntityMock.id, + escrowAddress: jobEntityMock.escrowAddress, + network: NETWORKS[jobEntityMock.chainId as ChainId]!.title, + fundAmount: jobEntityMock.fundAmount, + currency: jobEntityMock.token, + status: jobStatus, + }, + ], + }); + }); + + it('should validate chainId if specified', async () => { + const jobStatus = JobStatus.LAUNCHED; + const jobEntityMock = createJobEntity({ status: jobStatus }); + const getJobsDto: GetJobsDto = { + status: JobStatusFilter.LAUNCHED, + chainId: [ChainId.POLYGON_AMOY], + page: 1, + pageSize: 10, + skip: 10, + }; + mockJobRepository.fetchFiltered.mockResolvedValueOnce({ + entities: [jobEntityMock], + itemCount: 1, + }); + const result = await jobService.getJobsByStatus( + getJobsDto, + jobEntityMock.userId, + ); + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + getJobsDto.chainId![0], + ); + expect(mockJobRepository.fetchFiltered).toHaveBeenCalledWith( + getJobsDto, + jobEntityMock.userId, + ); + expect(result).toEqual({ + page: getJobsDto.page, + pageSize: getJobsDto.pageSize, + totalPages: 1, + totalResults: 1, + results: [ + { + jobId: jobEntityMock.id, + escrowAddress: jobEntityMock.escrowAddress, + network: NETWORKS[jobEntityMock.chainId as ChainId]!.title, + fundAmount: jobEntityMock.fundAmount, + currency: jobEntityMock.token, + status: jobStatus, + }, + ], + }); + }); + }); + + describe('getResult', () => { + const userId = faker.number.int(); + const jobId = faker.number.int(); + const url = faker.internet.url(); + + beforeEach(() => { + (EscrowClient.build as any).mockImplementationOnce(() => ({ + getResultsUrl: jest.fn().mockResolvedValueOnce(url), + })); + }); + + it('should download and return the fortune result', async () => { + const jobEntityMock = createJobEntity(); + + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + jobEntityMock, + ); + + const fortuneResult: FortuneFinalResultDto[] = [ + { + workerAddress: faker.finance.ethereumAddress(), + solution: 'good', + }, + { + workerAddress: faker.finance.ethereumAddress(), + solution: 'bad', + error: 'wrong answer', + }, + ]; + + mockStorageService.downloadJsonLikeData.mockResolvedValueOnce( + fortuneResult, + ); + + const result = await jobService.getResult(userId, jobId); + + expect(mockStorageService.downloadJsonLikeData).toHaveBeenCalledWith(url); + expect(mockStorageService.downloadJsonLikeData).toHaveBeenCalledTimes(1); + expect(result).toEqual(fortuneResult); + }); + + it('should download and return the image binary result', async () => { + const jobEntityMock = createJobEntity({ + requestType: CvatJobType.IMAGE_BOXES, + }); + + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + jobEntityMock, + ); + + const result = await jobService.getResult(userId, jobId); + + expect(result).toEqual(url); + }); + + it('should throw a NotFoundError if the result is not found', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + createJobEntity(), + ); + + mockStorageService.downloadJsonLikeData.mockResolvedValueOnce([]); + + await expect(jobService.getResult(userId, jobId)).rejects.toThrow( + new NotFoundError(ErrorJob.ResultNotFound), + ); + expect(mockStorageService.downloadJsonLikeData).toHaveBeenCalledWith(url); + expect(mockStorageService.downloadJsonLikeData).toHaveBeenCalledTimes(1); + }); + + it('should throw a ValidationError if the result is not valid', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + createJobEntity(), + ); + + const fortuneResult: any[] = [ + { + wrongAddress: faker.finance.ethereumAddress(), + solution: 1, + }, + { + wrongAddress: faker.finance.ethereumAddress(), + solution: 1, + error: 1, + }, + ]; + + (EscrowClient.build as any).mockImplementation(() => ({ + getResultsUrl: jest.fn().mockResolvedValue(url), + })); + mockStorageService.downloadJsonLikeData.mockResolvedValueOnce( + fortuneResult, + ); + + await expect(jobService.getResult(userId, jobId)).rejects.toThrow( + new ValidationError(ErrorJob.ResultValidationFailed), + ); + + expect(mockStorageService.downloadJsonLikeData).toHaveBeenCalledWith(url); + expect(mockStorageService.downloadJsonLikeData).toHaveBeenCalledTimes(1); + }); + }); + + describe('downloadJobResults', () => { + it('should download job results', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + createJobEntity({ + requestType: CvatJobType.IMAGE_BOXES, + }), + ); + const sampleFile = Buffer.from('test-file-contents'); + mockStorageService.downloadFile.mockResolvedValueOnce(sampleFile); + const result = await jobService.downloadJobResults( + faker.number.int(), + faker.number.int(), + ); + + expect(result).toHaveProperty('filename'); + expect(result.contents).toEqual(sampleFile); + }); + + it('should throw an error if job not found', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce(null); + await expect( + jobService.downloadJobResults(faker.number.int(), faker.number.int()), + ).rejects.toThrow(new NotFoundError(ErrorJob.NotFound)); + }); + + it('should throw an error if job type is invalid', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce( + createJobEntity({ + requestType: FortuneJobType.FORTUNE, + }), + ); + await expect( + jobService.downloadJobResults(faker.number.int(), faker.number.int()), + ).rejects.toThrow(new ValidationError(ErrorJob.InvalidRequestType)); + }); + }); + + describe('handleProcessJobFailure', () => { + beforeAll(() => { + (mockServerConfigService as any).maxRetryCount = 5; + }); + + it('should increase job failure count', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.LAUNCHED, + retriesCount: 0, + }); + const reason = faker.lorem.sentence(); + await expect( + jobService.handleProcessJobFailure(jobEntity, reason), + ).resolves.toBeUndefined(); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.LAUNCHED, + failedReason: null, + retriesCount: 1, + }); + }); + + it('should handle job failure and update job status', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.LAUNCHED, + retriesCount: 5, + }); + const reason = faker.lorem.sentence(); + await expect( + jobService.handleProcessJobFailure(jobEntity, reason), + ).resolves.toBeUndefined(); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.FAILED, + failedReason: reason, + retriesCount: jobEntity.retriesCount, + }); + }); + }); + + describe('getOracleType', () => { + it.each(Object.values(FortuneJobType))( + 'should return OracleType.FORTUNE for Fortune job type %s', + (jobType) => { + expect(jobService.getOracleType(jobType)).toBe(OracleType.FORTUNE); + }, + ); + + it.each(Object.values(CvatJobType))( + 'should return OracleType.CVAT for CVAT job type %s', + (jobType) => { + expect(jobService.getOracleType(jobType)).toBe(OracleType.CVAT); + }, + ); + + it.each(Object.values(AudinoJobType))( + 'should return OracleType.AUDINO for Audino job type %s', + (jobType) => { + expect(jobService.getOracleType(jobType)).toBe(OracleType.AUDINO); + }, + ); + + it.each(Object.values(HCaptchaJobType))( + 'should return OracleType.HCAPTCHA for HCaptcha job type %s', + (jobType) => { + expect(jobService.getOracleType(jobType)).toBe(OracleType.HCAPTCHA); + }, + ); + }); + + describe('processEscrowCancellation', () => { + it('should process escrow cancellation', async () => { + const jobEntity = createJobEntity(); + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + mockedEscrowClient.build.mockResolvedValue({ + getStatus: jest.fn().mockResolvedValue('Active'), + getBalance: jest.fn().mockResolvedValue(100n), + cancel: jest + .fn() + .mockResolvedValue({ txHash: '0x', amountRefunded: 100n }), + } as unknown as EscrowClient); + + const result = await jobService.processEscrowCancellation(jobEntity); + expect(result).toHaveProperty('txHash'); + }); + }); + + describe('escrowFailedWebhook', () => { + it('should handle escrow failed webhook', async () => { + const jobEntity = createJobEntity({ status: JobStatus.LAUNCHED }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntity, + ); + const reason = faker.lorem.sentence(); + await expect( + jobService.escrowFailedWebhook({ + eventType: EventType.ESCROW_FAILED, + chainId: 1, + escrowAddress: '0x', + eventData: { reason: reason }, + }), + ).resolves.toBeUndefined(); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.FAILED, + failedReason: reason, + }); + }); + + it('should throw an error if job not found', async () => { + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + null, + ); + await expect( + jobService.escrowFailedWebhook({ + eventType: EventType.ESCROW_FAILED, + chainId: 1, + escrowAddress: '0x', + eventData: { reason: faker.lorem.sentence() }, + }), + ).rejects.toThrow(new NotFoundError(ErrorJob.NotFound)); + }); + + it('should throw an error if job is not in LAUNCHED status', async () => { + const jobEntity = createJobEntity({ status: JobStatus.COMPLETED }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntity, + ); + await expect( + jobService.escrowFailedWebhook({ + eventType: EventType.ESCROW_FAILED, + chainId: 1, + escrowAddress: '0x', + eventData: { reason: faker.lorem.sentence() }, + }), + ).rejects.toThrow(new ValidationError(ErrorJob.NotLaunched)); + }); + + it('should throw an error if event data is missing', async () => { + const jobEntity = createJobEntity({ status: JobStatus.LAUNCHED }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntity, + ); + await expect( + jobService.escrowFailedWebhook({ + eventType: EventType.ESCROW_FAILED, + chainId: 1, + escrowAddress: '0x', + }), + ).rejects.toThrow( + new ValidationError('Event data is required but was not provided.'), + ); + }); + + it('should throw an error if event data is missing reason', async () => { + const jobEntity = createJobEntity({ status: JobStatus.LAUNCHED }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntity, + ); + await expect( + jobService.escrowFailedWebhook({ + eventType: EventType.ESCROW_FAILED, + chainId: 1, + escrowAddress: '0x', + eventData: {}, + }), + ).rejects.toThrow( + new ValidationError('Reason is undefined in event data.'), + ); + }); + }); + + describe('getDetails', () => { + it('should return job details with escrow address successfully', async () => { + const jobEntity = createJobEntity(); + + const fundTokenDecimals = getTokenDecimals( + jobEntity.chainId, + jobEntity.token as EscrowFundToken, + ); + + const manifestMock = createMockFortuneManifest({ + fundAmount: jobEntity.fundAmount, + }); + + const getEscrowData = { + token: faker.finance.ethereumAddress(), + totalFundedAmount: ethers.parseUnits( + jobEntity.fundAmount.toString(), + fundTokenDecimals, + ), + balance: 0, + amountPaid: 0, + exchangeOracle: jobEntity.exchangeOracle, + recordingOracle: jobEntity.recordingOracle, + reputationOracle: jobEntity.reputationOracle, + } as unknown as EscrowData; + + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce(jobEntity); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce(getEscrowData); + mockManifestService.downloadManifest.mockResolvedValueOnce(manifestMock); + const signer = createSignerMock() as unknown as Wallet; + mockWeb3Service.getSigner.mockReturnValueOnce(signer); + + const result = await jobService.getDetails( + faker.number.int(), + faker.number.int(), + ); + + expect(result).toStrictEqual({ + details: { + escrowAddress: jobEntity.escrowAddress, + manifestUrl: jobEntity.manifestUrl, + manifestHash: jobEntity.manifestHash, + status: jobEntity.status, + failedReason: jobEntity.failedReason, + balance: getEscrowData.balance, + paidOut: getEscrowData.amountPaid, + currency: jobEntity.token, + }, + manifest: { + title: manifestMock.requesterTitle, + description: manifestMock.requesterDescription, + submissionsRequired: manifestMock.submissionsRequired, + fundAmount: jobEntity.fundAmount, + requestType: manifestMock.requestType, + chainId: jobEntity.chainId, + exchangeOracleAddress: jobEntity.exchangeOracle, + recordingOracleAddress: jobEntity.recordingOracle, + reputationOracleAddress: jobEntity.reputationOracle, + requesterAddress: signer.address, + tokenAddress: getEscrowData.token, + }, + }); + }); + + it('should return job details without escrow address successfully', async () => { + const jobEntity = createJobEntity({ escrowAddress: null }); + + const manifestMock = createMockFortuneManifest({ + fundAmount: jobEntity.fundAmount, + }); + + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce(jobEntity); + mockManifestService.downloadManifest.mockResolvedValueOnce(manifestMock); + const signer = createSignerMock() as unknown as Wallet; + mockWeb3Service.getSigner.mockReturnValueOnce(signer); + + const result = await jobService.getDetails( + faker.number.int(), + faker.number.int(), + ); + expect(mockedEscrowUtils.getEscrow).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + details: { + escrowAddress: ZeroAddress, + manifestUrl: jobEntity.manifestUrl, + manifestHash: jobEntity.manifestHash, + status: jobEntity.status, + failedReason: jobEntity.failedReason, + balance: 0, + paidOut: 0, + }, + manifest: { + title: manifestMock.requesterTitle, + description: manifestMock.requesterDescription, + submissionsRequired: manifestMock.submissionsRequired, + fundAmount: 0, + requestType: manifestMock.requestType, + chainId: jobEntity.chainId, + exchangeOracleAddress: ZeroAddress, + recordingOracleAddress: ZeroAddress, + reputationOracleAddress: ZeroAddress, + requesterAddress: signer.address, + tokenAddress: ZeroAddress, + }, + }); + }); + + it('should throw not found exception when job not found', async () => { + mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce(null); + + await expect( + jobService.getDetails(faker.number.int(), faker.number.int()), + ).rejects.toThrow(new NotFoundError(ErrorJob.NotFound)); + }); + }); + + describe('completeJob', () => { + it('should complete a job', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.LAUNCHED, + }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntity, + ); + await expect( + jobService.completeJob({ + chainId: ChainId.POLYGON_AMOY, + escrowAddress: faker.finance.ethereumAddress(), + eventType: EventType.ESCROW_COMPLETED, + }), + ).resolves.toBeUndefined(); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.COMPLETED, + }); + }); + + it('should throw an error if job not found', async () => { + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + null, + ); + await expect( + jobService.completeJob({ + chainId: ChainId.POLYGON_AMOY, + escrowAddress: faker.finance.ethereumAddress(), + eventType: EventType.ESCROW_COMPLETED, + }), + ).rejects.toThrow(new NotFoundError(ErrorJob.NotFound)); + }); + + it('should throw an error if job is not in LAUNCHED or PARTIAL status', async () => { + const jobEntity = createJobEntity({ + status: JobStatus.CANCELED, + }); + mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( + jobEntity, + ); + await expect( + jobService.completeJob({ + chainId: ChainId.POLYGON_AMOY, + escrowAddress: faker.finance.ethereumAddress(), + eventType: EventType.ESCROW_COMPLETED, + }), + ).rejects.toThrow(new ValidationError(ErrorJob.InvalidStatusCompletion)); + }); + }); + + describe('isEscrowFunded', () => { + it('should check if escrow is funded', async () => { + mockedEscrowClient.build.mockResolvedValue({ + getBalance: jest + .fn() + .mockResolvedValue(BigInt(faker.number.int({ min: 1, max: 1000 }))), + } as unknown as EscrowClient); + const result = await jobService.isEscrowFunded( + faker.number.int(), + faker.finance.ethereumAddress(), + ); + expect(result).toBe(true); + }); + + it('should return false if escrow was not funded', async () => { + mockedEscrowClient.build.mockResolvedValue({ + getBalance: jest.fn().mockResolvedValue(0n), + } as unknown as EscrowClient); + const result = await jobService.isEscrowFunded( + faker.number.int(), + faker.finance.ethereumAddress(), + ); + expect(result).toBe(false); + }); + + it('should return false if escrowAddress is not provided', async () => { + const result = await jobService.isEscrowFunded(faker.number.int(), ''); + expect(result).toBe(false); + }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index d34a6a07c5..93d0e9f284 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -146,6 +146,7 @@ export class JobService { const whitelisted = await this.whitelistService.isUserWhitelisted(user.id); if (!whitelisted) { if ( + !user.stripeCustomerId || !(await this.paymentService.getDefaultPaymentMethod( user.stripeCustomerId, )) @@ -369,7 +370,7 @@ export class JobService { manifestHash: jobEntity.manifestHash, }; - await escrowClient.setup(jobEntity.escrowAddress, escrowConfig, { + await escrowClient.setup(jobEntity.escrowAddress!, escrowConfig, { gasPrice: await this.web3Service.calculateGasPrice(jobEntity.chainId), }); @@ -403,7 +404,7 @@ export class JobService { jobEntity.fundAmount.toString(), token.decimals, ); - await escrowClient.fund(jobEntity.escrowAddress, weiAmount, { + await escrowClient.fund(jobEntity.escrowAddress!, weiAmount, { gasPrice: await this.web3Service.calculateGasPrice(jobEntity.chainId), }); @@ -520,7 +521,7 @@ export class JobService { const jobs = entities.map((job) => { return { jobId: job.id, - escrowAddress: job.escrowAddress, + escrowAddress: job.escrowAddress ?? undefined, network: NETWORKS[job.chainId as ChainId]!.title, fundAmount: job.fundAmount, currency: job.token as EscrowFundToken, @@ -606,14 +607,10 @@ export class JobService { userId, ); - if (!jobEntity) { + if (!jobEntity || !jobEntity.escrowAddress) { throw new NotFoundError(ErrorJob.NotFound); } - if (!jobEntity.escrowAddress) { - throw new NotFoundError(ErrorJob.ResultNotFound); - } - if (jobEntity.requestType === FortuneJobType.FORTUNE) { throw new ValidationError(ErrorJob.InvalidRequestType); } @@ -671,7 +668,7 @@ export class JobService { const signer = this.web3Service.getSigner(chainId); const escrowClient = await EscrowClient.build(signer); - const escrowStatus = await escrowClient.getStatus(escrowAddress); + const escrowStatus = await escrowClient.getStatus(escrowAddress!); if ( escrowStatus === EscrowStatus.Complete || escrowStatus === EscrowStatus.Paid || @@ -680,12 +677,12 @@ export class JobService { throw new ConflictError(ErrorEscrow.InvalidStatusCancellation); } - const balance = await escrowClient.getBalance(escrowAddress); + const balance = await escrowClient.getBalance(escrowAddress!); if (balance === 0n) { throw new ConflictError(ErrorEscrow.InvalidBalanceCancellation); } - return escrowClient.cancel(escrowAddress, { + return escrowClient.cancel(escrowAddress!, { gasPrice: await this.web3Service.calculateGasPrice(chainId), }); } diff --git a/packages/apps/job-launcher/server/src/modules/manifest/fixtures.ts b/packages/apps/job-launcher/server/src/modules/manifest/fixtures.ts index 524b950191..9b405e0c48 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/fixtures.ts @@ -1,17 +1,28 @@ import { faker } from '@faker-js/faker'; -import { JobCvatDto, JobAudinoDto, JobCaptchaDto } from '../job/job.dto'; -import { AWSRegions, StorageProviders } from '../../common/enums/storage'; import { ChainId } from '@human-protocol/sdk'; -import { PaymentCurrency } from '../../common/enums/payment'; +import { AuthConfigService } from '../../common/config/auth-config.service'; +import { CvatConfigService } from '../../common/config/cvat-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; import { AudinoJobType, CvatJobType, EscrowFundToken, + JobCaptchaRequestType, JobCaptchaShapeType, } from '../../common/enums/job'; -import { CvatConfigService } from '../../common/config/cvat-config.service'; -import { AuthConfigService } from '../../common/config/auth-config.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { PaymentCurrency } from '../../common/enums/payment'; +import { JobAudinoDto, JobCaptchaDto, JobCvatDto } from '../job/job.dto'; +import { + getMockedProvider, + getMockedRegion, +} from '../../../test/fixtures/storage'; +import { + AudinoManifestDto, + CvatManifestDto, + FortuneManifestDto, + HCaptchaManifestDto, +} from './manifest.dto'; +import { FortuneJobType } from '../../common/enums/job'; export const mockCvatConfigService: Omit = { jobSize: faker.number.int({ min: 1, max: 1000 }), @@ -35,18 +46,6 @@ export const mockWeb3ConfigService: Omit< hCaptchaRecordingOracleURI: faker.internet.url(), }; -export function getMockedProvider(): StorageProviders { - return faker.helpers.arrayElement( - Object.values(StorageProviders).filter( - (provider) => provider !== StorageProviders.LOCAL, - ), - ); -} - -export function getMockedRegion(): AWSRegions { - return faker.helpers.arrayElement(Object.values(AWSRegions)); -} - export function createJobCvatDto( overrides: Partial = {}, ): JobCvatDto { @@ -139,3 +138,124 @@ export function createJobCaptchaDto( ...overrides, }; } + +export function createMockFortuneManifest( + overrides: Partial = {}, +): FortuneManifestDto { + return { + submissionsRequired: faker.number.int({ min: 1, max: 100 }), + requesterTitle: faker.lorem.sentence(), + requesterDescription: faker.lorem.sentence(), + fundAmount: faker.number.int({ min: 1, max: 100000 }), + requestType: FortuneJobType.FORTUNE, + ...overrides, + }; +} + +export function createMockCvatManifest( + overrides: Partial = {}, +): CvatManifestDto { + return { + data: { + data_url: faker.internet.url(), + points_url: faker.datatype.boolean() ? faker.internet.url() : undefined, + boxes_url: faker.datatype.boolean() ? faker.internet.url() : undefined, + }, + annotation: { + labels: [ + { + name: faker.lorem.word(), + nodes: [faker.lorem.word(), faker.lorem.word()], + joints: [faker.lorem.word()], + }, + ], + description: faker.lorem.sentence(), + user_guide: faker.internet.url(), + type: CvatJobType.IMAGE_BOXES, + job_size: faker.number.int({ min: 1, max: 100 }), + qualifications: [faker.lorem.word()], + }, + validation: { + min_quality: faker.number.float({ min: 0.1, max: 1 }), + val_size: faker.number.int({ min: 1, max: 100 }), + gt_url: faker.internet.url(), + }, + job_bounty: faker.finance.amount(), + ...overrides, + }; +} + +export function createMockAudinoManifest( + overrides: Partial = {}, +): AudinoManifestDto { + return { + data: { + data_url: faker.internet.url(), + }, + annotation: { + labels: [{ name: faker.lorem.word() }], + description: faker.lorem.sentence(), + user_guide: faker.internet.url(), + type: AudinoJobType.AUDIO_TRANSCRIPTION, + segment_duration: faker.number.int({ min: 10, max: 360000 }), + qualifications: [faker.lorem.word()], + }, + validation: { + min_quality: faker.number.float({ min: 0.1, max: 1 }), + gt_url: faker.internet.url(), + }, + ...overrides, + }; +} + +export function createMockHcaptchaManifest( + overrides: Partial = {}, +): HCaptchaManifestDto { + return { + job_mode: faker.lorem.word(), + request_type: faker.helpers.arrayElement( + Object.values(JobCaptchaRequestType), + ), + request_config: { + shape_type: faker.helpers.arrayElement( + Object.values(JobCaptchaShapeType), + ), + min_shapes_per_image: faker.number.int({ min: 1, max: 5 }), + max_shapes_per_image: faker.number.int({ min: 6, max: 10 }), + min_points: faker.number.int({ min: 1, max: 5 }), + max_points: faker.number.int({ min: 6, max: 10 }), + minimum_selection_area_per_shape: faker.number.int({ min: 1, max: 100 }), + multiple_choice_max_choices: faker.number.int({ min: 1, max: 5 }), + multiple_choice_min_choices: faker.number.int({ min: 1, max: 5 }), + answer_type: faker.lorem.word(), + overlap_threshold: faker.number.float({ min: 0, max: 1 }), + max_length: faker.number.int({ min: 1, max: 100 }), + min_length: faker.number.int({ min: 1, max: 100 }), + }, + requester_accuracy_target: faker.number.float({ min: 0.1, max: 1 }), + requester_max_repeats: faker.number.int({ min: 1, max: 10 }), + requester_min_repeats: faker.number.int({ min: 1, max: 10 }), + requester_question_example: [faker.internet.url()], + requester_question: { en: faker.lorem.sentence() }, + taskdata_uri: faker.internet.url(), + job_total_tasks: faker.number.int({ min: 1, max: 1000 }), + task_bid_price: faker.number.float({ min: 0.01, max: 10 }), + groundtruth_uri: faker.internet.url(), + public_results: faker.datatype.boolean(), + oracle_stake: faker.number.int({ min: 1, max: 100 }), + repo_uri: faker.internet.url(), + ro_uri: faker.internet.url(), + restricted_audience: {}, + requester_restricted_answer_set: {}, + taskdata: [ + { + task_key: faker.string.uuid(), + datapoint_uri: faker.internet.url(), + datapoint_hash: faker.string.uuid(), + datapoint_text: { en: faker.lorem.sentence() }, + }, + ], + qualifications: [faker.lorem.word()], + ...overrides, + }; +} diff --git a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts index 619a12ae74..0ae790de59 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts @@ -51,14 +51,16 @@ import { createJobAudinoDto, createJobCaptchaDto, createJobCvatDto, - getMockedProvider, - getMockedRegion, mockAuthConfigService, mockCvatConfigService, mockWeb3ConfigService, } from './fixtures'; import { FortuneManifestDto } from './manifest.dto'; import { ManifestService } from './manifest.service'; +import { + getMockedProvider, + getMockedRegion, +} from '../../../test/fixtures/storage'; describe('ManifestService', () => { let manifestService: ManifestService; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 4f1d74d264..2a7a83146a 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -256,10 +256,7 @@ describe('PaymentService', () => { const user = { id: 1, - paymentInfo: { - customerId: 'test', - paymentMethodId: 'test', - }, + stripeCustomerId: 'cus_123', }; const paymentIntent = { @@ -300,10 +297,7 @@ describe('PaymentService', () => { const user = { id: 1, - paymentInfo: { - customerId: 'test', - paymentMethodId: 'test', - }, + stripeCustomerId: 'cus_123', }; const paymentIntent = { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index db4c2bd3f7..0809be590b 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -80,7 +80,7 @@ export class PaymentService { // Creates a new Stripe customer if the user does not already have one. // It then initiates a SetupIntent to link a payment method (card) to the customer. let setupIntent: Stripe.Response; - let customerId: string = user.stripeCustomerId; + let customerId = user.stripeCustomerId; if (!user.stripeCustomerId) { try { @@ -101,7 +101,7 @@ export class PaymentService { automatic_payment_methods: { enabled: true, }, - customer: customerId, + customer: customerId ?? undefined, }); } catch (error) { this.logger.log(error.message, PaymentService.name); @@ -163,6 +163,10 @@ export class PaymentService { const { amount, currency, paymentMethodId } = dto; const amountInCents = Math.ceil(mul(amount, 100)); + if (!user.stripeCustomerId) { + throw new NotFoundError(ErrorPayment.CustomerNotFound); + } + const invoice = await this.createInvoice( user.stripeCustomerId, amountInCents, @@ -453,8 +457,7 @@ export class PaymentService { const currency = PaymentCurrency.USD; const user = await this.userRepository.findById(job.userId); - if (!user) { - this.logger.log(ErrorPayment.CustomerNotFound, PaymentService.name); + if (!user || !user.stripeCustomerId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } @@ -571,6 +574,7 @@ export class PaymentService { // Check if the payment method is the default one and in use for the user if ( + user.stripeCustomerId && paymentMethod.id === (await this.getDefaultPaymentMethod(user.stripeCustomerId)) && (await this.isPaymentMethodInUse(user.id)) @@ -657,6 +661,9 @@ export class PaymentService { } async changeDefaultPaymentMethod(user: UserEntity, cardId: string) { + if (!user.stripeCustomerId) { + throw new NotFoundError(ErrorPayment.CustomerNotFound); + } // Update the user's default payment method in Stripe return this.stripe.customers.update(user.stripeCustomerId, { invoice_settings: { default_payment_method: cardId }, @@ -705,8 +712,8 @@ export class PaymentService { status: payment.status, transaction: payment.transaction, createdAt: payment.createdAt.toISOString(), - jobId: payment.job ? payment.jobId : undefined, - escrowAddress: payment.job ? payment.job.escrowAddress : undefined, + jobId: payment.jobId ?? undefined, + escrowAddress: payment.job?.escrowAddress ?? undefined, }; }); diff --git a/packages/apps/job-launcher/server/src/modules/user/fixtures.ts b/packages/apps/job-launcher/server/src/modules/user/fixtures.ts new file mode 100644 index 0000000000..06b69d59f2 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/user/fixtures.ts @@ -0,0 +1,21 @@ +import { faker } from '@faker-js/faker'; +import { UserStatus, UserType } from '../../common/enums/user'; +import { UserEntity } from './user.entity'; + +export const createUser = (overrides: Partial = {}): UserEntity => { + const user = new UserEntity(); + user.id = faker.number.int(); + user.email = faker.internet.email(); + user.password = faker.internet.password(); + user.type = faker.helpers.arrayElement(Object.values(UserType)); + user.status = faker.helpers.arrayElement(Object.values(UserStatus)); + user.stripeCustomerId = faker.string.uuid(); + user.jobs = []; + user.payments = []; + user.apiKey = null; + user.whitelist = null; + user.createdAt = faker.date.recent(); + user.updatedAt = new Date(); + Object.assign(user, overrides); + return user; +}; diff --git a/packages/apps/job-launcher/server/src/modules/user/user.entity.ts b/packages/apps/job-launcher/server/src/modules/user/user.entity.ts index f67be393be..c63f108411 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.entity.ts @@ -29,7 +29,7 @@ export class UserEntity extends BaseEntity implements IUser { public status: UserStatus; @Column({ type: 'varchar', nullable: true, unique: true }) - public stripeCustomerId: string; + public stripeCustomerId: string | null; @OneToMany(() => JobEntity, (job) => job.user) public jobs: JobEntity[]; @@ -40,10 +40,10 @@ export class UserEntity extends BaseEntity implements IUser { @OneToOne(() => ApiKeyEntity, (apiKey) => apiKey.user, { nullable: true, }) - public apiKey: ApiKeyEntity; + public apiKey: ApiKeyEntity | null; @OneToOne(() => WhitelistEntity, (whitelist) => whitelist.user, { nullable: true, }) - public whitelist: WhitelistEntity; + public whitelist: WhitelistEntity | null; } diff --git a/packages/apps/job-launcher/server/test/fixtures/storage.ts b/packages/apps/job-launcher/server/test/fixtures/storage.ts new file mode 100644 index 0000000000..a4b1a328f1 --- /dev/null +++ b/packages/apps/job-launcher/server/test/fixtures/storage.ts @@ -0,0 +1,16 @@ +import { faker } from '@faker-js/faker'; +import { AWSRegions, StorageProviders } from '../../src/common/enums/storage'; + +const PROVIDERS = Object.values(StorageProviders).filter( + (provider) => provider !== StorageProviders.LOCAL, +); + +const REGIONS = Object.values(AWSRegions); + +export function getMockedProvider(): StorageProviders { + return faker.helpers.arrayElement(PROVIDERS); +} + +export function getMockedRegion(): AWSRegions { + return faker.helpers.arrayElement(REGIONS); +} diff --git a/packages/apps/job-launcher/server/test/fixtures/web3.ts b/packages/apps/job-launcher/server/test/fixtures/web3.ts new file mode 100644 index 0000000000..6ac5909e8d --- /dev/null +++ b/packages/apps/job-launcher/server/test/fixtures/web3.ts @@ -0,0 +1,18 @@ +import { Wallet } from 'ethers'; + +export type SignerMock = jest.Mocked> & { + __transactionResponse: { + wait: jest.Mock; + }; +}; + +export function createSignerMock(): SignerMock { + const transactionResponse = { + wait: jest.fn(), + }; + + return { + sendTransaction: jest.fn().mockResolvedValue(transactionResponse), + __transactionResponse: transactionResponse, + }; +}