diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx index 10c649ee37..71094902e1 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx @@ -99,6 +99,7 @@ export const CvatJobRequestForm = () => { gtPath, userGuide, accuracyTarget, + jobBounty, }: ReturnType) => { let bp = undefined; if (type === CvatJobType.IMAGE_BOXES_FROM_POINTS) { @@ -154,7 +155,8 @@ export const CvatJobRequestForm = () => { path: gtPath, }, userGuide, - accuracyTarget, + accuracyTarget: Number(accuracyTarget), + jobBounty: Number(jobBounty), }, }); goToNextStep(); @@ -830,6 +832,35 @@ export const CvatJobRequestForm = () => { /> + + + + setFieldValue('jobBounty', e.target.value) + } + onBlur={handleBlur} + error={touched.jobBounty && Boolean(errors.jobBounty)} + helperText={errors.jobBounty} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts b/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts index cd1fe2e2b0..6ccc3ba2b4 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts @@ -24,6 +24,7 @@ export const mapCvatFormValues = ( : [], userGuide: cvatRequest?.userGuide || '', accuracyTarget: cvatRequest?.accuracyTarget || 80, + jobBounty: cvatRequest?.jobBounty || 0, dataProvider: cvatRequest?.data?.dataset?.provider || StorageProviders.AWS, dataRegion: (cvatRequest?.data?.dataset?.region as AWSRegions | GCSRegions) || '', diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts b/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts index 85884c5fba..d904d72557 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts @@ -19,6 +19,10 @@ export const CvatJobRequestValidationSchema = Yup.object().shape({ .required('Accuracy target is required') .moreThan(0, 'Accuracy target must be greater than 0') .max(100, 'Accuracy target must be less than or equal to 100'), + jobBounty: Yup.number() + .typeError('Job bounty is required') + .required('Job bounty is required') + .moreThan(0, 'Job bounty must be greater than 0'), qualifications: Yup.array().of(Yup.object()), }); diff --git a/packages/apps/job-launcher/client/src/constants/cvat.ts b/packages/apps/job-launcher/client/src/constants/cvat.ts new file mode 100644 index 0000000000..deb5bd61e7 --- /dev/null +++ b/packages/apps/job-launcher/client/src/constants/cvat.ts @@ -0,0 +1,2 @@ +export const CVAT_JOB_SIZE = 10; +export const CVAT_VAL_SIZE = 2; diff --git a/packages/apps/job-launcher/client/src/services/job.ts b/packages/apps/job-launcher/client/src/services/job.ts index ceb719f492..a6d2c52184 100644 --- a/packages/apps/job-launcher/client/src/services/job.ts +++ b/packages/apps/job-launcher/client/src/services/job.ts @@ -1,16 +1,71 @@ import { ChainId } from '@human-protocol/sdk'; +import { CVAT_JOB_SIZE, CVAT_VAL_SIZE } from '../constants/cvat'; import { - CreateFortuneJobRequest, - CreateCvatJobRequest, + CreateJobRequest, FortuneRequest, CvatRequest, JobStatus, JobDetailsResponse, FortuneFinalResult, + FortuneManifest, + CvatManifest, + JobType, + StorageProviders, } from '../types'; import api from '../utils/api'; import { getFilenameFromContentDisposition } from '../utils/string'; +const buildFortuneManifest = (data: FortuneRequest): FortuneManifest => ({ + submissionsRequired: Number(data.fortunesRequested), + requesterTitle: data.title, + requesterDescription: data.description, + requestType: JobType.FORTUNE, + qualifications: data.qualifications, +}); + +const buildBucketUrl = ({ + provider, + region, + bucketName, + path, +}: CvatRequest['data']['dataset']) => { + if (provider === StorageProviders.AWS) { + return `https://${bucketName}.s3.${region}.amazonaws.com${ + path ? `/${path.replace(/\/$/, '')}` : '' + }`; + } + + return `https://${bucketName}.storage.googleapis.com${path ? `/${path}` : ''}`; +}; + +const buildCvatManifest = (data: CvatRequest): CvatManifest => ({ + data: { + dataUrl: buildBucketUrl(data.data.dataset), + ...(data.data.points && { + pointsUrl: buildBucketUrl(data.data.points), + }), + ...(data.data.boxes && { + boxesUrl: buildBucketUrl(data.data.boxes), + }), + }, + annotation: { + labels: data.labels, + description: data.description, + userGuide: data.userGuide, + type: data.type, + jobSize: CVAT_JOB_SIZE, + ...(data.qualifications?.length && { + qualifications: data.qualifications, + }), + }, + validation: { + minQuality: Number(data.accuracyTarget) / 100, + valSize: CVAT_VAL_SIZE, + gtUrl: buildBucketUrl(data.groundTruth), + }, + jobBounty: String(data.jobBounty), +}); + export const createFortuneJob = async ( chainId: number, data: FortuneRequest, @@ -18,17 +73,16 @@ export const createFortuneJob = async ( paymentAmount: number | string, escrowFundToken: string, ) => { - 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), }; - await api.post('/job/fortune', body); + await api.post('/job', body); }; export const createCvatJob = async ( @@ -38,21 +92,16 @@ export const createCvatJob = async ( paymentAmount: number | string, escrowFundToken: string, ) => { - const body: CreateCvatJobRequest = { + const body: CreateJobRequest = { chainId, - requesterDescription: data.description, + requestType: data.type, paymentCurrency, paymentAmount: Number(paymentAmount), escrowFundToken, - data: data.data, - labels: data.labels, - minQuality: Number(data.accuracyTarget) / 100, - groundTruth: data.groundTruth, - userGuide: data.userGuide, - type: data.type, qualifications: data.qualifications, + manifest: buildCvatManifest(data), }; - await api.post('/job/cvat', body); + await api.post('/job', body); }; export const getJobList = 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..0affc77908 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -39,30 +39,47 @@ export type FiatPaymentRequest = { paymentMethodId: string; }; -export type CreateFortuneJobRequest = { - chainId: number; +export type FortuneManifest = { submissionsRequired: number; requesterTitle: string; requesterDescription: string; - paymentCurrency: string; - paymentAmount: number; - escrowFundToken: string; + fundAmount?: number; + requestType: JobType.FORTUNE; qualifications?: string[]; }; -export type CreateCvatJobRequest = { +export type JobRequestType = JobType.FORTUNE | JobType.HCAPTCHA | CvatJobType; + +export type CreateJobRequest> = { chainId: number; - requesterDescription: string; - qualifications?: string[]; + requestType: JobRequestType; paymentCurrency: string; paymentAmount: number; escrowFundToken: string; - data: CvatData; - labels: Label[]; - minQuality: number; - groundTruth: CvatDataSource; - userGuide: string; - type: CvatJobType; + qualifications?: string[]; + manifest: TManifest; +}; + +export type CvatManifest = { + data: { + dataUrl: string; + pointsUrl?: string; + boxesUrl?: string; + }; + annotation: { + labels: Label[]; + description: string; + userGuide: string; + type: CvatJobType; + jobSize: number; + qualifications?: string[]; + }; + validation: { + minQuality: number; + valSize: number; + gtUrl: string; + }; + jobBounty: string; }; export enum CreateJobStep { @@ -215,6 +232,7 @@ export type CvatRequest = { groundTruth: CvatDataSource; userGuide: string; accuracyTarget: number; + jobBounty: number; }; export type JobRequest = { 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 f4820d64fd..5778fa133d 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,34 +1,30 @@ +import { faker } from '@faker-js/faker'; import { ChainId } from '@human-protocol/sdk'; -import { Test, TestingModule } from '@nestjs/testing'; -import { JobController } from './job.controller'; -import { JobService } from './job.service'; 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; @@ -57,8 +53,6 @@ describe('JobController', () => { provide: MutexManagerService, useValue: mockMutexManagerService, }, - Web3ConfigService, - ConfigService, ], }) .overrideGuard(JwtAuthGuard) @@ -87,10 +81,10 @@ describe('JobController', () => { const jobDto: JobQuickLaunchDto = { chainId: ChainId.POLYGON_AMOY, 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, }; @@ -123,10 +117,12 @@ describe('JobController', () => { const jobDto: JobQuickLaunchDto = { chainId: ChainId.POLYGON_AMOY, 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, }; @@ -145,9 +141,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( @@ -164,11 +164,15 @@ describe('JobController', () => { const jobDto: JobQuickLaunchDto = { chainId: ChainId.POLYGON_AMOY, 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( @@ -187,27 +191,31 @@ describe('JobController', () => { }); }); - describe('createFortuneJob', () => { - const jobFortuneDto: JobFortuneDto = { + describe('createJob', () => { + const jobManifestDto: JobManifestDto = { chainId: ChainId.POLYGON_AMOY, - requesterTitle: MOCK_REQUESTER_TITLE, - requesterDescription: MOCK_REQUESTER_DESCRIPTION, - submissionsRequired: 10, - paymentCurrency: PaymentCurrency.HMT, - paymentAmount: 500, - escrowFundToken: EscrowFundToken.HMT, + 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( @@ -217,8 +225,43 @@ 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 = { + chainId: ChainId.POLYGON_AMOY, + 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, ); }); @@ -228,7 +271,7 @@ describe('JobController', () => { ); await expect( - jobController.createFortuneJob(jobFortuneDto, mockRequest), + jobController.createJob(jobManifestDto, mockRequest), ).rejects.toThrow(UnauthorizedException); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -245,7 +288,7 @@ describe('JobController', () => { ); await expect( - jobController.createFortuneJob(jobFortuneDto, mockRequest), + jobController.createJob(jobManifestDto, mockRequest), ).rejects.toThrow(ConflictException); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -262,7 +305,7 @@ describe('JobController', () => { ); await expect( - jobController.createFortuneJob(jobFortuneDto, mockRequest), + jobController.createJob(jobManifestDto, mockRequest), ).rejects.toThrow(BadRequestException); expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith( @@ -273,109 +316,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 efd406286c..dc5822aa8d 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 @@ -14,6 +14,7 @@ import { Min, IsUrl, ValidateNested, + IsObject, } from 'class-validator'; import { IsEnumCaseInsensitive } from '../../common/decorators'; import { @@ -29,13 +30,21 @@ 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, name: 'chain_id' }) @IsEnumCaseInsensitive(ChainId) public chainId: ChainId; + @ApiProperty({ + description: 'Request type', + name: 'request_type', + enum: JobType, + }) + @IsEnumCaseInsensitive(JobType) + public requestType: JobRequestType; + @ApiPropertyOptional() @IsArray() @IsOptional() @@ -84,14 +93,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,24 +101,14 @@ export class JobQuickLaunchDto extends JobDto { @ApiProperty({ name: 'manifest_hash' }) @IsString() @IsOptional() - public manifestHash: string; + 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 { @@ -297,4 +288,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 3b15ccc1f3..74db1409f5 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 @@ -40,7 +40,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'; @@ -141,22 +144,18 @@ 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, + manifest: createMockFortuneManifest({ + fundAmount: undefined as unknown as number, + }), }); 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({ @@ -173,43 +172,45 @@ 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), ); + const expectedManifest = { + ...jobManifestDto.manifest, + fundAmount: jobManifestDto.paymentAmount, + }; expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, ); - expect(mockWeb3Service.findAvailableOracles).not.toHaveBeenCalled(); - expect(mockManifestService.createManifest).toHaveBeenCalledWith( - fortuneJobDto, + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( FortuneJobType.FORTUNE, - fortuneJobDto.paymentAmount, + expectedManifest, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( - fortuneJobDto.chainId, - mockManifest, + jobManifestDto.chainId, + expectedManifest, [ - 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, @@ -220,35 +221,31 @@ 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, + manifest: createMockFortuneManifest({ + fundAmount: undefined, + }), }); 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({ @@ -265,44 +262,52 @@ 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), ); + const expectedFundAmount = Number( + mul( + mul(jobManifestDto.paymentAmount, tokenToUsdRate), + usdToTokenRate, + ).toFixed(6), + ); + const expectedManifest = { + ...jobManifestDto.manifest, + fundAmount: expectedFundAmount, + }; expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, ); - expect(mockWeb3Service.findAvailableOracles).not.toHaveBeenCalled(); - expect(mockManifestService.createManifest).toHaveBeenCalledWith( - fortuneJobDto, + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( FortuneJobType.FORTUNE, - Number(fortuneJobDto.paymentAmount.toFixed(6)), + expectedManifest, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( - fortuneJobDto.chainId, - mockManifest, + jobManifestDto.chainId, + expectedManifest, [ - 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, @@ -313,43 +318,34 @@ describe('JobService', () => { usdToTokenRate, ).toFixed(fundTokenDecimals), ), - fundAmount: Number( - mul( - mul(fortuneJobDto.paymentAmount, tokenToUsdRate), - usdToTokenRate, - ).toFixed(6), - ), + fundAmount: expectedFundAmount, 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, recordingOracle: null, reputationOracle: null, + manifest: createMockFortuneManifest({ + fundAmount: undefined, + }), }); 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({ @@ -383,33 +379,36 @@ 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), ); + const expectedManifest = { + ...jobManifestDto.manifest, + fundAmount: jobManifestDto.paymentAmount, + }; expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, ); expect(mockWeb3Service.findAvailableOracles).toHaveBeenCalledWith( - fortuneJobDto.chainId, + jobManifestDto.chainId, FortuneJobType.FORTUNE, mockOracles.reputationOracle, ); - expect(mockManifestService.createManifest).toHaveBeenCalledWith( - fortuneJobDto, + expect(mockManifestService.validateManifest).toHaveBeenCalledWith( FortuneJobType.FORTUNE, - fortuneJobDto.paymentAmount, + expectedManifest, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( - fortuneJobDto.chainId, - mockManifest, + jobManifestDto.chainId, + expectedManifest, [ mockOracles.exchangeOracle, mockOracles.reputationOracle, @@ -419,11 +418,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, @@ -434,10 +433,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, @@ -447,12 +446,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)); }); @@ -462,13 +461,98 @@ 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(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(); @@ -504,8 +588,8 @@ describe('JobService', () => { expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( jobQuickLaunchDto.chainId, ); - expect(mockWeb3Service.findAvailableOracles).not.toHaveBeenCalled(); - expect(mockManifestService.createManifest).not.toHaveBeenCalled(); + expect(mockManifestService.downloadManifest).not.toHaveBeenCalled(); + 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 b10716443f..f8fe35afa5 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 @@ -75,7 +75,6 @@ import { GetJobsDto, JobDetailsDto, JobListDto, - JobQuickLaunchDto, } from './job.dto'; import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; @@ -218,7 +217,7 @@ export class JobService { let jobEntity = new JobEntity(); - if (dto instanceof JobQuickLaunchDto) { + if ('manifestUrl' in dto) { if (!dto.manifestHash) { const { filename } = parseUrl(dto.manifestUrl); @@ -232,21 +231,24 @@ export class JobService { } jobEntity.manifestUrl = dto.manifestUrl; - } else { - const manifestOrigin = await this.manifestService.createManifest( - dto, - requestType, - fundTokenAmount, - ); + } else if ('manifest' in dto) { + const manifest = dto.manifest; + if (requestType === FortuneJobType.FORTUNE) { + (manifest as FortuneManifestDto).fundAmount = fundTokenAmount; + } + + await this.manifestService.validateManifest(requestType, manifest); const { url, hash } = await this.manifestService.uploadManifest( chainId, - manifestOrigin, + 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 747e5c79ee..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,47 +42,107 @@ describe('ManifestService', () => { jest.clearAllMocks(); }); - describe('createManifest', () => { - it('should create a fortune manifest', async () => { - const dto: JobFortuneDto = { - chainId: faker.number.int({ min: 1, max: 100 }), - 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(), @@ -88,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); }); @@ -122,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..4283fcd3ca 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 { @@ -24,8 +24,6 @@ import { @Injectable() export class ManifestService { - public readonly bucket: string; - constructor( private readonly web3Service: Web3Service, private readonly pgpConfigService: PGPConfigService, @@ -33,22 +31,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 +57,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 46608d4029..6a286320e1 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';