diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx index f9fce014bb..096058f38d 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx @@ -226,6 +226,7 @@ export const CryptoPayForm = ({ paymentTokenSymbol, Number(amount), fundTokenSymbol, + fundAmount, ); } else if (jobType === JobType.CVAT && cvatRequest) { await jobService.createCvatJob( diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx index 82502c9b9b..4da24ea43a 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx @@ -253,6 +253,7 @@ export const FiatPayForm = ({ CURRENCY.usd, amount, tokenSymbol, + fundAmount, ); } else if (jobType === JobType.CVAT && cvatRequest) { await createCvatJob( diff --git a/packages/apps/job-launcher/client/src/services/job.ts b/packages/apps/job-launcher/client/src/services/job.ts index ceb719f492..d705b27bc4 100644 --- a/packages/apps/job-launcher/client/src/services/job.ts +++ b/packages/apps/job-launcher/client/src/services/job.ts @@ -1,34 +1,48 @@ import { ChainId } from '@human-protocol/sdk'; import { - CreateFortuneJobRequest, + CreateJobRequest, CreateCvatJobRequest, FortuneRequest, CvatRequest, JobStatus, JobDetailsResponse, FortuneFinalResult, + FortuneManifest, + JobType, } from '../types'; import api from '../utils/api'; import { getFilenameFromContentDisposition } from '../utils/string'; +const buildFortuneManifest = ( + data: FortuneRequest, + fundAmount: number, +): FortuneManifest => ({ + submissionsRequired: Number(data.fortunesRequested), + requesterTitle: data.title, + requesterDescription: data.description, + fundAmount, + requestType: JobType.FORTUNE, + qualifications: data.qualifications, +}); + export const createFortuneJob = async ( chainId: number, data: FortuneRequest, paymentCurrency: string, paymentAmount: number | string, escrowFundToken: string, + fundAmount: number, ) => { - const body: CreateFortuneJobRequest = { + const body: CreateJobRequest = { chainId, - submissionsRequired: Number(data.fortunesRequested), - requesterTitle: data.title, - requesterDescription: data.description, + requestType: JobType.FORTUNE, paymentCurrency, paymentAmount: Number(paymentAmount), escrowFundToken, qualifications: data.qualifications, + manifest: buildFortuneManifest(data, fundAmount), }; - await api.post('/job/fortune', body); + await api.post('/job', body); }; export const createCvatJob = async ( diff --git a/packages/apps/job-launcher/client/src/types/index.ts b/packages/apps/job-launcher/client/src/types/index.ts index 0ca3d5a11b..478876d6c8 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -39,15 +39,23 @@ export type FiatPaymentRequest = { paymentMethodId: string; }; -export type CreateFortuneJobRequest = { - chainId: number; +export type FortuneManifest = { submissionsRequired: number; requesterTitle: string; requesterDescription: string; + fundAmount: number; + requestType: JobType.FORTUNE; + qualifications?: string[]; +}; + +export type CreateJobRequest> = { + chainId: number; + requestType: JobType; paymentCurrency: string; paymentAmount: number; escrowFundToken: string; qualifications?: string[]; + manifest: TManifest; }; export type CreateCvatJobRequest = { 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 e2f9686b1b..a4a0823533 100644 --- a/packages/apps/job-launcher/server/src/modules/job/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/job/fixtures.ts @@ -2,7 +2,8 @@ import { faker } from '@faker-js/faker'; import { ChainId } from '@human-protocol/sdk'; import { EscrowFundToken, FortuneJobType } from '../../common/enums/job'; import { PaymentCurrency } from '../../common/enums/payment'; -import { JobFortuneDto } from './job.dto'; +import { createMockFortuneManifest } from '../manifest/fixtures'; +import { JobManifestDto } from './job.dto'; import { JobEntity } from './job.entity'; import { JobStatus } from '../../common/enums/job'; @@ -14,11 +15,10 @@ const escrowFundTokens = ( Object.values(EscrowFundToken) as EscrowFundToken[] ).filter((c) => c !== EscrowFundToken.HMT); -export const createFortuneJobDto = (overrides = {}): JobFortuneDto => ({ +export const createJobManifestDto = (overrides = {}): JobManifestDto => ({ chainId: ChainId.POLYGON_AMOY, - submissionsRequired: faker.number.int({ min: 1, max: 10 }), - requesterTitle: faker.lorem.words(3), - requesterDescription: faker.lorem.sentence(), + requestType: FortuneJobType.FORTUNE, + manifest: createMockFortuneManifest(), paymentAmount: faker.number.float({ min: 1, max: 100, fractionDigits: 6 }), paymentCurrency: faker.helpers.arrayElement(paymentCurrencies), escrowFundToken: faker.helpers.arrayElement(escrowFundTokens), diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts index bc2cb7a1df..22fcaaae96 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts @@ -1,33 +1,29 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { JobController } from './job.controller'; -import { JobService } from './job.service'; +import { faker } from '@faker-js/faker/.'; import { BadRequestException, ConflictException, ExecutionContext, UnauthorizedException, } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import { MUTEX_TIMEOUT } from '../../common/constants'; -import { MutexManagerService } from '../mutex/mutex-manager.service'; -import { RequestWithUser } from '../../common/types'; -import { JwtAuthGuard } from '../../common/guards'; -import { JobFortuneDto, JobQuickLaunchDto } from './job.dto'; import { - // CvatJobType, + CvatJobType, EscrowFundToken, FortuneJobType, JobRequestType, } from '../../common/enums/job'; -import { - MOCK_FILE_HASH, - MOCK_FILE_URL, - MOCK_REQUESTER_DESCRIPTION, - MOCK_REQUESTER_TITLE, -} from '../../../test/constants'; -// import { AWSRegions, StorageProviders } from '../../common/enums/storage'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ConfigService } from '@nestjs/config'; import { PaymentCurrency } from '../../common/enums/payment'; +import { JwtAuthGuard } from '../../common/guards'; +import { RequestWithUser } from '../../common/types'; +import { + createMockCvatManifest, + createMockFortuneManifest, +} from '../manifest/fixtures'; +import { MutexManagerService } from '../mutex/mutex-manager.service'; +import { JobController } from './job.controller'; +import { JobManifestDto, JobQuickLaunchDto } from './job.dto'; +import { JobService } from './job.service'; describe('JobController', () => { let jobController: JobController; @@ -56,8 +52,6 @@ describe('JobController', () => { provide: MutexManagerService, useValue: mockMutexManagerService, }, - Web3ConfigService, - ConfigService, ], }) .overrideGuard(JwtAuthGuard) @@ -85,10 +79,10 @@ describe('JobController', () => { it('should create a job and return job ID', async () => { const jobDto: JobQuickLaunchDto = { requestType: 'type_a' as JobRequestType, - manifestUrl: MOCK_FILE_URL, - manifestHash: MOCK_FILE_HASH, + manifestUrl: faker.internet.url(), + manifestHash: faker.string.uuid(), paymentCurrency: PaymentCurrency.USD, - paymentAmount: 500, + paymentAmount: faker.number.int({ min: 100, max: 1000 }), escrowFundToken: EscrowFundToken.HMT, }; @@ -120,10 +114,12 @@ describe('JobController', () => { it('should throw a conflict error if mutex manager fails', async () => { const jobDto: JobQuickLaunchDto = { requestType: 'type_a' as JobRequestType, - manifestUrl: MOCK_FILE_URL, - manifestHash: MOCK_FILE_HASH, - paymentCurrency: PaymentCurrency.USD, - paymentAmount: 500, + manifestUrl: faker.internet.url(), + manifestHash: faker.string.uuid(), + paymentCurrency: faker.helpers.arrayElement( + Object.values(PaymentCurrency), + ), + paymentAmount: faker.number.int({ min: 100, max: 1000 }), escrowFundToken: EscrowFundToken.HMT, }; @@ -142,9 +138,13 @@ describe('JobController', () => { requestType: '', // Invalid input manifestUrl: '', manifestHash: '', - paymentCurrency: PaymentCurrency.USD, - paymentAmount: 500, - escrowFundToken: EscrowFundToken.HMT, + paymentCurrency: faker.helpers.arrayElement( + Object.values(PaymentCurrency), + ), + paymentAmount: faker.number.int({ min: 100, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement( + Object.values(EscrowFundToken), + ), }; mockMutexManagerService.runExclusive.mockRejectedValueOnce( @@ -160,11 +160,15 @@ describe('JobController', () => { it('should return unauthorized error if user is not authenticated', async () => { const jobDto: JobQuickLaunchDto = { requestType: 'type_a' as JobRequestType, - manifestUrl: MOCK_FILE_URL, - manifestHash: MOCK_FILE_HASH, - paymentCurrency: PaymentCurrency.USD, - paymentAmount: 500, - escrowFundToken: EscrowFundToken.HMT, + manifestUrl: faker.internet.url(), + manifestHash: faker.string.uuid(), + paymentCurrency: faker.helpers.arrayElement( + Object.values(PaymentCurrency), + ), + paymentAmount: faker.number.int({ min: 100, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement( + Object.values(EscrowFundToken), + ), }; mockMutexManagerService.runExclusive.mockRejectedValueOnce( @@ -183,26 +187,30 @@ describe('JobController', () => { }); }); - describe('createFortuneJob', () => { - const jobFortuneDto: JobFortuneDto = { - requesterTitle: MOCK_REQUESTER_TITLE, - requesterDescription: MOCK_REQUESTER_DESCRIPTION, - submissionsRequired: 10, - paymentCurrency: PaymentCurrency.HMT, - paymentAmount: 500, - escrowFundToken: EscrowFundToken.HMT, + describe('createJob', () => { + const jobManifestDto: JobManifestDto = { + requestType: FortuneJobType.FORTUNE, + manifest: createMockFortuneManifest({ + requesterTitle: faker.string.sample(), + requesterDescription: faker.string.sample(), + submissionsRequired: faker.number.int({ min: 1, max: 10 }), + }), + paymentCurrency: faker.helpers.arrayElement( + Object.values(PaymentCurrency), + ), + paymentAmount: faker.number.int({ min: 100, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement( + Object.values(EscrowFundToken), + ), }; - it('should create a fortune job successfully', async () => { + it('should create a job successfully', async () => { mockJobService.createJob.mockResolvedValue(1); mockMutexManagerService.runExclusive.mockImplementation( async (_lock, _timeout, fn) => await fn(), ); - const result = await jobController.createFortuneJob( - jobFortuneDto, - mockRequest, - ); + const result = await jobController.createJob(jobManifestDto, mockRequest); expect(result).toBe(1); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -212,8 +220,42 @@ describe('JobController', () => { ); expect(mockJobService.createJob).toHaveBeenCalledWith( mockRequest.user, - FortuneJobType.FORTUNE, - jobFortuneDto, + jobManifestDto.requestType, + jobManifestDto, + ); + }); + + it('should create a CVAT job successfully', async () => { + const cvatManifest = createMockCvatManifest(); + cvatManifest.annotation.type = CvatJobType.IMAGE_BOXES; + + const cvatJobManifestDto: JobManifestDto = { + requestType: CvatJobType.IMAGE_BOXES, + manifest: cvatManifest, + paymentCurrency: faker.helpers.arrayElement( + Object.values(PaymentCurrency), + ), + paymentAmount: faker.number.int({ min: 100, max: 1000 }), + escrowFundToken: faker.helpers.arrayElement( + Object.values(EscrowFundToken), + ), + }; + + mockJobService.createJob.mockResolvedValue(2); + mockMutexManagerService.runExclusive.mockImplementation( + async (_lock, _timeout, fn) => await fn(), + ); + + const result = await jobController.createJob( + cvatJobManifestDto, + mockRequest, + ); + + expect(result).toBe(2); + expect(mockJobService.createJob).toHaveBeenCalledWith( + mockRequest.user, + cvatJobManifestDto.requestType, + cvatJobManifestDto, ); }); @@ -223,7 +265,7 @@ describe('JobController', () => { ); await expect( - jobController.createFortuneJob(jobFortuneDto, mockRequest), + jobController.createJob(jobManifestDto, mockRequest), ).rejects.toThrow(UnauthorizedException); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -240,7 +282,7 @@ describe('JobController', () => { ); await expect( - jobController.createFortuneJob(jobFortuneDto, mockRequest), + jobController.createJob(jobManifestDto, mockRequest), ).rejects.toThrow(ConflictException); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -257,7 +299,7 @@ describe('JobController', () => { ); await expect( - jobController.createFortuneJob(jobFortuneDto, mockRequest), + jobController.createJob(jobManifestDto, mockRequest), ).rejects.toThrow(BadRequestException); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -268,109 +310,4 @@ describe('JobController', () => { expect(mockJobService.createJob).not.toHaveBeenCalled(); }); }); - - //disabled CVAT jobs - // describe('createCvatJob', () => { - // const jobCvatDto: JobCvatDto = { - // requesterDescription: 'Sample description', - // data: { - // dataset: { - // provider: 'AWS' as StorageProviders, - // region: 'us-east-1' as AWSRegions, - // bucketName: 'sample-bucket', - // path: 'path/to/dataset', - // }, - // }, - // labels: [ - // { - // name: 'Label 1', - // nodes: ['node1', 'node2'], - // }, - // ], - // minQuality: 90, - // groundTruth: { - // provider: 'AWS' as StorageProviders, - // region: 'us-west-1' as AWSRegions, - // bucketName: 'ground-truth-bucket', - // path: 'path/to/groundtruth', - // }, - // userGuide: 'https://example.com/user-guide', - // type: CvatJobType.IMAGE_BOXES, - // paymentCurrency: PaymentCurrency.USDC, - // paymentAmount: 500, - // escrowFundToken: EscrowFundToken.USDC, - // }; - - // it('should create a CVAT job successfully', async () => { - // mockJobService.createJob.mockResolvedValue(1); - // mockMutexManagerService.runExclusive.mockImplementation( - // async (_lock, _timeout, fn) => await fn(), - // ); - - // const result = await jobController.createCvatJob(jobCvatDto, mockRequest); - - // expect(result).toBe(1); - // expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( - // `user${mockRequest.user.id}`, - // expect.any(Number), - // expect.any(Function), - // ); - // expect(mockJobService.createJob).toHaveBeenCalledWith( - // mockRequest.user, - // CvatJobType.IMAGE_BOXES, - // jobCvatDto, - // ); - // }); - - // it('should throw UnauthorizedException if user is not authorized', async () => { - // mockMutexManagerService.runExclusive.mockRejectedValueOnce( - // new UnauthorizedException(), - // ); - - // await expect( - // jobController.createCvatJob(jobCvatDto, mockRequest), - // ).rejects.toThrow(UnauthorizedException); - - // expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( - // `user${mockRequest.user.id}`, - // expect.any(Number), - // expect.any(Function), - // ); - // expect(mockJobService.createJob).not.toHaveBeenCalled(); - // }); - - // it('should throw ConflictException if there is a conflict', async () => { - // mockMutexManagerService.runExclusive.mockRejectedValueOnce( - // new ConflictException(), - // ); - - // await expect( - // jobController.createCvatJob(jobCvatDto, mockRequest), - // ).rejects.toThrow(ConflictException); - - // expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( - // `user${mockRequest.user.id}`, - // expect.any(Number), - // expect.any(Function), - // ); - // expect(mockJobService.createJob).not.toHaveBeenCalled(); - // }); - - // it('should throw BadRequestException for invalid input', async () => { - // mockMutexManagerService.runExclusive.mockRejectedValueOnce( - // new BadRequestException(), - // ); - - // await expect( - // jobController.createCvatJob(jobCvatDto, mockRequest), - // ).rejects.toThrow(BadRequestException); - - // expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( - // `user${mockRequest.user.id}`, - // expect.any(Number), - // expect.any(Function), - // ); - // expect(mockJobService.createJob).not.toHaveBeenCalled(); - // }); - // }); }); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts index e6f2c0a6c9..abb5dbdf0f 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts @@ -18,12 +18,8 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; import { MUTEX_TIMEOUT } from '../../common/constants'; import { ApiKey } from '../../common/decorators'; -import { FortuneJobType } from '../../common/enums/job'; -import { Web3Env } from '../../common/enums/web3'; -import { ForbiddenError } from '../../common/errors'; import { JwtAuthGuard } from '../../common/guards'; import { PageDto } from '../../common/pagination/pagination.dto'; import { RequestWithUser } from '../../common/types'; @@ -33,9 +29,9 @@ import { GetJobsDto, JobCancelDto, JobDetailsDto, - JobFortuneDto, JobIdDto, JobListDto, + JobManifestDto, JobQuickLaunchDto, } from './job.dto'; import { JobService } from './job.service'; @@ -49,7 +45,6 @@ export class JobController { constructor( private readonly jobService: JobService, private readonly mutexManagerService: MutexManagerService, - private readonly web3ConfigService: Web3ConfigService, ) {} @ApiOperation({ @@ -94,13 +89,13 @@ export class JobController { } @ApiOperation({ - summary: 'Create a fortune job', - description: 'Endpoint to create a new fortune job.', + summary: 'Create a job', + description: 'Endpoint to create a new job using a manifest JSON body.', }) - @ApiBody({ type: JobFortuneDto }) + @ApiBody({ type: JobManifestDto }) @ApiResponse({ status: 201, - description: 'ID of the created fortune job.', + description: 'ID of the created job.', type: Number, }) @ApiResponse({ @@ -115,22 +110,18 @@ export class JobController { status: 409, description: 'Conflict. Conflict with the current state of the server.', }) - @Post('/fortune') - public async createFortuneJob( - @Body() data: JobFortuneDto, + @Post() + public async createJob( + @Body() data: JobManifestDto, @Request() req: RequestWithUser, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ForbiddenError('Disabled'); - } - return await this.mutexManagerService.runExclusive( `user${req.user.id}`, MUTEX_TIMEOUT, async () => { return await this.jobService.createJob( req.user, - FortuneJobType.FORTUNE, + data.requestType, data, ); }, diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index 919396f901..3c726a5345 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -8,6 +8,7 @@ import { IsNotEmpty, IsNumber, IsNumberString, + IsObject, IsOptional, IsPositive, IsString, @@ -29,7 +30,7 @@ import { AWSRegions, StorageProviders } from '../../common/enums/storage'; import { PageOptionsDto } from '../../common/pagination/pagination.dto'; import { IsValidTokenDecimals } from '../../common/validators/token-decimals'; import { IsValidToken } from '../../common/validators/tokens'; -import { ManifestDetails } from '../manifest/manifest.dto'; +import { ManifestDetails, ManifestDto } from '../manifest/manifest.dto'; export class JobDto { @ApiProperty({ enum: ChainId, required: false, name: 'chain_id' }) @@ -37,6 +38,14 @@ export class JobDto { @IsOptional() public chainId?: ChainId; + @ApiProperty({ + description: 'Request type', + name: 'request_type', + enum: JobType, + }) + @IsEnumCaseInsensitive(JobType) + public requestType: JobRequestType; + @ApiPropertyOptional() @IsArray() @IsOptional() @@ -85,14 +94,6 @@ export class JobDto { } export class JobQuickLaunchDto extends JobDto { - @ApiProperty({ - description: 'Request type', - name: 'request_type', - enum: JobType, - }) - @IsEnumCaseInsensitive(JobType) - public requestType: JobRequestType; - @ApiProperty({ name: 'manifest_url' }) @IsUrl() @IsNotEmpty() @@ -100,25 +101,15 @@ export class JobQuickLaunchDto extends JobDto { @ApiProperty({ name: 'manifest_hash' }) @IsString() - @IsOptional() + @IsNotEmpty() public manifestHash: string; } -export class JobFortuneDto extends JobDto { - @ApiProperty({ name: 'requester_title' }) - @IsString() - @IsNotEmpty() - public requesterTitle: string; - - @ApiProperty({ name: 'requester_description' }) - @IsString() +export class JobManifestDto extends JobDto { + @ApiProperty({ type: Object }) + @IsObject() @IsNotEmpty() - public requesterDescription: string; - - @ApiProperty({ name: 'submissions_required' }) - @IsNumber() - @IsPositive() - public submissionsRequired: number; + public manifest: ManifestDto; } export class StorageDataDto { @@ -298,4 +289,4 @@ export class GetJobsDto extends PageOptionsDto { status?: JobStatusFilter; } -export type CreateJob = JobQuickLaunchDto | JobFortuneDto; +export type CreateJob = JobQuickLaunchDto | JobManifestDto; 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 3d69a1c594..c53f58bd08 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 @@ -39,7 +39,10 @@ import { } from '../../common/errors'; import { div, max, mul } from '../../common/utils/decimal'; import { getTokenDecimals } from '../../common/utils/tokens'; -import { createMockFortuneManifest } from '../manifest/fixtures'; +import { + createMockCvatManifest, + createMockFortuneManifest, +} from '../manifest/fixtures'; import { ManifestService } from '../manifest/manifest.service'; import { PaymentRepository } from '../payment/payment.repository'; import { PaymentService } from '../payment/payment.service'; @@ -52,11 +55,11 @@ import { Web3Service } from '../web3/web3.service'; import { WebhookRepository } from '../webhook/webhook.repository'; import { WhitelistEntity } from '../whitelist/whitelist.entity'; import { WhitelistService } from '../whitelist/whitelist.service'; -import { createFortuneJobDto, createJobEntity } from './fixtures'; +import { createJobEntity, createJobManifestDto } from './fixtures'; import { FortuneFinalResultDto, GetJobsDto, - JobFortuneDto, + JobManifestDto, JobQuickLaunchDto, } from './job.dto'; import { JobRepository } from './job.repository'; @@ -142,22 +145,15 @@ describe('JobService', () => { describe('Fortune', () => { it('should create a Fortune job successfully paid and funded with the same currency', async () => { - const fortuneJobDto: JobFortuneDto = createFortuneJobDto({ + const jobManifestDto: JobManifestDto = createJobManifestDto({ paymentCurrency: PaymentCurrency.USDC, escrowFundToken: EscrowFundToken.USDC, }); const fundTokenDecimals = getTokenDecimals( - fortuneJobDto.chainId!, - fortuneJobDto.escrowFundToken, + jobManifestDto.chainId!, + jobManifestDto.escrowFundToken, ); - const mockManifest = createMockFortuneManifest({ - submissionsRequired: fortuneJobDto.submissionsRequired, - requesterTitle: fortuneJobDto.requesterTitle, - requesterDescription: fortuneJobDto.requesterDescription, - fundAmount: fortuneJobDto.paymentAmount, - }); - mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); mockManifestService.uploadManifest.mockResolvedValueOnce({ @@ -174,50 +170,49 @@ describe('JobService', () => { const result = await jobService.createJob( userMock, FortuneJobType.FORTUNE, - fortuneJobDto, + jobManifestDto, ); const paymentCurrencyFee = Number( max( div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), - mul(div(1, 100), fortuneJobDto.paymentAmount), + mul(div(1, 100), jobManifestDto.paymentAmount), ).toFixed(18), ); expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, ); expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, FortuneJobType.FORTUNE, - fortuneJobDto.reputationOracle, - fortuneJobDto.exchangeOracle, - fortuneJobDto.recordingOracle, + jobManifestDto.reputationOracle, + jobManifestDto.exchangeOracle, + jobManifestDto.recordingOracle, ); - expect(mockManifestService.createManifest).toHaveBeenCalledWith( - fortuneJobDto, + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( FortuneJobType.FORTUNE, - fortuneJobDto.paymentAmount, + jobManifestDto.manifest, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( - fortuneJobDto.chainId, - mockManifest, + jobManifestDto.chainId, + jobManifestDto.manifest, [ - fortuneJobDto.exchangeOracle, - fortuneJobDto.reputationOracle, - fortuneJobDto.recordingOracle, + jobManifestDto.exchangeOracle, + jobManifestDto.reputationOracle, + jobManifestDto.recordingOracle, ], ); expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( userMock.id, expect.any(Number), - fortuneJobDto.paymentCurrency, + jobManifestDto.paymentCurrency, tokenToUsdRate, ); expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ - chainId: fortuneJobDto.chainId, + chainId: jobManifestDto.chainId, userId: userMock.id, manifestUrl: mockUrl, manifestHash: mockHash, @@ -228,35 +223,28 @@ describe('JobService', () => { usdToTokenRate, ).toFixed(fundTokenDecimals), ), - fundAmount: fortuneJobDto.paymentAmount, + fundAmount: jobManifestDto.paymentAmount, status: JobStatus.PAID, waitUntil: expect.any(Date), - token: fortuneJobDto.escrowFundToken, - exchangeOracle: fortuneJobDto.exchangeOracle, - recordingOracle: fortuneJobDto.recordingOracle, - reputationOracle: fortuneJobDto.reputationOracle, + token: jobManifestDto.escrowFundToken, + exchangeOracle: jobManifestDto.exchangeOracle, + recordingOracle: jobManifestDto.recordingOracle, + reputationOracle: jobManifestDto.reputationOracle, payments: expect.any(Array), }); }); it('should create a Fortune job successfully paid and funded with different currencies', async () => { - const fortuneJobDto: JobFortuneDto = createFortuneJobDto({ + const jobManifestDto: JobManifestDto = createJobManifestDto({ paymentCurrency: PaymentCurrency.USD, escrowFundToken: EscrowFundToken.USDC, }); const fundTokenDecimals = getTokenDecimals( - fortuneJobDto.chainId!, - fortuneJobDto.escrowFundToken, + jobManifestDto.chainId!, + jobManifestDto.escrowFundToken, ); - const mockManifest = createMockFortuneManifest({ - submissionsRequired: fortuneJobDto.submissionsRequired, - requesterTitle: fortuneJobDto.requesterTitle, - requesterDescription: fortuneJobDto.requesterDescription, - fundAmount: fortuneJobDto.paymentAmount, - }); - mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); mockManifestService.uploadManifest.mockResolvedValueOnce({ @@ -273,51 +261,50 @@ describe('JobService', () => { const result = await jobService.createJob( userMock, FortuneJobType.FORTUNE, - fortuneJobDto, + jobManifestDto, ); const paymentCurrencyFee = Number( max( div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), - mul(div(1, 100), fortuneJobDto.paymentAmount), + mul(div(1, 100), jobManifestDto.paymentAmount), ).toFixed(18), ); expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, ); expect(mockRoutingProtocolService.selectOracles).not.toHaveBeenCalled(); expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, FortuneJobType.FORTUNE, - fortuneJobDto.reputationOracle, - fortuneJobDto.exchangeOracle, - fortuneJobDto.recordingOracle, + jobManifestDto.reputationOracle, + jobManifestDto.exchangeOracle, + jobManifestDto.recordingOracle, ); - expect(mockManifestService.createManifest).toHaveBeenCalledWith( - fortuneJobDto, + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( FortuneJobType.FORTUNE, - Number(fortuneJobDto.paymentAmount.toFixed(6)), + jobManifestDto.manifest, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( - fortuneJobDto.chainId, - mockManifest, + jobManifestDto.chainId, + jobManifestDto.manifest, [ - fortuneJobDto.exchangeOracle, - fortuneJobDto.reputationOracle, - fortuneJobDto.recordingOracle, + jobManifestDto.exchangeOracle, + jobManifestDto.reputationOracle, + jobManifestDto.recordingOracle, ], ); expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( userMock.id, expect.any(Number), - fortuneJobDto.paymentCurrency, + jobManifestDto.paymentCurrency, tokenToUsdRate, ); expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ - chainId: fortuneJobDto.chainId, + chainId: jobManifestDto.chainId, userId: userMock.id, manifestUrl: mockUrl, manifestHash: mockHash, @@ -330,22 +317,22 @@ describe('JobService', () => { ), fundAmount: Number( mul( - mul(fortuneJobDto.paymentAmount, tokenToUsdRate), + mul(jobManifestDto.paymentAmount, tokenToUsdRate), usdToTokenRate, ).toFixed(6), ), status: JobStatus.PAID, waitUntil: expect.any(Date), - token: fortuneJobDto.escrowFundToken, - exchangeOracle: fortuneJobDto.exchangeOracle, - recordingOracle: fortuneJobDto.recordingOracle, - reputationOracle: fortuneJobDto.reputationOracle, + token: jobManifestDto.escrowFundToken, + exchangeOracle: jobManifestDto.exchangeOracle, + recordingOracle: jobManifestDto.recordingOracle, + reputationOracle: jobManifestDto.reputationOracle, payments: expect.any(Array), }); }); it('should select the right oracles when no oracle addresses provided', async () => { - const fortuneJobDto: JobFortuneDto = createFortuneJobDto({ + const jobManifestDto: JobManifestDto = createJobManifestDto({ paymentCurrency: EscrowFundToken.USDC, escrowFundToken: EscrowFundToken.USDC, exchangeOracle: null, @@ -354,17 +341,10 @@ describe('JobService', () => { }); const fundTokenDecimals = getTokenDecimals( - fortuneJobDto.chainId!, - fortuneJobDto.escrowFundToken, + jobManifestDto.chainId!, + jobManifestDto.escrowFundToken, ); - const mockManifest = createMockFortuneManifest({ - submissionsRequired: fortuneJobDto.submissionsRequired, - requesterTitle: fortuneJobDto.requesterTitle, - requesterDescription: fortuneJobDto.requesterDescription, - fundAmount: fortuneJobDto.paymentAmount, - }); - mockManifestService.createManifest.mockResolvedValueOnce(mockManifest); const mockUrl = faker.internet.url(); const mockHash = faker.string.uuid(); mockManifestService.uploadManifest.mockResolvedValueOnce({ @@ -391,35 +371,34 @@ describe('JobService', () => { const result = await jobService.createJob( userMock, FortuneJobType.FORTUNE, - fortuneJobDto, + jobManifestDto, ); const paymentCurrencyFee = Number( max( div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), - mul(div(1, 100), fortuneJobDto.paymentAmount), + mul(div(1, 100), jobManifestDto.paymentAmount), ).toFixed(18), ); expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, ); expect(mockRoutingProtocolService.selectOracles).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, FortuneJobType.FORTUNE, ); expect( mockRoutingProtocolService.validateOracles, ).not.toHaveBeenCalled(); - expect(mockManifestService.createManifest).toHaveBeenCalledWith( - fortuneJobDto, + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( FortuneJobType.FORTUNE, - fortuneJobDto.paymentAmount, + jobManifestDto.manifest, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( - fortuneJobDto.chainId, - mockManifest, + jobManifestDto.chainId, + jobManifestDto.manifest, [ mockOracles.exchangeOracle, mockOracles.reputationOracle, @@ -429,11 +408,11 @@ describe('JobService', () => { expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( userMock.id, expect.any(Number), - fortuneJobDto.paymentCurrency, + jobManifestDto.paymentCurrency, tokenToUsdRate, ); expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ - chainId: fortuneJobDto.chainId, + chainId: jobManifestDto.chainId, userId: userMock.id, manifestUrl: mockUrl, manifestHash: mockHash, @@ -444,10 +423,10 @@ describe('JobService', () => { usdToTokenRate, ).toFixed(fundTokenDecimals), ), - fundAmount: fortuneJobDto.paymentAmount, + fundAmount: jobManifestDto.paymentAmount, status: JobStatus.PAID, waitUntil: expect.any(Date), - token: fortuneJobDto.escrowFundToken, + token: jobManifestDto.escrowFundToken, exchangeOracle: mockOracles.exchangeOracle, recordingOracle: mockOracles.recordingOracle, reputationOracle: mockOracles.reputationOracle, @@ -457,12 +436,12 @@ describe('JobService', () => { it('should throw if user is not whitelisted and has no payment method', async () => { mockWhitelistService.isUserWhitelisted.mockResolvedValueOnce(false); - const fortuneJobDto: JobFortuneDto = createFortuneJobDto(); + const jobManifestDto: JobManifestDto = createJobManifestDto(); await expect( jobService.createJob( createUser({ paymentProviderId: null }), FortuneJobType.FORTUNE, - fortuneJobDto, + jobManifestDto, ), ).rejects.toThrow(new ValidationError(ErrorJob.NotActiveCard)); }); @@ -472,13 +451,105 @@ describe('JobService', () => { mockWeb3Service.validateChainId.mockImplementationOnce(() => { throw randomError; }); - const dto = createFortuneJobDto(); + const dto = createJobManifestDto(); await expect( jobService.createJob(createUser(), FortuneJobType.FORTUNE, dto), ).rejects.toThrow(randomError); }); }); + describe('CVAT', () => { + it('should create a CVAT job successfully with a manifest JSON body', async () => { + const cvatManifest = createMockCvatManifest(); + cvatManifest.annotation.type = CvatJobType.IMAGE_BOXES; + + const jobManifestDto: JobManifestDto = createJobManifestDto({ + requestType: CvatJobType.IMAGE_BOXES, + manifest: cvatManifest, + paymentCurrency: PaymentCurrency.USDC, + escrowFundToken: EscrowFundToken.USDC, + }); + const fundTokenDecimals = getTokenDecimals( + jobManifestDto.chainId!, + jobManifestDto.escrowFundToken, + ); + const mockUrl = faker.internet.url(); + const mockHash = faker.string.uuid(); + mockManifestService.uploadManifest.mockResolvedValueOnce({ + url: mockUrl, + hash: mockHash, + }); + const jobEntityMock = createJobEntity({ + requestType: CvatJobType.IMAGE_BOXES, + }); + mockJobRepository.updateOne.mockResolvedValueOnce(jobEntityMock); + mockRateService.getRate + .mockResolvedValueOnce(tokenToUsdRate) + .mockResolvedValueOnce(usdToTokenRate); + mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + + const result = await jobService.createJob( + userMock, + CvatJobType.IMAGE_BOXES, + jobManifestDto, + ); + + const paymentCurrencyFee = Number( + max( + div(mockServerConfigService.minimumFeeUsd, tokenToUsdRate), + mul(div(1, 100), jobManifestDto.paymentAmount), + ).toFixed(fundTokenDecimals), + ); + + expect(result).toBe(jobEntityMock.id); + expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( + jobManifestDto.chainId, + ); + expect(mockRoutingProtocolService.validateOracles).toHaveBeenCalledWith( + jobManifestDto.chainId, + CvatJobType.IMAGE_BOXES, + jobManifestDto.reputationOracle, + jobManifestDto.exchangeOracle, + jobManifestDto.recordingOracle, + ); + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( + CvatJobType.IMAGE_BOXES, + jobManifestDto.manifest, + ); + expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( + jobManifestDto.chainId, + jobManifestDto.manifest, + [ + jobManifestDto.exchangeOracle, + jobManifestDto.reputationOracle, + jobManifestDto.recordingOracle, + ], + ); + expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + jobManifestDto.paymentCurrency, + tokenToUsdRate, + ); + expect(mockJobRepository.updateOne).toHaveBeenCalledWith({ + chainId: jobManifestDto.chainId, + userId: userMock.id, + manifestUrl: mockUrl, + manifestHash: mockHash, + requestType: CvatJobType.IMAGE_BOXES, + fee: paymentCurrencyFee, + fundAmount: jobManifestDto.paymentAmount, + status: JobStatus.PAID, + waitUntil: expect.any(Date), + token: jobManifestDto.escrowFundToken, + exchangeOracle: jobManifestDto.exchangeOracle, + recordingOracle: jobManifestDto.recordingOracle, + reputationOracle: jobManifestDto.reputationOracle, + payments: expect.any(Array), + }); + }); + }); + describe('JobQuickLaunchDto', () => { it('should create a job with quick launch dto', async () => { const jobQuickLaunchDto = new JobQuickLaunchDto(); @@ -522,7 +593,11 @@ describe('JobService', () => { jobQuickLaunchDto.exchangeOracle, jobQuickLaunchDto.recordingOracle, ); - expect(mockManifestService.createManifest).not.toHaveBeenCalled(); + expect(mockManifestService.downloadManifest).toHaveBeenCalledWith( + jobQuickLaunchDto.manifestUrl, + HCaptchaJobType.HCAPTCHA, + ); + expect(mockManifestService.validateManifest).not.toHaveBeenCalled(); expect(mockManifestService.uploadManifest).not.toHaveBeenCalled(); expect(mockPaymentService.createWithdrawalPayment).toHaveBeenCalledWith( userMock.id, 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 385dd3961a..4168d4456b 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 @@ -47,7 +47,6 @@ import { ValidationError, } from '../../common/errors'; import { PageDto } from '../../common/pagination/pagination.dto'; -import { parseUrl } from '../../common/utils'; import { add, div, max, mul } from '../../common/utils/decimal'; import { getTokenDecimals } from '../../common/utils/tokens'; import logger from '../../logger'; @@ -56,6 +55,7 @@ import { CvatManifestDto, FortuneManifestDto, HCaptchaManifestDto, + ManifestDto, } from '../manifest/manifest.dto'; import { ManifestService } from '../manifest/manifest.service'; import { PaymentService } from '../payment/payment.service'; @@ -75,7 +75,6 @@ import { GetJobsDto, JobDetailsDto, JobListDto, - JobQuickLaunchDto, } from './job.dto'; import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; @@ -235,35 +234,31 @@ export class JobService { let jobEntity = new JobEntity(); - if (dto instanceof JobQuickLaunchDto) { - if (!dto.manifestHash) { - const { filename } = parseUrl(dto.manifestUrl); - - if (!filename) { - throw new ValidationError(ErrorJob.ManifestHashNotExist); - } + if ('manifestUrl' in dto) { + await this.manifestService.downloadManifest(dto.manifestUrl, requestType); - jobEntity.manifestHash = filename; - } else { - jobEntity.manifestHash = dto.manifestHash; + if (!dto.manifestHash) { + throw new ValidationError(ErrorJob.ManifestHashNotExist); } + jobEntity.manifestHash = dto.manifestHash; jobEntity.manifestUrl = dto.manifestUrl; - } else { - const manifestOrigin = await this.manifestService.createManifest( - dto, + } else if ('manifest' in dto) { + await this.manifestService.validateManifest( requestType, - fundTokenAmount, + dto.manifest as ManifestDto, ); const { url, hash } = await this.manifestService.uploadManifest( chainId, - manifestOrigin, + dto.manifest, [exchangeOracle, reputationOracle, recordingOracle], ); jobEntity.manifestUrl = url; jobEntity.manifestHash = hash; + } else { + throw new ValidationError(ErrorJob.InvalidRequestType); } const paymentEntity = await this.paymentService.createWithdrawalPayment( 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 21e93dfa4e..163313ffd0 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 @@ -4,13 +4,18 @@ import { Encryption } from '@human-protocol/sdk'; import { Test } from '@nestjs/testing'; import { PGPConfigService } from '../../common/config/pgp-config.service'; import { ErrorJob } from '../../common/constants/errors'; -import { CvatJobType, FortuneJobType } from '../../common/enums/job'; +import { + CvatJobType, + FortuneJobType, + HCaptchaJobType, + JobCaptchaRequestType, +} from '../../common/enums/job'; import { ServerError, ValidationError } from '../../common/errors'; -import { JobFortuneDto } from '../job/job.dto'; import { StorageService } from '../storage/storage.service'; import { Web3Service } from '../web3/web3.service'; -import { ManifestService } from './manifest.service'; +import { createMockCvatManifest, createMockFortuneManifest } from './fixtures'; import { ManifestDto } from './manifest.dto'; +import { ManifestService } from './manifest.service'; describe('ManifestService', () => { let manifestService: ManifestService; @@ -37,46 +42,107 @@ describe('ManifestService', () => { jest.clearAllMocks(); }); - describe('createManifest', () => { - it('should create a fortune manifest', async () => { - const dto: JobFortuneDto = { - requesterTitle: faker.lorem.sentence(), - requesterDescription: faker.lorem.sentence(), - submissionsRequired: faker.number.int({ min: 1, max: 100 }), - paymentCurrency: faker.helpers.arrayElement([0, 1]) as any, - paymentAmount: faker.number.int({ min: 1, max: 1000 }), - escrowFundToken: faker.helpers.arrayElement(['HMT', 'USDC']) as any, - }; - + describe('validateManifest', () => { + it('should validate a fortune manifest successfully', async () => { await expect( - manifestService.createManifest( - dto, + manifestService.validateManifest( FortuneJobType.FORTUNE, - dto.paymentAmount, + createMockFortuneManifest(), ), - ).resolves.toEqual({ - ...dto, - requestType: FortuneJobType.FORTUNE, - fundAmount: dto.paymentAmount, - }); + ).resolves.toBeUndefined(); + }); + + it('should validate a cvat manifest successfully', async () => { + const manifest = createMockCvatManifest(); + manifest.annotation.type = CvatJobType.IMAGE_BOXES; + + await expect( + manifestService.validateManifest(CvatJobType.IMAGE_BOXES, manifest), + ).resolves.toBeUndefined(); + }); + + it('should validate an hcaptcha manifest successfully', async () => { + const manifest = { + job_mode: faker.lorem.word(), + request_type: JobCaptchaRequestType.IMAGE_LABEL_BINARY, + request_config: {}, + requester_accuracy_target: faker.number.float({ + min: 0.5, + max: 1, + fractionDigits: 2, + }), + requester_max_repeats: faker.number.int({ min: 2, max: 10 }), + requester_min_repeats: faker.number.int({ min: 1, max: 1 }), + requester_question: { en: faker.lorem.sentence() }, + taskdata_uri: faker.internet.url(), + job_total_tasks: faker.number.int({ min: 1, max: 100 }), + task_bid_price: faker.number.int({ min: 1, max: 10 }), + public_results: faker.datatype.boolean(), + oracle_stake: faker.number.int({ min: 1, max: 10 }), + repo_uri: faker.internet.url(), + ro_uri: faker.internet.url(), + restricted_audience: {}, + requester_restricted_answer_set: {}, + }; + + await expect( + manifestService.validateManifest(HCaptchaJobType.HCAPTCHA, manifest), + ).resolves.toBeUndefined(); + }); + + it('should throw when a required fortune property is missing', async () => { + const manifest = createMockFortuneManifest(); + delete (manifest as Partial).fundAmount; + + await expect( + manifestService.validateManifest(FortuneJobType.FORTUNE, manifest), + ).rejects.toThrow(new ValidationError(ErrorJob.ManifestValidationFailed)); + }); + + it('should throw when a required cvat property is missing', async () => { + const manifest = createMockCvatManifest(); + delete (manifest.validation as Partial<(typeof manifest)['validation']>) + .gt_url; + + await expect( + manifestService.validateManifest(CvatJobType.IMAGE_BOXES, manifest), + ).rejects.toThrow(new ValidationError(ErrorJob.ManifestValidationFailed)); }); - it('should reject non-fortune request types', async () => { + it('should throw when a required hcaptcha property is missing', async () => { + const manifest = { + job_mode: faker.lorem.word(), + request_type: JobCaptchaRequestType.IMAGE_LABEL_BINARY, + request_config: {}, + requester_accuracy_target: faker.number.float({ + min: 0.5, + max: 1, + fractionDigits: 2, + }), + requester_max_repeats: faker.number.int({ min: 2, max: 10 }), + requester_min_repeats: faker.number.int({ min: 1, max: 1 }), + requester_question: { en: faker.lorem.sentence() }, + job_total_tasks: faker.number.int({ min: 1, max: 100 }), + task_bid_price: faker.number.int({ min: 1, max: 10 }), + public_results: faker.datatype.boolean(), + oracle_stake: faker.number.int({ min: 1, max: 10 }), + repo_uri: faker.internet.url(), + ro_uri: faker.internet.url(), + restricted_audience: {}, + requester_restricted_answer_set: {}, + }; + await expect( - manifestService.createManifest( - {} as JobFortuneDto, - CvatJobType.IMAGE_BOXES, - 1, + manifestService.validateManifest( + HCaptchaJobType.HCAPTCHA, + manifest as unknown as ManifestDto, ), - ).rejects.toThrow(new ValidationError(ErrorJob.InvalidRequestType)); + ).rejects.toThrow(new ValidationError(ErrorJob.ManifestValidationFailed)); }); }); describe('uploadManifest', () => { it('should upload a manifest successfully', async () => { - const mockChainId = faker.number.int(); - const mockData = { key: faker.lorem.word() }; - const mockOracleAddresses: string[] = []; const mockManifestData = { url: faker.internet.url(), hash: faker.string.uuid(), @@ -87,33 +153,24 @@ describe('ManifestService', () => { ); const result = await manifestService.uploadManifest( - mockChainId, - mockData, - mockOracleAddresses, + faker.number.int(), + { key: faker.lorem.word() }, + [], ); - expect(result).toEqual( - expect.objectContaining({ - url: mockManifestData.url, - hash: mockManifestData.hash, - }), - ); + expect(result).toEqual(mockManifestData); }); it('should throw an error if upload fails', async () => { - const mockChainId = faker.number.int(); - const mockData = { key: faker.lorem.word() }; - const mockOracleAddresses: string[] = []; - - mockStorageService.uploadJsonLikeData.mockRejectedValue( + mockStorageService.uploadJsonLikeData.mockRejectedValueOnce( new ServerError('File not uploaded'), ); await expect( manifestService.uploadManifest( - mockChainId, - mockData, - mockOracleAddresses, + faker.number.int(), + { key: faker.lorem.word() }, + [], ), ).rejects.toThrow(ServerError); }); @@ -121,42 +178,33 @@ describe('ManifestService', () => { describe('downloadManifest', () => { it('should download and validate a manifest successfully', async () => { - const mockManifestUrl = faker.internet.url(); - const mockRequestType = FortuneJobType.FORTUNE; - const mockManifest: ManifestDto = { - submissionsRequired: faker.number.int({ min: 1, max: 100 }), - requesterTitle: faker.lorem.words(3), - requesterDescription: faker.lorem.sentence(), - fundAmount: faker.number.float({ min: 1, max: 1000 }), - requestType: FortuneJobType.FORTUNE, - qualifications: [faker.lorem.word(), faker.lorem.word()], - }; + const mockManifest: ManifestDto = createMockFortuneManifest(); + mockStorageService.downloadJsonLikeData.mockResolvedValueOnce( mockManifest, ); + const result = await manifestService.downloadManifest( - mockManifestUrl, - mockRequestType, + faker.internet.url(), + FortuneJobType.FORTUNE, ); + expect(result).toEqual(mockManifest); }); - it('should throw an error if validation fails', async () => { - const mockManifestUrl = faker.internet.url(); - const mockRequestType = CvatJobType.IMAGE_BOXES; - const mockManifest: ManifestDto = { - submissionsRequired: faker.number.int({ min: 1, max: 100 }), - requesterTitle: faker.lorem.words(3), - requesterDescription: faker.lorem.sentence(), - fundAmount: faker.number.float({ min: 1, max: 1000 }), - requestType: FortuneJobType.FORTUNE, - qualifications: [faker.lorem.word(), faker.lorem.word()], - }; + it('should throw if downloaded manifest is invalid', async () => { + const mockManifest = createMockFortuneManifest(); + delete (mockManifest as Partial).fundAmount; + mockStorageService.downloadJsonLikeData.mockResolvedValueOnce( mockManifest, ); + await expect( - manifestService.downloadManifest(mockManifestUrl, mockRequestType), + manifestService.downloadManifest( + faker.internet.url(), + FortuneJobType.FORTUNE, + ), ).rejects.toThrow(new ValidationError(ErrorJob.ManifestValidationFailed)); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts index 490aac01d2..1712174445 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts @@ -4,6 +4,7 @@ import { Injectable, } from '@nestjs/common'; import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; import { PGPConfigService } from '../../common/config/pgp-config.service'; import { ErrorJob } from '../../common/constants/errors'; import { @@ -12,7 +13,6 @@ import { JobRequestType, } from '../../common/enums/job'; import { ValidationError } from '../../common/errors'; -import { JobFortuneDto } from '../job/job.dto'; import { StorageService } from '../storage/storage.service'; import { Web3Service } from '../web3/web3.service'; import { @@ -33,22 +33,6 @@ export class ManifestService { private readonly encryption: Encryption, ) {} - async createManifest( - dto: JobFortuneDto, - requestType: JobRequestType, - fundAmount: number, - ): Promise { - if (requestType !== FortuneJobType.FORTUNE) { - throw new ValidationError(ErrorJob.InvalidRequestType); - } - - return { - ...dto, - requestType, - fundAmount, - }; - } - async uploadManifest( chainId: ChainId, data: any, @@ -75,24 +59,22 @@ export class ManifestService { return this.storageService.uploadJsonLikeData(manifestFile); } - private async validateManifest( + public async validateManifest( requestType: JobRequestType, manifest: ManifestDto, ): Promise { let dtoCheck; if (requestType === FortuneJobType.FORTUNE) { - dtoCheck = new FortuneManifestDto(); + dtoCheck = plainToInstance(FortuneManifestDto, manifest); } else if (requestType === HCaptchaJobType.HCAPTCHA) { - return; - dtoCheck = new HCaptchaManifestDto(); + dtoCheck = plainToInstance(HCaptchaManifestDto, manifest); } else { - dtoCheck = new CvatManifestDto(); + dtoCheck = plainToInstance(CvatManifestDto, manifest); } - Object.assign(dtoCheck, manifest); - const validationErrors: ClassValidationError[] = await validate(dtoCheck); + if (validationErrors.length > 0) { throw new ValidationError(ErrorJob.ManifestValidationFailed); } diff --git a/packages/apps/job-launcher/server/test/constants.ts b/packages/apps/job-launcher/server/test/constants.ts index 438e96be3b..6aafe70564 100644 --- a/packages/apps/job-launcher/server/test/constants.ts +++ b/packages/apps/job-launcher/server/test/constants.ts @@ -2,8 +2,6 @@ import { FortuneJobType } from '../src/common/enums/job'; import { Web3Env } from '../src/common/enums/web3'; import { FortuneManifestDto } from '../src/modules/manifest/manifest.dto'; -export const MOCK_REQUESTER_TITLE = 'Mock job title'; -export const MOCK_REQUESTER_DESCRIPTION = 'Mock job description'; export const MOCK_ADDRESS = '0xCf88b3f1992458C2f5a229573c768D0E9F70C44e'; export const MOCK_FILE_URL = 'http://mockedFileUrl.test/bucket/file.json'; export const MOCK_FILE_HASH = 'mockedFileHash';