From 368b42e5b8799fc788657b28fcf56801e7ba1ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 30 May 2025 15:56:15 +0200 Subject: [PATCH 1/4] - Created tests for JobService - Added checks for `stripeCustomerId` in JobService to ensure proper handling of users without a Stripe account. - Updated PaymentService to throw errors when `stripeCustomerId` is not present. - Modified UserEntity to allow `stripeCustomerId` to be nullable. - Created migration to enforce non-null constraints on certain fields in the Job entity. - Refactored fixtures for jobs and users to accommodate changes in the structure and ensure proper testing. - Enhanced test cases in PaymentService and ManifestService to reflect the new logic and structure. --- .../server/src/common/interfaces/job.ts | 6 +- ...48612934037-removeNullableFromJobEntity.ts | 61 + .../src/modules/cron-job/cron-job.service.ts | 5 +- .../server/src/modules/job/fixtures.ts | 162 ++ .../server/src/modules/job/job.entity.ts | 12 +- .../src/modules/job/job.service.spec.ts | 1826 +++++++++++++++-- .../server/src/modules/job/job.service.ts | 19 +- .../server/src/modules/manifest/fixtures.ts | 27 +- .../modules/manifest/manifest.service.spec.ts | 6 +- .../modules/payment/payment.service.spec.ts | 10 +- .../src/modules/payment/payment.service.ts | 19 +- .../server/src/modules/user/fixtures.ts | 21 + .../server/src/modules/user/user.entity.ts | 6 +- .../server/test/fixtures/storage.ts | 14 + .../job-launcher/server/test/fixtures/web3.ts | 18 + 15 files changed, 1972 insertions(+), 240 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/database/migrations/1748612934037-removeNullableFromJobEntity.ts create mode 100644 packages/apps/job-launcher/server/src/modules/job/fixtures.ts create mode 100644 packages/apps/job-launcher/server/src/modules/user/fixtures.ts create mode 100644 packages/apps/job-launcher/server/test/fixtures/storage.ts create mode 100644 packages/apps/job-launcher/server/test/fixtures/web3.ts 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..5ee5219707 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/job/fixtures.ts @@ -0,0 +1,162 @@ +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() }], + audioDuration: faker.number.int({ min: 100, max: 1000 }), + 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..9f3528b566 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,1701 @@ /* 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 { - JobStatus, + ErrorEscrow, + ErrorJob, + ErrorWeb3, +} from '../../common/constants/errors'; +import { + AudinoJobType, + CvatJobType, EscrowFundToken, FortuneJobType, + HCaptchaJobType, + JobStatus, + JobStatusFilter, } from '../../common/enums/job'; -import { MOCK_FILE_HASH, MOCK_FILE_URL } from '../../../test/constants'; +import { PaymentCurrency } from '../../common/enums/payment'; +import { + EventType, + OracleType, + WebhookStatus, +} from '../../common/enums/webhook'; +import { + ConflictError, + NotFoundError, + ValidationError, +} from '../../common/errors'; +import { mul } from '../../common/utils/decimal'; +import { getTokenDecimals } from '../../common/utils/tokens'; +import { + AudinoManifestDto, + CvatManifestDto, + FortuneManifestDto, + HCaptchaManifestDto, +} from '../manifest/manifest.dto'; +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 { createUser } from '../user/fixtures'; import { Web3Service } from '../web3/web3.service'; -import { JobFortuneDto } from './job.dto'; +import { WebhookRepository } from '../webhook/webhook.repository'; +import { WhitelistEntity } from '../whitelist/whitelist.entity'; +import { WhitelistService } from '../whitelist/whitelist.service'; +import { + createAudinoJobDto, + createCaptchaJobDto, + createCvatJobDto, + createFortuneJobDto, + createJobEntity, +} from './fixtures'; +import { + FortuneFinalResultDto, + GetJobsDto, + JobFortuneDto, + JobQuickLaunchDto, +} 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 { PaymentRepository } from '../payment/payment.repository'; -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 { WhitelistService } from '../whitelist/whitelist.service'; -import { generateRandomEthAddress } from '../../../test/utils/address'; -import { mul } from '../../common/utils/decimal'; -import { ManifestService } from '../manifest/manifest.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: FortuneManifestDto = { + submissionsRequired: fortuneJobDto.submissionsRequired, + requesterTitle: fortuneJobDto.requesterTitle, + requesterDescription: fortuneJobDto.requesterDescription, + fundAmount: fortuneJobDto.paymentAmount, + requestType: FortuneJobType.FORTUNE, + }; + 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, + FortuneJobType.FORTUNE, + fortuneJobDto, + ); + + 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: 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 = createFortuneJobDto({ + paymentCurrency: PaymentCurrency.USD, + escrowFundToken: EscrowFundToken.HMT, + }); + + const fundTokenDecimals = getTokenDecimals( + fortuneJobDto.chainId!, + fortuneJobDto.escrowFundToken, + ); + + const mockManifest: FortuneManifestDto = { + submissionsRequired: fortuneJobDto.submissionsRequired, + requesterTitle: fortuneJobDto.requesterTitle, + requesterDescription: fortuneJobDto.requesterDescription, + fundAmount: fortuneJobDto.paymentAmount, + requestType: FortuneJobType.FORTUNE, + }; + 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, + FortuneJobType.FORTUNE, + fortuneJobDto, + ); + + 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: expect.any(Number), + 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: FortuneManifestDto = { + submissionsRequired: fortuneJobDto.submissionsRequired, + requesterTitle: fortuneJobDto.requesterTitle, + requesterDescription: fortuneJobDto.requesterDescription, + fundAmount: fortuneJobDto.paymentAmount, + requestType: FortuneJobType.FORTUNE, + }; + 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); + 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, + }); + + await jobService.createJob( + userMock, + FortuneJobType.FORTUNE, + fortuneJobDto, + ); + + 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: expect.any(Number), + 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 throw an exception for invalid chain id provided', async () => { + mockWeb3Service.validateChainId.mockImplementationOnce(() => { + throw new ValidationError(ErrorWeb3.InvalidChainId); }); + const dto = createFortuneJobDto(); + await expect( + jobService.createJob(createUser(), FortuneJobType.FORTUNE, dto), + ).rejects.toThrow(new ValidationError(ErrorWeb3.InvalidChainId)); }); }); + + describe('CVAT', () => { + it('should create a CVAT job', async () => { + const cvatJobDto = createCvatJobDto(); + const fundTokenDecimals = getTokenDecimals( + cvatJobDto.chainId!, + cvatJobDto.escrowFundToken, + ); + + const mockManifest = { + annotation: { description: cvatJobDto.requesterDescription }, + fundAmount: cvatJobDto.paymentAmount, + requestType: CvatJobType.IMAGE_BOXES, + } as unknown as CvatManifestDto; + 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 = { + annotation: { description: audinoJobDto.requesterDescription }, + fundAmount: audinoJobDto.paymentAmount, + requestType: AudinoJobType.AUDIO_TRANSCRIPTION, + } as unknown as AudinoManifestDto; + 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 = { + fundAmount: captchaJobDto.paymentAmount, + requestType: captchaJobDto.annotations.typeOfJob, + } as unknown as HCaptchaManifestDto; + 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(); + const signer = createSignerMock() as unknown as Wallet; + + mockWeb3Service.getSigner.mockReturnValueOnce(signer); + mockedEscrowClient.build.mockResolvedValueOnce({ + createEscrow: jest.fn().mockResolvedValueOnce(escrowAddress), + } as unknown as EscrowClient); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + mockJobRepository.updateOne.mockResolvedValueOnce({ + ...jobEntity, + status: JobStatus.CREATED, + escrowAddress, + } as JobEntity); + + 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, + }); + + const signer = createSignerMock() as unknown as Wallet; + mockWeb3Service.getSigner.mockReturnValueOnce(signer); + 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 signer = createSignerMock() as unknown as Wallet; + mockWeb3Service.getSigner.mockReturnValueOnce(signer); + + const escrowClientMock = { + setup: jest.fn().mockResolvedValueOnce(undefined), + }; + mockedEscrowClient.build.mockResolvedValueOnce( + escrowClientMock as unknown as EscrowClient, + ); + + mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); + mockedKVStoreUtils.get + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('1'); + + const result = await jobService.setupEscrow(jobEntity); + + expect(mockWeb3Service.getSigner).toHaveBeenCalledWith(jobEntity.chainId); + expect(mockWeb3Service.calculateGasPrice).toHaveBeenCalledWith( + jobEntity.chainId, + ); + expect(escrowClientMock.setup).toHaveBeenCalledWith( + jobEntity.escrowAddress, + { + recordingOracle: jobEntity.recordingOracle, + recordingOracleFee: 1n, + reputationOracle: jobEntity.reputationOracle, + reputationOracleFee: 1n, + exchangeOracle: jobEntity.exchangeOracle, + exchangeOracleFee: 1n, + 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 + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('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, + }); + + mockWeb3Service.getSigner.mockReturnValueOnce( + createSignerMock() as unknown as Wallet, + ); + 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('getJobByStatus', () => { + 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', () => { + beforeEach(() => { + (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('should return the correct oracle type for Fortune job', () => { + const jobType = FortuneJobType.FORTUNE; + const oracleType = jobService.getOracleType(jobType); + expect(oracleType).toBe(OracleType.FORTUNE); + }); + + it('should return the correct oracle type for CVAT job', () => { + const jobType = CvatJobType.IMAGE_BOXES; + const oracleType = jobService.getOracleType(jobType); + expect(oracleType).toBe(OracleType.CVAT); + }); + + it('should return the correct oracle type for Audino job', () => { + const jobType = AudinoJobType.AUDIO_TRANSCRIPTION; + const oracleType = jobService.getOracleType(jobType); + expect(oracleType).toBe(OracleType.AUDINO); + }); + + it('should return the correct oracle type for HCaptcha job', () => { + const jobType = HCaptchaJobType.HCAPTCHA; + const oracleType = jobService.getOracleType(jobType); + expect(oracleType).toBe(OracleType.HCAPTCHA); + }); + }); + + describe('processEscrowCancellation', () => { + it('should process escrow cancellation', async () => { + const jobEntity = createJobEntity(); + + const mockedSigner = createSignerMock(); + mockWeb3Service.getSigner.mockReturnValueOnce( + mockedSigner as unknown as Wallet, + ); + 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, + ); + mockJobRepository.updateOne.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 reason', 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 = { + submissionsRequired: faker.number.int({ min: 1, max: 100 }), + requesterTitle: faker.lorem.words(), + requesterDescription: faker.lorem.sentence(), + fundAmount: jobEntity.fundAmount, + requestType: jobEntity.requestType, + } as unknown as FortuneManifestDto; + + 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 = { + submissionsRequired: faker.number.int({ min: 1, max: 100 }), + requesterTitle: faker.lorem.words(), + requesterDescription: faker.lorem.sentence(), + fundAmount: jobEntity.fundAmount, + requestType: jobEntity.requestType, + } as unknown as FortuneManifestDto; + + 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, + ); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntity); + await expect( + jobService.completeJob({ + chainId: ChainId.POLYGON_AMOY, + escrowAddress: faker.finance.ethereumAddress(), + eventType: EventType.ESCROW_COMPLETED, + }), + ).resolves.toBeUndefined(); + }); + + 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 () => { + mockWeb3Service.getSigner.mockReturnValueOnce( + createSignerMock() as unknown as Wallet, + ); + + mockedEscrowClient.build.mockResolvedValue({ + getBalance: jest.fn().mockResolvedValue(100n), + } 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 () => { + mockWeb3Service.getSigner.mockReturnValueOnce( + createSignerMock() as unknown as Wallet, + ); + + 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..224dba8bc6 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,20 @@ 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, 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'; export const mockCvatConfigService: Omit = { jobSize: faker.number.int({ min: 1, max: 1000 }), @@ -35,18 +38,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 { 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..2e5362970f --- /dev/null +++ b/packages/apps/job-launcher/server/test/fixtures/storage.ts @@ -0,0 +1,14 @@ +import { faker } from '@faker-js/faker'; +import { AWSRegions, StorageProviders } from '../../src/common/enums/storage'; + +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)); +} 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, + }; +} From c847ad4c852a9a6d957169a46dc14d96dd1caa1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 30 May 2025 17:54:36 +0200 Subject: [PATCH 2/4] fix: remove audio duration from createAudinoJobDto --- packages/apps/job-launcher/server/src/modules/job/fixtures.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/apps/job-launcher/server/src/modules/job/fixtures.ts b/packages/apps/job-launcher/server/src/modules/job/fixtures.ts index 5ee5219707..16089a658c 100644 --- a/packages/apps/job-launcher/server/src/modules/job/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/job/fixtures.ts @@ -82,7 +82,6 @@ export const createAudinoJobDto = (overrides = {}): JobAudinoDto => ({ path: faker.system.filePath(), }, labels: [{ name: faker.lorem.word() }], - audioDuration: faker.number.int({ min: 100, max: 1000 }), segmentDuration: faker.number.int({ min: 10, max: 100 }), requesterDescription: faker.lorem.sentence(), userGuide: faker.internet.url(), From 79d3b9353f20626eaef8c1e11019a35164f257ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 2 Jun 2025 17:41:38 +0200 Subject: [PATCH 3/4] simplify mocked provider and region retrieval in storage fixtures and solve comments in job service tests --- .../src/modules/job/job.service.spec.ts | 250 ++++++++---------- .../server/src/modules/manifest/fixtures.ts | 129 +++++++++ .../server/test/fixtures/storage.ts | 14 +- 3 files changed, 252 insertions(+), 141 deletions(-) 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 9f3528b566..c8b7c33b79 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 @@ -15,11 +15,7 @@ import { Test } from '@nestjs/testing'; import { ethers, Wallet, ZeroAddress } from 'ethers'; import { createSignerMock } from '../../../test/fixtures/web3'; import { ServerConfigService } from '../../common/config/server-config.service'; -import { - ErrorEscrow, - ErrorJob, - ErrorWeb3, -} from '../../common/constants/errors'; +import { ErrorEscrow, ErrorJob } from '../../common/constants/errors'; import { AudinoJobType, CvatJobType, @@ -40,14 +36,14 @@ import { NotFoundError, ValidationError, } from '../../common/errors'; -import { mul } from '../../common/utils/decimal'; +import { div, max, mul } from '../../common/utils/decimal'; import { getTokenDecimals } from '../../common/utils/tokens'; import { - AudinoManifestDto, - CvatManifestDto, - FortuneManifestDto, - HCaptchaManifestDto, -} from '../manifest/manifest.dto'; + 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'; @@ -73,7 +69,6 @@ import { JobFortuneDto, JobQuickLaunchDto, } from './job.dto'; -import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; import { JobService } from './job.service'; @@ -162,13 +157,12 @@ describe('JobService', () => { fortuneJobDto.escrowFundToken, ); - const mockManifest: FortuneManifestDto = { + const mockManifest = createMockFortuneManifest({ submissionsRequired: fortuneJobDto.submissionsRequired, requesterTitle: fortuneJobDto.requesterTitle, requesterDescription: fortuneJobDto.requesterDescription, fundAmount: fortuneJobDto.paymentAmount, - requestType: FortuneJobType.FORTUNE, - }; + }); mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); @@ -177,19 +171,26 @@ describe('JobService', () => { hash: mockHash, }); const jobEntityMock = createJobEntity(); - mockJobRepository.createUnique = jest - .fn() - .mockResolvedValueOnce(jobEntityMock); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); mockRateService.getRate .mockResolvedValueOnce(tokenToUsdRate) .mockResolvedValueOnce(usdToTokenRate); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); - await jobService.createJob( + 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, ); @@ -228,7 +229,12 @@ describe('JobService', () => { manifestUrl: mockUrl, manifestHash: mockHash, requestType: FortuneJobType.FORTUNE, - fee: expect.any(Number), + fee: Number( + mul( + mul(paymentCurrencyFee, tokenToUsdRate), + usdToTokenRate, + ).toFixed(fundTokenDecimals), + ), fundAmount: fortuneJobDto.paymentAmount, status: JobStatus.MODERATION_PASSED, waitUntil: expect.any(Date), @@ -251,13 +257,12 @@ describe('JobService', () => { fortuneJobDto.escrowFundToken, ); - const mockManifest: FortuneManifestDto = { + const mockManifest = createMockFortuneManifest({ submissionsRequired: fortuneJobDto.submissionsRequired, requesterTitle: fortuneJobDto.requesterTitle, requesterDescription: fortuneJobDto.requesterDescription, fundAmount: fortuneJobDto.paymentAmount, - requestType: FortuneJobType.FORTUNE, - }; + }); mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); @@ -266,19 +271,27 @@ describe('JobService', () => { hash: mockHash, }); const jobEntityMock = createJobEntity(); - mockJobRepository.createUnique = jest - .fn() - .mockResolvedValueOnce(jobEntityMock); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); mockRateService.getRate .mockResolvedValueOnce(tokenToUsdRate) .mockResolvedValueOnce(usdToTokenRate); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); - await jobService.createJob( + 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, ); @@ -317,7 +330,12 @@ describe('JobService', () => { manifestUrl: mockUrl, manifestHash: mockHash, requestType: FortuneJobType.FORTUNE, - fee: expect.any(Number), + fee: Number( + mul( + mul(paymentCurrencyFee, tokenToUsdRate), + usdToTokenRate, + ).toFixed(fundTokenDecimals), + ), fundAmount: Number( mul( mul(fortuneJobDto.paymentAmount, tokenToUsdRate), @@ -348,13 +366,12 @@ describe('JobService', () => { fortuneJobDto.escrowFundToken, ); - const mockManifest: FortuneManifestDto = { + const mockManifest = createMockFortuneManifest({ submissionsRequired: fortuneJobDto.submissionsRequired, requesterTitle: fortuneJobDto.requesterTitle, requesterDescription: fortuneJobDto.requesterDescription, fundAmount: fortuneJobDto.paymentAmount, - requestType: FortuneJobType.FORTUNE, - }; + }); mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); @@ -363,9 +380,7 @@ describe('JobService', () => { hash: mockHash, }); const jobEntityMock = createJobEntity(); - mockJobRepository.createUnique = jest - .fn() - .mockResolvedValueOnce(jobEntityMock); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); mockRateService.getRate .mockResolvedValueOnce(tokenToUsdRate) .mockResolvedValueOnce(usdToTokenRate); @@ -379,12 +394,21 @@ describe('JobService', () => { exchangeOracle: mockOracles.exchangeOracle, reputationOracle: mockOracles.reputationOracle, }); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); - await jobService.createJob( + 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, @@ -423,7 +447,12 @@ describe('JobService', () => { manifestUrl: mockUrl, manifestHash: mockHash, requestType: FortuneJobType.FORTUNE, - fee: expect.any(Number), + fee: Number( + mul( + mul(paymentCurrencyFee, tokenToUsdRate), + usdToTokenRate, + ).toFixed(fundTokenDecimals), + ), fundAmount: fortuneJobDto.paymentAmount, status: JobStatus.MODERATION_PASSED, waitUntil: expect.any(Date), @@ -447,14 +476,15 @@ describe('JobService', () => { ).rejects.toThrow(new ValidationError(ErrorJob.NotActiveCard)); }); - it('should throw an exception for invalid chain id provided', async () => { + it('should re-throw errors from validateChainId', async () => { + const randomError = new Error(faker.lorem.word()); mockWeb3Service.validateChainId.mockImplementationOnce(() => { - throw new ValidationError(ErrorWeb3.InvalidChainId); + throw randomError; }); const dto = createFortuneJobDto(); await expect( jobService.createJob(createUser(), FortuneJobType.FORTUNE, dto), - ).rejects.toThrow(new ValidationError(ErrorWeb3.InvalidChainId)); + ).rejects.toThrow(randomError); }); }); @@ -466,11 +496,7 @@ describe('JobService', () => { cvatJobDto.escrowFundToken, ); - const mockManifest = { - annotation: { description: cvatJobDto.requesterDescription }, - fundAmount: cvatJobDto.paymentAmount, - requestType: CvatJobType.IMAGE_BOXES, - } as unknown as CvatManifestDto; + const mockManifest = createMockCvatManifest(); mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); @@ -552,11 +578,7 @@ describe('JobService', () => { audinoJobDto.escrowFundToken, ); - const mockManifest = { - annotation: { description: audinoJobDto.requesterDescription }, - fundAmount: audinoJobDto.paymentAmount, - requestType: AudinoJobType.AUDIO_TRANSCRIPTION, - } as unknown as AudinoManifestDto; + const mockManifest = createMockAudinoManifest(); mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); @@ -642,10 +664,7 @@ describe('JobService', () => { captchaJobDto.escrowFundToken, ); - const mockManifest = { - fundAmount: captchaJobDto.paymentAmount, - requestType: captchaJobDto.annotations.typeOfJob, - } as unknown as HCaptchaManifestDto; + const mockManifest = createMockHcaptchaManifest(); mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); @@ -808,19 +827,11 @@ describe('JobService', () => { }); const escrowAddress = faker.finance.ethereumAddress(); - const signer = createSignerMock() as unknown as Wallet; - - mockWeb3Service.getSigner.mockReturnValueOnce(signer); mockedEscrowClient.build.mockResolvedValueOnce({ createEscrow: jest.fn().mockResolvedValueOnce(escrowAddress), } as unknown as EscrowClient); mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); - mockJobRepository.updateOne.mockResolvedValueOnce({ - ...jobEntity, - status: JobStatus.CREATED, - escrowAddress, - } as JobEntity); const result = await jobService.createEscrow(jobEntity); @@ -844,8 +855,6 @@ describe('JobService', () => { escrowAddress: null, }); - const signer = createSignerMock() as unknown as Wallet; - mockWeb3Service.getSigner.mockReturnValueOnce(signer); mockedEscrowClient.build.mockResolvedValueOnce({ createEscrow: jest.fn().mockResolvedValueOnce(undefined), } as unknown as EscrowClient); @@ -864,9 +873,6 @@ describe('JobService', () => { status: JobStatus.FUNDED, }); - const signer = createSignerMock() as unknown as Wallet; - mockWeb3Service.getSigner.mockReturnValueOnce(signer); - const escrowClientMock = { setup: jest.fn().mockResolvedValueOnce(undefined), }; @@ -875,10 +881,18 @@ describe('JobService', () => { ); mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); - mockedKVStoreUtils.get - .mockResolvedValueOnce('1') - .mockResolvedValueOnce('1') - .mockResolvedValueOnce('1'); + 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); @@ -889,12 +903,12 @@ describe('JobService', () => { expect(escrowClientMock.setup).toHaveBeenCalledWith( jobEntity.escrowAddress, { - recordingOracle: jobEntity.recordingOracle, - recordingOracleFee: 1n, reputationOracle: jobEntity.reputationOracle, reputationOracleFee: 1n, + recordingOracle: jobEntity.recordingOracle, + recordingOracleFee: 2n, exchangeOracle: jobEntity.exchangeOracle, - exchangeOracleFee: 1n, + exchangeOracleFee: 3n, manifestUrl: jobEntity.manifestUrl, manifestHash: jobEntity.manifestHash, }, @@ -933,10 +947,7 @@ describe('JobService', () => { ); mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); - mockedKVStoreUtils.get - .mockResolvedValueOnce('1') - .mockResolvedValueOnce('1') - .mockResolvedValueOnce('1'); + mockedKVStoreUtils.get.mockResolvedValue('1'); await expect(jobService.setupEscrow(jobEntity)).rejects.toThrow( 'Network error', @@ -950,10 +961,7 @@ describe('JobService', () => { status: JobStatus.PAID, token: EscrowFundToken.HMT, }); - - mockWeb3Service.getSigner.mockReturnValueOnce( - createSignerMock() as unknown as Wallet, - ); + mockedKVStoreUtils.get.mockResolvedValue('1'); mockedEscrowClient.build.mockResolvedValueOnce({ fund: jest.fn().mockResolvedValue(undefined), } as unknown as EscrowClient); @@ -1092,7 +1100,7 @@ describe('JobService', () => { }); }); - describe('getJobByStatus', () => { + describe('getJobsByStatus', () => { it('should return jobs by status', async () => { const jobStatus = JobStatus.LAUNCHED; const jobEntityMock = createJobEntity({ status: jobStatus }); @@ -1317,7 +1325,7 @@ describe('JobService', () => { }); describe('handleProcessJobFailure', () => { - beforeEach(() => { + beforeAll(() => { (mockServerConfigService as any).maxRetryCount = 5; }); @@ -1357,39 +1365,23 @@ describe('JobService', () => { }); describe('getOracleType', () => { - it('should return the correct oracle type for Fortune job', () => { - const jobType = FortuneJobType.FORTUNE; - const oracleType = jobService.getOracleType(jobType); - expect(oracleType).toBe(OracleType.FORTUNE); - }); - - it('should return the correct oracle type for CVAT job', () => { - const jobType = CvatJobType.IMAGE_BOXES; - const oracleType = jobService.getOracleType(jobType); - expect(oracleType).toBe(OracleType.CVAT); - }); - - it('should return the correct oracle type for Audino job', () => { - const jobType = AudinoJobType.AUDIO_TRANSCRIPTION; - const oracleType = jobService.getOracleType(jobType); - expect(oracleType).toBe(OracleType.AUDINO); - }); - - it('should return the correct oracle type for HCaptcha job', () => { - const jobType = HCaptchaJobType.HCAPTCHA; - const oracleType = jobService.getOracleType(jobType); - expect(oracleType).toBe(OracleType.HCAPTCHA); - }); + it.each([ + [FortuneJobType.FORTUNE, OracleType.FORTUNE], + [CvatJobType.IMAGE_BOXES, OracleType.CVAT], + [AudinoJobType.AUDIO_TRANSCRIPTION, OracleType.AUDINO], + [HCaptchaJobType.HCAPTCHA, OracleType.HCAPTCHA], + ])( + 'should return the correct oracle type for %s job', + (jobType, expectedOracleType) => { + const oracleType = jobService.getOracleType(jobType); + expect(oracleType).toBe(expectedOracleType); + }, + ); }); describe('processEscrowCancellation', () => { it('should process escrow cancellation', async () => { const jobEntity = createJobEntity(); - - const mockedSigner = createSignerMock(); - mockWeb3Service.getSigner.mockReturnValueOnce( - mockedSigner as unknown as Wallet, - ); mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n); mockedEscrowClient.build.mockResolvedValue({ getStatus: jest.fn().mockResolvedValue('Active'), @@ -1410,7 +1402,6 @@ describe('JobService', () => { mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( jobEntity, ); - mockJobRepository.updateOne.mockResolvedValueOnce(jobEntity); const reason = faker.lorem.sentence(); await expect( jobService.escrowFailedWebhook({ @@ -1456,7 +1447,7 @@ describe('JobService', () => { ).rejects.toThrow(new ValidationError(ErrorJob.NotLaunched)); }); - it('should throw an error if event data is missing reason', async () => { + it('should throw an error if event data is missing', async () => { const jobEntity = createJobEntity({ status: JobStatus.LAUNCHED }); mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( jobEntity, @@ -1499,13 +1490,9 @@ describe('JobService', () => { jobEntity.token as EscrowFundToken, ); - const manifestMock = { - submissionsRequired: faker.number.int({ min: 1, max: 100 }), - requesterTitle: faker.lorem.words(), - requesterDescription: faker.lorem.sentence(), + const manifestMock = createMockFortuneManifest({ fundAmount: jobEntity.fundAmount, - requestType: jobEntity.requestType, - } as unknown as FortuneManifestDto; + }); const getEscrowData = { token: faker.finance.ethereumAddress(), @@ -1561,13 +1548,9 @@ describe('JobService', () => { it('should return job details without escrow address successfully', async () => { const jobEntity = createJobEntity({ escrowAddress: null }); - const manifestMock = { - submissionsRequired: faker.number.int({ min: 1, max: 100 }), - requesterTitle: faker.lorem.words(), - requesterDescription: faker.lorem.sentence(), + const manifestMock = createMockFortuneManifest({ fundAmount: jobEntity.fundAmount, - requestType: jobEntity.requestType, - } as unknown as FortuneManifestDto; + }); mockJobRepository.findOneByIdAndUserId.mockResolvedValueOnce(jobEntity); mockManifestService.downloadManifest.mockResolvedValueOnce(manifestMock); @@ -1622,7 +1605,6 @@ describe('JobService', () => { mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce( jobEntity, ); - mockJobRepository.updateOne.mockResolvedValueOnce(jobEntity); await expect( jobService.completeJob({ chainId: ChainId.POLYGON_AMOY, @@ -1630,6 +1612,10 @@ describe('JobService', () => { eventType: EventType.ESCROW_COMPLETED, }), ).resolves.toBeUndefined(); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.COMPLETED, + }); }); it('should throw an error if job not found', async () => { @@ -1664,12 +1650,10 @@ describe('JobService', () => { describe('isEscrowFunded', () => { it('should check if escrow is funded', async () => { - mockWeb3Service.getSigner.mockReturnValueOnce( - createSignerMock() as unknown as Wallet, - ); - mockedEscrowClient.build.mockResolvedValue({ - getBalance: jest.fn().mockResolvedValue(100n), + getBalance: jest + .fn() + .mockResolvedValue(BigInt(faker.number.int({ min: 1, max: 1000 }))), } as unknown as EscrowClient); const result = await jobService.isEscrowFunded( faker.number.int(), @@ -1679,10 +1663,6 @@ describe('JobService', () => { }); it('should return false if escrow was not funded', async () => { - mockWeb3Service.getSigner.mockReturnValueOnce( - createSignerMock() as unknown as Wallet, - ); - mockedEscrowClient.build.mockResolvedValue({ getBalance: jest.fn().mockResolvedValue(0n), } as unknown as EscrowClient); 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 224dba8bc6..9b405e0c48 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/fixtures.ts @@ -7,6 +7,7 @@ import { AudinoJobType, CvatJobType, EscrowFundToken, + JobCaptchaRequestType, JobCaptchaShapeType, } from '../../common/enums/job'; import { PaymentCurrency } from '../../common/enums/payment'; @@ -15,6 +16,13 @@ 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 }), @@ -130,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/test/fixtures/storage.ts b/packages/apps/job-launcher/server/test/fixtures/storage.ts index 2e5362970f..a4b1a328f1 100644 --- a/packages/apps/job-launcher/server/test/fixtures/storage.ts +++ b/packages/apps/job-launcher/server/test/fixtures/storage.ts @@ -1,14 +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( - Object.values(StorageProviders).filter( - (provider) => provider !== StorageProviders.LOCAL, - ), - ); + return faker.helpers.arrayElement(PROVIDERS); } export function getMockedRegion(): AWSRegions { - return faker.helpers.arrayElement(Object.values(AWSRegions)); + return faker.helpers.arrayElement(REGIONS); } From 5ab44582751b3cbc2dfd349b39d09de507b3f68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 2 Jun 2025 18:18:13 +0200 Subject: [PATCH 4/4] enhance getOracleType tests for job types with specific assertions --- .../src/modules/job/job.service.spec.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) 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 c8b7c33b79..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 @@ -1365,16 +1365,31 @@ describe('JobService', () => { }); describe('getOracleType', () => { - it.each([ - [FortuneJobType.FORTUNE, OracleType.FORTUNE], - [CvatJobType.IMAGE_BOXES, OracleType.CVAT], - [AudinoJobType.AUDIO_TRANSCRIPTION, OracleType.AUDINO], - [HCaptchaJobType.HCAPTCHA, OracleType.HCAPTCHA], - ])( - 'should return the correct oracle type for %s job', - (jobType, expectedOracleType) => { - const oracleType = jobService.getOracleType(jobType); - expect(oracleType).toBe(expectedOracleType); + 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); }, ); });