From dad042200064475645738e6764a23840afb65a2d Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Tue, 22 Aug 2023 08:42:15 +0300 Subject: [PATCH 1/9] Added cancel job functionality --- .../server/src/common/enums/job.ts | 1 + .../1691485394906-InitialMigration.ts | 3 +- .../server/src/modules/job/job.controller.ts | 10 ++++- .../server/src/modules/job/job.dto.ts | 7 ++++ .../src/modules/job/job.service.spec.ts | 40 +++++++++++++++++++ .../server/src/modules/job/job.service.ts | 27 +++++++++++++ 6 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 6f237ab7e8..479d10ac49 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -4,6 +4,7 @@ export enum JobStatus { LAUNCHED = 'LAUNCHED', COMPLETED = 'COMPLETED', FAILED = 'FAILED', + CANCELED = 'CANCELED', } export enum JobRequestType { diff --git a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts index 03e9aaf6c9..77a72bf892 100644 --- a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts +++ b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts @@ -52,7 +52,8 @@ export class InitialMigration1691485394906 implements MigrationInterface { 'PAID', 'LAUNCHED', 'COMPLETED', - 'FAILED' + 'FAILED', + 'CANCELED' ) `); await queryRunner.query(` 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 41291373e6..042b2031d4 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 @@ -9,7 +9,7 @@ import { import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from 'src/common/guards'; import { RequestWithUser } from 'src/common/types'; -import { JobFortuneDto, JobImageLabelBinaryDto } from './job.dto'; +import { JobCancelDto, JobFortuneDto, JobImageLabelBinaryDto } from './job.dto'; import { JobService } from './job.service'; import { JobRequestType } from 'src/common/enums/job'; @@ -40,4 +40,12 @@ export class JobController { public async getResult(@Request() req: any): Promise { return this.jobService.getResult(req.user?.id); } + + @Post('/cancel') + public async cancelJob( + @Request() req: RequestWithUser, + @Body() data: JobCancelDto, + ): Promise { + return this.jobService.cancelJob(req.user.id, JobRequestType.FORTUNE, 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 bd7123ef1a..b7b04a5c47 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 @@ -115,6 +115,13 @@ export class JobImageLabelBinaryDto extends JobDto { public requesterAccuracyTarget: number; } +export class JobCancelDto { + @ApiProperty() + @IsNumber() + @IsPositive() + public id: number; +} + export class JobUpdateDto { @ApiPropertyOptional({ enum: JobStatus, 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 6c4fcc64c7..d1bb3771a8 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 @@ -38,6 +38,7 @@ import { FortuneManifestDto, ImageLabelBinaryFinalResultDto, ImageLabelBinaryManifestDto, + JobCancelDto, JobFortuneDto, JobImageLabelBinaryDto, } from './job.dto'; @@ -672,6 +673,45 @@ describe('JobService', () => { }); }); + describe('cancelJob', () => { + it('should cancel the job', async () => { + const jobId = 1; + const userId = 123; + const escrowAddress = '0xValidEscrowAddress'; + const mockJobEntity: Partial = { + id: jobId, + userId, + status: JobStatus.LAUNCHED, + escrowAddress, + chainId: ChainId.LOCALHOST, + save: jest.fn().mockResolvedValue(true), + }; + + const dto: JobCancelDto = { id: jobId }; + const mockEscrowClient = { cancel: jest.fn() }; + + jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity); + EscrowClient.build = jest.fn().mockResolvedValue(mockEscrowClient); + + const result = await jobService.cancelJob(userId, dto); + + expect(result).toEqual(true); + expect(jobRepository.findOne).toHaveBeenCalledWith({ id: jobId, userId }); + expect(web3Service.getSigner).toHaveBeenCalledWith(ChainId.LOCALHOST); + expect(EscrowClient.build).toHaveBeenCalledWith(signerMock); + expect(mockEscrowClient.cancel).toHaveBeenCalledWith(escrowAddress); + expect(mockJobEntity.save).toHaveBeenCalled(); + }); + + it('should throw not found exception if job not found', async () => { + jobRepository.findOne = jest.fn().mockResolvedValue(undefined); + + const dto: JobCancelDto = { id: 1 }; + + await expect(jobService.cancelJob(123, dto)).rejects.toThrow(NotFoundException); + }); + }); + describe('saveManifest with fortune request type', () => { const fortuneManifestParams = { requestType: JobRequestType.FORTUNE, 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 78a7bcdf76..991a75fe53 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 @@ -37,6 +37,7 @@ import { FortuneManifestDto, ImageLabelBinaryFinalResultDto, ImageLabelBinaryManifestDto, + JobCancelDto, JobFortuneDto, JobImageLabelBinaryDto, SaveManifestDto, @@ -241,6 +242,32 @@ export class JobService { return jobEntity; } + public async cancelJob( + userId: number, + dto: JobCancelDto, + ): Promise { + const { id } = dto; + + const jobEntity = await this.jobRepository.findOne({ id, userId }); + + if (!jobEntity) { + this.logger.log(ErrorJob.NotFound, JobService.name); + throw new NotFoundException(ErrorJob.NotFound); + } + + if (jobEntity.status === JobStatus.LAUNCHED && jobEntity.escrowAddress) { + const signer = this.web3Service.getSigner(jobEntity.chainId); + + const escrowClient = await EscrowClient.build(signer); + await escrowClient.cancel(jobEntity.escrowAddress) + } + + jobEntity.status = JobStatus.CANCELED; + await jobEntity.save(); + + return true; + } + public async saveManifest( dto: FortuneManifestDto | ImageLabelBinaryManifestDto ): Promise { From 30ec987b7aa20519ce14a8767e6ca96b37c4b874 Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Tue, 22 Aug 2023 08:44:57 +0300 Subject: [PATCH 2/9] Updated job controller --- .../job-launcher/server/src/modules/job/job.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 042b2031d4..f887578318 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 @@ -45,7 +45,7 @@ export class JobController { public async cancelJob( @Request() req: RequestWithUser, @Body() data: JobCancelDto, - ): Promise { - return this.jobService.cancelJob(req.user.id, JobRequestType.FORTUNE, data); + ): Promise { + return this.jobService.cancelJob(req.user.id, data); } } From a410e8944aa812102c9da8a7d16f0910baf52149 Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Tue, 22 Aug 2023 08:53:31 +0300 Subject: [PATCH 3/9] Updated type to patch and validation params approach --- .../job-launcher/server/src/modules/job/job.controller.ts | 8 +++++--- .../apps/job-launcher/server/src/modules/job/job.dto.ts | 4 ++-- .../job-launcher/server/src/modules/job/job.service.ts | 4 +--- 3 files changed, 8 insertions(+), 8 deletions(-) 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 f887578318..df418b3c00 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 @@ -2,6 +2,8 @@ import { Body, Controller, Get, + Param, + Patch, Post, Request, UseGuards, @@ -41,11 +43,11 @@ export class JobController { return this.jobService.getResult(req.user?.id); } - @Post('/cancel') + @Patch('/cancel/:id') public async cancelJob( @Request() req: RequestWithUser, - @Body() data: JobCancelDto, + @Param() params: JobCancelDto, ): Promise { - return this.jobService.cancelJob(req.user.id, data); + return this.jobService.cancelJob(req.user.id, params.id); } } 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 b7b04a5c47..08a44af2a5 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 { IsUrl, IsDate, IsOptional, + IsNumberString } from 'class-validator'; import { ChainId } from '@human-protocol/sdk'; import { JobRequestType, JobStatus } from '../../common/enums/job'; @@ -117,8 +118,7 @@ export class JobImageLabelBinaryDto extends JobDto { export class JobCancelDto { @ApiProperty() - @IsNumber() - @IsPositive() + @IsNumberString() public id: number; } 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 991a75fe53..05fee1c7af 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 @@ -244,10 +244,8 @@ export class JobService { public async cancelJob( userId: number, - dto: JobCancelDto, + id: number, ): Promise { - const { id } = dto; - const jobEntity = await this.jobRepository.findOne({ id, userId }); if (!jobEntity) { From bbe5260e4ba8111e511c83879fc9dbc60792a4c4 Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Tue, 22 Aug 2023 17:06:39 +0300 Subject: [PATCH 4/9] Updated job cancelation logic --- .../server/src/common/enums/job.ts | 2 +- .../1691485394906-InitialMigration.ts | 2 +- .../server/src/modules/job/job.controller.ts | 2 +- .../server/src/modules/job/job.cron.ts | 23 ++++++++++ .../src/modules/job/job.service.spec.ts | 46 ++++++++++++++----- .../server/src/modules/job/job.service.ts | 14 +++++- 6 files changed, 72 insertions(+), 17 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 479d10ac49..6e086f87cb 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -2,8 +2,8 @@ export enum JobStatus { PENDING = 'PENDING', PAID = 'PAID', LAUNCHED = 'LAUNCHED', - COMPLETED = 'COMPLETED', FAILED = 'FAILED', + TO_CANCEL = 'TO_CANCEL', CANCELED = 'CANCELED', } diff --git a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts index 77a72bf892..77e13e60ba 100644 --- a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts +++ b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts @@ -51,8 +51,8 @@ export class InitialMigration1691485394906 implements MigrationInterface { 'PENDING', 'PAID', 'LAUNCHED', - 'COMPLETED', 'FAILED', + 'TO_CANCEL', 'CANCELED' ) `); 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 df418b3c00..22ba4ea92e 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 @@ -48,6 +48,6 @@ export class JobController { @Request() req: RequestWithUser, @Param() params: JobCancelDto, ): Promise { - return this.jobService.cancelJob(req.user.id, params.id); + return this.jobService.requestToCancelJob(req.user.id, params.id); } } diff --git a/packages/apps/job-launcher/server/src/modules/job/job.cron.ts b/packages/apps/job-launcher/server/src/modules/job/job.cron.ts index 7ea15e30c3..084ff882e7 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.cron.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.cron.ts @@ -41,4 +41,27 @@ export class JobCron { return; } } + + @Cron(CronExpression.EVERY_10_SECONDS) + public async cancelJob() { + try { + const jobEntity = await this.jobEntityRepository.findOne({ + where: { + status: JobStatus.TO_CANCEL, + retriesCount: LessThanOrEqual(JOB_RETRIES_COUNT_THRESHOLD), + waitUntil: LessThanOrEqual(new Date()), + }, + order: { + waitUntil: SortDirection.ASC, + }, + }); + + if (!jobEntity) return; + + await this.jobService.cancelJob(jobEntity); + } catch (e) { + this.logger.error(e); + return; + } + } } 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 d1bb3771a8..967960e5be 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 @@ -673,6 +673,38 @@ describe('JobService', () => { }); }); + describe('requestToCancelJob', () => { + const jobId = 1; + const userId = 123; + + it('should to cancel the job', async () => { + const escrowAddress = '0xValidEscrowAddress'; + const mockJobEntity: Partial = { + id: jobId, + userId, + status: JobStatus.LAUNCHED, + escrowAddress, + chainId: ChainId.LOCALHOST, + save: jest.fn().mockResolvedValue(true), + }; + + jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity); + + const result = await jobService.requestToCancelJob(userId, jobId); + + expect(result).toEqual(true); + expect(jobRepository.findOne).toHaveBeenCalledWith({ id: jobId, userId }); + expect(mockJobEntity.save).toHaveBeenCalled(); + }); + + it('should throw not found exception if job not found', async () => { + jobRepository.findOne = jest.fn().mockResolvedValue(undefined); + + await expect(jobService.requestToCancelJob(userId, jobId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('cancelJob', () => { it('should cancel the job', async () => { const jobId = 1; @@ -681,35 +713,25 @@ describe('JobService', () => { const mockJobEntity: Partial = { id: jobId, userId, - status: JobStatus.LAUNCHED, + status: JobStatus.TO_CANCEL, escrowAddress, chainId: ChainId.LOCALHOST, save: jest.fn().mockResolvedValue(true), }; - const dto: JobCancelDto = { id: jobId }; const mockEscrowClient = { cancel: jest.fn() }; jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity); EscrowClient.build = jest.fn().mockResolvedValue(mockEscrowClient); - const result = await jobService.cancelJob(userId, dto); + const result = await jobService.cancelJob(mockJobEntity as JobEntity); expect(result).toEqual(true); - expect(jobRepository.findOne).toHaveBeenCalledWith({ id: jobId, userId }); expect(web3Service.getSigner).toHaveBeenCalledWith(ChainId.LOCALHOST); expect(EscrowClient.build).toHaveBeenCalledWith(signerMock); expect(mockEscrowClient.cancel).toHaveBeenCalledWith(escrowAddress); expect(mockJobEntity.save).toHaveBeenCalled(); }); - - it('should throw not found exception if job not found', async () => { - jobRepository.findOne = jest.fn().mockResolvedValue(undefined); - - const dto: JobCancelDto = { id: 1 }; - - await expect(jobService.cancelJob(123, dto)).rejects.toThrow(NotFoundException); - }); }); describe('saveManifest with fortune request type', () => { 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 05fee1c7af..a51195f6e3 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 @@ -242,7 +242,7 @@ export class JobService { return jobEntity; } - public async cancelJob( + public async requestToCancelJob( userId: number, id: number, ): Promise { @@ -252,8 +252,18 @@ export class JobService { this.logger.log(ErrorJob.NotFound, JobService.name); throw new NotFoundException(ErrorJob.NotFound); } + + jobEntity.status = JobStatus.TO_CANCEL; + jobEntity.retriesCount = 0; + await jobEntity.save(); - if (jobEntity.status === JobStatus.LAUNCHED && jobEntity.escrowAddress) { + return true; + } + + public async cancelJob( + jobEntity: JobEntity + ): Promise { + if (jobEntity.escrowAddress) { const signer = this.web3Service.getSigner(jobEntity.chainId); const escrowClient = await EscrowClient.build(signer); From 35625de7a1cd203b607385c2a73a8810685e097e Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Fri, 25 Aug 2023 09:16:22 +0300 Subject: [PATCH 5/9] Added job cancel functionality --- .../server/src/common/config/env.ts | 12 +- .../server/src/modules/job/job.dto.ts | 2 + .../src/modules/job/job.service.spec.ts | 167 +++++++++++++++--- .../server/src/modules/job/job.service.ts | 32 +++- 4 files changed, 183 insertions(+), 30 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/config/env.ts b/packages/apps/job-launcher/server/src/common/config/env.ts index 7dbcff2dcb..04752958fe 100644 --- a/packages/apps/job-launcher/server/src/common/config/env.ts +++ b/packages/apps/job-launcher/server/src/common/config/env.ts @@ -21,8 +21,10 @@ export const ConfigNames = { JOB_LAUNCHER_FEE: 'JOB_LAUNCHER_FEE', RECORDING_ORACLE_FEE: 'RECORDING_ORACLE_FEE', REPUTATION_ORACLE_FEE: 'REPUTATION_ORACLE_FEE', - EXCHANGE_ORACLE_ADDRESS: 'EXCHANGE_ORACLE_ADDRESS', - EXCHANGE_ORACLE_WEBHOOK_URL: 'EXCHANGE_ORACLE_WEBHOOK_URL', + FORTUNE_EXCHANGE_ORACLE_ADDRESS: 'FORTUNE_EXCHANGE_ORACLE_ADDRESS', + CVAT_EXCHANGE_ORACLE_ADDRESS: 'CVAT_EXCHANGE_ORACLE_ADDRESS', + FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL: 'FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL', + CVAT_EXCHANGE_ORACLE_WEBHOOK_URL: 'CVAT_EXCHANGE_ORACLE_WEBHOOK_URL', RECORDING_ORACLE_ADDRESS: 'RECORDING_ORACLE_ADDRESS', REPUTATION_ORACLE_ADDRESS: 'REPUTATION_ORACLE_ADDRESS', S3_ENDPOINT: 'S3_ENDPOINT', @@ -67,8 +69,10 @@ export const envValidator = Joi.object({ JOB_LAUNCHER_FEE: Joi.string().default(10), RECORDING_ORACLE_FEE: Joi.string().default(10), REPUTATION_ORACLE_FEE: Joi.string().default(10), - EXCHANGE_ORACLE_ADDRESS: Joi.string().required(), - EXCHANGE_ORACLE_WEBHOOK_URL: Joi.string().default('http://localhost:3005'), + FORTUNE_EXCHANGE_ORACLE_ADDRESS: Joi.string().required(), + CVAT_EXCHANGE_ORACLE_ADDRESS: Joi.string().required(), + FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL: Joi.string().default('http://localhost:3004'), + CVAT_EXCHANGE_ORACLE_WEBHOOK_URL: Joi.string().default('http://localhost:3005'), RECORDING_ORACLE_ADDRESS: Joi.string().required(), REPUTATION_ORACLE_ADDRESS: Joi.string().required(), // S3 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 08a44af2a5..30fa231888 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 @@ -12,6 +12,7 @@ import { } from 'class-validator'; import { ChainId } from '@human-protocol/sdk'; import { JobRequestType, JobStatus } from '../../common/enums/job'; +import { EventType } from 'src/common/enums/webhook'; export class JobCreateDto { public chainId: ChainId; @@ -146,6 +147,7 @@ export class SaveManifestDto { export class SendWebhookDto { public escrowAddress: string; public chainId: number; + public eventType: EventType; } export class FortuneManifestDto { 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 967960e5be..182885f327 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 @@ -33,12 +33,10 @@ import { import { PaymentService } from '../payment/payment.service'; import { Web3Service } from '../web3/web3.service'; import { - CreateJobDto, FortuneFinalResultDto, FortuneManifestDto, ImageLabelBinaryFinalResultDto, ImageLabelBinaryManifestDto, - JobCancelDto, JobFortuneDto, JobImageLabelBinaryDto, } from './job.dto'; @@ -49,6 +47,7 @@ import { JobService } from './job.service'; import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { RoutingProtocolService } from './routing-protocol.service'; import { PaymentRepository } from '../payment/payment.repository'; +import { EventType } from '../../common/enums/webhook'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -100,7 +99,9 @@ describe('JobService', () => { return MOCK_RECORDING_ORACLE_ADDRESS; case 'REPUTATION_ORACLE_ADDRESS': return MOCK_REPUTATION_ORACLE_ADDRESS; - case 'EXCHANGE_ORACLE_WEBHOOK_URL': + case 'FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL': + return MOCK_EXCHANGE_ORACLE_WEBHOOK_URL; + case 'CVAT_EXCHANGE_ORACLE_WEBHOOK_URL': return MOCK_EXCHANGE_ORACLE_WEBHOOK_URL; case 'HOST': return '127.0.0.1'; @@ -704,33 +705,153 @@ describe('JobService', () => { }); }); - describe('cancelJob', () => { - it('should cancel the job', async () => { - const jobId = 1; - const userId = 123; - const escrowAddress = '0xValidEscrowAddress'; - const mockJobEntity: Partial = { - id: jobId, - userId, + let getManifestMock: any, + saveMock: any, + buildMock: any, + sendWebhookMock: any, + jobEntityMock: Partial + + const escrowClientMock: any = { + cancel: jest.fn().mockResolvedValue(undefined) + }; + + beforeEach(() => { + jobEntityMock = { + escrowAddress: MOCK_ADDRESS, + chainId: 1, + manifestUrl: MOCK_FILE_URL, status: JobStatus.TO_CANCEL, - escrowAddress, - chainId: ChainId.LOCALHOST, - save: jest.fn().mockResolvedValue(true), + save: jest.fn(), }; - const mockEscrowClient = { cancel: jest.fn() }; + getManifestMock = jest.spyOn(jobService, 'getManifest'); + saveMock = jest.spyOn(jobEntityMock, 'save'); + buildMock = jest.spyOn(EscrowClient, 'build'); + sendWebhookMock = jest.spyOn(jobService, 'sendWebhook'); + }); - jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity); - EscrowClient.build = jest.fn().mockResolvedValue(mockEscrowClient); + afterEach(() => { + jest.clearAllMocks(); + }); - const result = await jobService.cancelJob(mockJobEntity as JobEntity); + it('cancels a job successfully with Fortune job type, without escrow address and pending status', async () => { + const manifest: FortuneManifestDto = { + submissionsRequired: 10, + requesterTitle: MOCK_REQUESTER_TITLE, + requesterDescription: MOCK_REQUESTER_DESCRIPTION, + fundAmount: '10', + requestType: JobRequestType.FORTUNE + }; + - expect(result).toEqual(true); - expect(web3Service.getSigner).toHaveBeenCalledWith(ChainId.LOCALHOST); - expect(EscrowClient.build).toHaveBeenCalledWith(signerMock); - expect(mockEscrowClient.cancel).toHaveBeenCalledWith(escrowAddress); - expect(mockJobEntity.save).toHaveBeenCalled(); + jobEntityMock.status = JobStatus.PENDING; + jobEntityMock.escrowAddress = undefined; + saveMock.mockResolvedValue(jobEntityMock as JobEntity); + getManifestMock.mockResolvedValue(manifest); + sendWebhookMock.mockResolvedValue(true); + + const result = await jobService.cancelJob(jobEntityMock as any); + + expect(result).toBe(true); + expect(escrowClientMock.cancel).toBeCalledTimes(0); + expect(saveMock).toHaveBeenCalledWith(); + expect(sendWebhookMock).toHaveBeenCalledWith( + expect.any(String), + { + escrowAddress: jobEntityMock.escrowAddress, + chainId: jobEntityMock.chainId, + eventType: EventType.ESCROW_CANCELED + } + ); + }); + + it('cancels a job successfully with Fortune job type and launched escrow', async () => { + const manifest: FortuneManifestDto = { + submissionsRequired: 10, + requesterTitle: MOCK_REQUESTER_TITLE, + requesterDescription: MOCK_REQUESTER_DESCRIPTION, + fundAmount: '10', + requestType: JobRequestType.FORTUNE + }; + + saveMock.mockResolvedValue(jobEntityMock as JobEntity); + getManifestMock.mockResolvedValue(manifest); + buildMock.mockResolvedValue(escrowClientMock as any); + sendWebhookMock.mockResolvedValue(true); + + const result = await jobService.cancelJob(jobEntityMock as any); + + expect(result).toBe(true); + expect(escrowClientMock.cancel).toHaveBeenCalledWith(jobEntityMock.escrowAddress); + expect(saveMock).toHaveBeenCalledWith(); + expect(sendWebhookMock).toHaveBeenCalledWith( + expect.any(String), + { + escrowAddress: jobEntityMock.escrowAddress, + chainId: jobEntityMock.chainId, + eventType: EventType.ESCROW_CANCELED + } + ); + }); + + it('cancels a job successfully with image binary lavel job type, without escrow address and pending status', async () => { + const manifest: FortuneManifestDto = { + submissionsRequired: 10, + requesterTitle: MOCK_REQUESTER_TITLE, + requesterDescription: MOCK_REQUESTER_DESCRIPTION, + fundAmount: '10', + requestType: JobRequestType.IMAGE_LABEL_BINARY + }; + + jobEntityMock.status = JobStatus.PENDING; + jobEntityMock.escrowAddress = undefined; + saveMock.mockResolvedValue(jobEntityMock as JobEntity); + getManifestMock.mockResolvedValue(manifest); + sendWebhookMock.mockResolvedValue(true); + + const result = await jobService.cancelJob(jobEntityMock as any); + + expect(result).toBe(true); + expect(escrowClientMock.cancel).toBeCalledTimes(0); + expect(saveMock).toHaveBeenCalledWith(); + expect(sendWebhookMock).toHaveBeenCalledWith( + expect.any(String), + { + escrowAddress: jobEntityMock.escrowAddress, + chainId: jobEntityMock.chainId, + eventType: EventType.ESCROW_CANCELED + } + ); + }); + + it('cancels a job successfully with image binary lavel job type and launched escrow', async () => { + const manifest: FortuneManifestDto = { + submissionsRequired: 10, + requesterTitle: MOCK_REQUESTER_TITLE, + requesterDescription: MOCK_REQUESTER_DESCRIPTION, + fundAmount: '10', + requestType: JobRequestType.IMAGE_LABEL_BINARY + }; + + saveMock.mockResolvedValue(jobEntityMock as JobEntity); + getManifestMock.mockResolvedValue(manifest); + buildMock.mockResolvedValue(escrowClientMock as any); + sendWebhookMock.mockResolvedValue(true); + + const result = await jobService.cancelJob(jobEntityMock as any); + + expect(result).toBe(true); + expect(escrowClientMock.cancel).toHaveBeenCalledWith(jobEntityMock.escrowAddress); + expect(saveMock).toHaveBeenCalledWith(); + expect(sendWebhookMock).toHaveBeenCalledWith( + expect.any(String), + { + escrowAddress: jobEntityMock.escrowAddress, + chainId: jobEntityMock.chainId, + eventType: EventType.ESCROW_CANCELED + } + ); }); }); 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 a51195f6e3..25d183e640 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 @@ -32,12 +32,10 @@ import { UploadFile, } from '@human-protocol/sdk'; import { - CreateJobDto, FortuneFinalResultDto, FortuneManifestDto, ImageLabelBinaryFinalResultDto, ImageLabelBinaryManifestDto, - JobCancelDto, JobFortuneDto, JobImageLabelBinaryDto, SaveManifestDto, @@ -60,6 +58,7 @@ import { import { RoutingProtocolService } from './routing-protocol.service'; import { PaymentRepository } from '../payment/payment.repository'; import { getRate } from '../../common/utils'; +import { EventType } from '../../common/enums/webhook'; @Injectable() export class JobService { @@ -230,11 +229,12 @@ export class JobService { if (manifest.requestType === JobRequestType.IMAGE_LABEL_BINARY) { await this.sendWebhook( this.configService.get( - ConfigNames.EXCHANGE_ORACLE_WEBHOOK_URL, + ConfigNames.CVAT_EXCHANGE_ORACLE_WEBHOOK_URL, )!, { escrowAddress: jobEntity.escrowAddress, chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CREATED }, ); } @@ -272,6 +272,32 @@ export class JobService { jobEntity.status = JobStatus.CANCELED; await jobEntity.save(); + + const manifest = await this.getManifest(jobEntity.manifestUrl); + + if (manifest.requestType === JobRequestType.FORTUNE) { + await this.sendWebhook( + this.configService.get( + ConfigNames.FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL, + )!, + { + escrowAddress: jobEntity.escrowAddress, + chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CANCELED + }, + ); + } else if (manifest.requestType === JobRequestType.IMAGE_LABEL_BINARY) { + await this.sendWebhook( + this.configService.get( + ConfigNames.CVAT_EXCHANGE_ORACLE_WEBHOOK_URL, + )!, + { + escrowAddress: jobEntity.escrowAddress, + chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CANCELED + }, + ); + } return true; } From a7604d4a96c0778a1691ff95b8f005ed401646fa Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Fri, 25 Aug 2023 10:35:46 +0300 Subject: [PATCH 6/9] Added enums webhook --- packages/apps/job-launcher/server/src/common/enums/webhook.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/apps/job-launcher/server/src/common/enums/webhook.ts diff --git a/packages/apps/job-launcher/server/src/common/enums/webhook.ts b/packages/apps/job-launcher/server/src/common/enums/webhook.ts new file mode 100644 index 0000000000..34a24d4ffa --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/enums/webhook.ts @@ -0,0 +1,4 @@ +export enum EventType { + ESCROW_CREATED = 'escrow_created', + ESCROW_CANCELED = 'escrow_canceled', +} From 0fe7455585d0db193521c3474c354b301dfa57b1 Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Fri, 25 Aug 2023 12:04:53 +0300 Subject: [PATCH 7/9] Resolved comments --- .../server/src/common/constants/errors.ts | 2 + .../server/src/modules/job/job.dto.ts | 2 +- .../src/modules/job/job.service.spec.ts | 120 ++++++++---------- .../server/src/modules/job/job.service.ts | 70 ++++++---- 4 files changed, 100 insertions(+), 94 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 86005271b8..77e6d4e023 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -21,6 +21,8 @@ export enum ErrorEscrow { NotFound = 'Escrow not found', NotCreated = 'Escrow has not been created', NotLaunched = 'Escrow has not been launched', + InvalidStatusCancellation = 'Escrow has an invalid status for cancellation', + InvalidBalanceCancellation = 'Escrow has an invalid balance for cancellation' } /** 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 3b1ee1bd14..ef230ef1ee 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 @@ -13,7 +13,7 @@ import { } from 'class-validator'; import { ChainId } from '@human-protocol/sdk'; import { JobRequestType, JobStatus } from '../../common/enums/job'; -import { EventType } from 'src/common/enums/webhook'; +import { EventType } from '../../common/enums/webhook'; export class JobCreateDto { public chainId: ChainId; 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 b73320d87b..c08ceeb408 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -1,12 +1,13 @@ import { createMock } from '@golevelup/ts-jest'; -import { ChainId, EscrowClient, StorageClient } from '@human-protocol/sdk'; +import { ChainId, EscrowClient, EscrowStatus, StorageClient } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; import { BadGatewayException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; -import { ethers } from 'ethers'; +import { BigNumber, ethers } from 'ethers'; import { ErrorBucket, + ErrorEscrow, ErrorJob, ErrorWeb3, } from '../../common/constants/errors'; @@ -674,8 +675,8 @@ describe('JobService', () => { const jobId = 1; const userId = 123; - it('should to cancel the job', async () => { - const escrowAddress = '0xValidEscrowAddress'; + it('should cancel the job', async () => { + const escrowAddress = MOCK_ADDRESS; const mockJobEntity: Partial = { id: jobId, userId, @@ -702,17 +703,20 @@ describe('JobService', () => { }); describe('cancelJob', () => { - let getManifestMock: any, + let escrowClientMock: any, + getManifestMock: any, saveMock: any, buildMock: any, sendWebhookMock: any, jobEntityMock: Partial - const escrowClientMock: any = { - cancel: jest.fn().mockResolvedValue(undefined) - }; - beforeEach(() => { + escrowClientMock = { + cancel: jest.fn().mockResolvedValue(undefined), + getStatus: jest.fn().mockResolvedValue(EscrowStatus.Launched), + getBalance: jest.fn().mockResolvedValue(BigNumber.from(10)) + }; + jobEntityMock = { escrowAddress: MOCK_ADDRESS, chainId: 1, @@ -731,85 +735,60 @@ describe('JobService', () => { jest.clearAllMocks(); }); - it('cancels a job successfully with Fortune job type, without escrow address and pending status', async () => { - const manifest: FortuneManifestDto = { - submissionsRequired: 10, - requesterTitle: MOCK_REQUESTER_TITLE, - requesterDescription: MOCK_REQUESTER_DESCRIPTION, - fundAmount: 10, - requestType: JobRequestType.FORTUNE - }; - - - jobEntityMock.status = JobStatus.PENDING; + it('cancels a job successfully', async () => { jobEntityMock.escrowAddress = undefined; saveMock.mockResolvedValue(jobEntityMock as JobEntity); - getManifestMock.mockResolvedValue(manifest); - sendWebhookMock.mockResolvedValue(true); const result = await jobService.cancelJob(jobEntityMock as any); expect(result).toBe(true); expect(escrowClientMock.cancel).toBeCalledTimes(0); expect(saveMock).toHaveBeenCalledWith(); - expect(sendWebhookMock).toHaveBeenCalledWith( - expect.any(String), - { - escrowAddress: jobEntityMock.escrowAddress, - chainId: jobEntityMock.chainId, - eventType: EventType.ESCROW_CANCELED - } + }); + + it('should throw an error if the escrow has invalid status', async () => { + escrowClientMock.getStatus = jest.fn().mockResolvedValue(EscrowStatus.Complete); + saveMock.mockResolvedValue(jobEntityMock as JobEntity); + buildMock.mockResolvedValue(escrowClientMock as any); + + await expect( + jobService.cancelJob(jobEntityMock as any) + ).rejects.toThrowError( + new BadGatewayException(ErrorEscrow.InvalidStatusCancellation), ); }); - it('cancels a job successfully with Fortune job type and launched escrow', async () => { - const manifest: FortuneManifestDto = { - submissionsRequired: 10, - requesterTitle: MOCK_REQUESTER_TITLE, - requesterDescription: MOCK_REQUESTER_DESCRIPTION, - fundAmount: 10, - requestType: JobRequestType.FORTUNE - }; - + it('should throw an error if the escrow has invalid balance', async () => { + escrowClientMock.getStatus = jest.fn().mockResolvedValue(EscrowStatus.Launched); + escrowClientMock.getBalance = jest.fn().mockResolvedValue(BigNumber.from(0)); saveMock.mockResolvedValue(jobEntityMock as JobEntity); - getManifestMock.mockResolvedValue(manifest); - buildMock.mockResolvedValue(escrowClientMock as any); - sendWebhookMock.mockResolvedValue(true); - - const result = await jobService.cancelJob(jobEntityMock as any); + buildMock.mockResolvedValue(escrowClientMock as any); - expect(result).toBe(true); - expect(escrowClientMock.cancel).toHaveBeenCalledWith(jobEntityMock.escrowAddress); - expect(saveMock).toHaveBeenCalledWith(); - expect(sendWebhookMock).toHaveBeenCalledWith( - expect.any(String), - { - escrowAddress: jobEntityMock.escrowAddress, - chainId: jobEntityMock.chainId, - eventType: EventType.ESCROW_CANCELED - } + await expect( + jobService.cancelJob(jobEntityMock as any) + ).rejects.toThrowError( + new BadGatewayException(ErrorEscrow.InvalidBalanceCancellation), ); }); - it('cancels a job successfully with image binary lavel job type, without escrow address and pending status', async () => { + it('cancels a job successfully with Fortune job type and send webhook', async () => { const manifest: FortuneManifestDto = { submissionsRequired: 10, requesterTitle: MOCK_REQUESTER_TITLE, requesterDescription: MOCK_REQUESTER_DESCRIPTION, fundAmount: 10, - requestType: JobRequestType.IMAGE_LABEL_BINARY + requestType: JobRequestType.FORTUNE }; - jobEntityMock.status = JobStatus.PENDING; - jobEntityMock.escrowAddress = undefined; saveMock.mockResolvedValue(jobEntityMock as JobEntity); getManifestMock.mockResolvedValue(manifest); + buildMock.mockResolvedValue(escrowClientMock as any); sendWebhookMock.mockResolvedValue(true); const result = await jobService.cancelJob(jobEntityMock as any); expect(result).toBe(true); - expect(escrowClientMock.cancel).toBeCalledTimes(0); + expect(escrowClientMock.cancel).toHaveBeenCalledWith(jobEntityMock.escrowAddress); expect(saveMock).toHaveBeenCalledWith(); expect(sendWebhookMock).toHaveBeenCalledWith( expect.any(String), @@ -821,13 +800,24 @@ describe('JobService', () => { ); }); - it('cancels a job successfully with image binary lavel job type and launched escrow', async () => { - const manifest: FortuneManifestDto = { - submissionsRequired: 10, - requesterTitle: MOCK_REQUESTER_TITLE, - requesterDescription: MOCK_REQUESTER_DESCRIPTION, - fundAmount: 10, - requestType: JobRequestType.IMAGE_LABEL_BINARY + it('cancels a job successfully with image binary lavel job type and send webhook', async () => { + const manifest: CvatManifestDto = { + data: { + data_url: MOCK_FILE_URL, + }, + annotation: { + labels: [{ name: 'label1' }], + description: MOCK_REQUESTER_DESCRIPTION, + type: JobRequestType.IMAGE_LABEL_BINARY, + job_size: 10, + max_time: 300, + }, + validation: { + min_quality: 1, + val_size: 2, + gt_url: '', + }, + job_bounty: '1', }; saveMock.mockResolvedValue(jobEntityMock as JobEntity); 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 35f4effd86..dc343bbb0b 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 @@ -6,6 +6,7 @@ import { import { ChainId, EscrowClient, + EscrowStatus, NETWORKS, StorageClient, StorageCredentials, @@ -296,40 +297,53 @@ export class JobService { public async cancelJob( jobEntity: JobEntity ): Promise { - if (jobEntity.escrowAddress) { + const { escrowAddress } = jobEntity + if (escrowAddress) { const signer = this.web3Service.getSigner(jobEntity.chainId); - const escrowClient = await EscrowClient.build(signer); - await escrowClient.cancel(jobEntity.escrowAddress) + + const escrowStatus = await escrowClient.getStatus(escrowAddress) + if (escrowStatus === EscrowStatus.Complete || escrowStatus === EscrowStatus.Paid) { + this.logger.log(ErrorEscrow.InvalidStatusCancellation, JobService.name); + throw new BadRequestException(ErrorEscrow.InvalidStatusCancellation); + } + + const balance = await escrowClient.getBalance(escrowAddress); + if (balance.eq(0)) { + this.logger.log(ErrorEscrow.InvalidBalanceCancellation, JobService.name); + throw new BadRequestException(ErrorEscrow.InvalidBalanceCancellation); + } + + await escrowClient.cancel(escrowAddress) + + const manifest = await this.getManifest(jobEntity.manifestUrl); + if ((manifest as FortuneManifestDto).requestType === JobRequestType.FORTUNE) { + await this.sendWebhook( + this.configService.get( + ConfigNames.FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL, + )!, + { + escrowAddress, + chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CANCELED + }, + ); + } else { + await this.sendWebhook( + this.configService.get( + ConfigNames.CVAT_EXCHANGE_ORACLE_WEBHOOK_URL, + )!, + { + escrowAddress, + chainId: jobEntity.chainId, + eventType: EventType.ESCROW_CANCELED + }, + ); + } } jobEntity.status = JobStatus.CANCELED; await jobEntity.save(); - - const manifest = await this.getManifest(jobEntity.manifestUrl); - if ((manifest as FortuneManifestDto).requestType === JobRequestType.FORTUNE) { - await this.sendWebhook( - this.configService.get( - ConfigNames.FORTUNE_EXCHANGE_ORACLE_WEBHOOK_URL, - )!, - { - escrowAddress: jobEntity.escrowAddress, - chainId: jobEntity.chainId, - eventType: EventType.ESCROW_CANCELED - }, - ); - } else { - await this.sendWebhook( - this.configService.get( - ConfigNames.CVAT_EXCHANGE_ORACLE_WEBHOOK_URL, - )!, - { - escrowAddress: jobEntity.escrowAddress, - chainId: jobEntity.chainId, - eventType: EventType.ESCROW_CANCELED - }, - ); - } return true; } From 210d15b54fb3e3ad151c870981f04b559494c31f Mon Sep 17 00:00:00 2001 From: eugenvoronov <104138627+eugenvoronov@users.noreply.github.com> Date: Mon, 28 Aug 2023 12:35:04 +0300 Subject: [PATCH 8/9] [Job Launcher] Added job id relation to the payment (#835) * Updated payment entity and migration * Updated job service * Updated unit tests --- .../1691485394906-InitialMigration.ts | 9 +++++ .../server/src/modules/auth/auth.entity.ts | 1 - .../server/src/modules/job/job.entity.ts | 6 +++- .../src/modules/job/job.service.spec.ts | 34 +++++++++++++++++++ .../server/src/modules/job/job.service.ts | 1 + .../server/src/modules/payment/payment.dto.ts | 1 + .../src/modules/payment/payment.entity.ts | 10 +++++- 7 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts index 31f0818978..dec52b8e8a 100644 --- a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts +++ b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts @@ -29,6 +29,8 @@ export class InitialMigration1691485394906 implements MigrationInterface { "source" "hmt"."payments_source_enum" NOT NULL, "status" "hmt"."payments_status_enum" NOT NULL, "user_id" integer NOT NULL, + "job_id" integer, + CONSTRAINT "REL_f83af8ea8055b85bde0e095e40" UNIQUE ("job_id"), CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id") ) `); @@ -132,6 +134,10 @@ export class InitialMigration1691485394906 implements MigrationInterface { ALTER TABLE "hmt"."jobs" ADD CONSTRAINT "FK_9027c8f0ba75fbc1ac46647d043" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments" + ADD CONSTRAINT "FK_f83af8ea8055b85bde0e095e400" FOREIGN KEY ("job_id") REFERENCES "hmt"."jobs"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); await queryRunner.query(` ALTER TABLE "hmt"."tokens" ADD CONSTRAINT "FK_8769073e38c365f315426554ca5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION @@ -152,6 +158,9 @@ export class InitialMigration1691485394906 implements MigrationInterface { await queryRunner.query(` ALTER TABLE "hmt"."jobs" DROP CONSTRAINT "FK_9027c8f0ba75fbc1ac46647d043" `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments" DROP CONSTRAINT "FK_f83af8ea8055b85bde0e095e400" + `); await queryRunner.query(` ALTER TABLE "hmt"."payments" DROP CONSTRAINT "FK_427785468fb7d2733f59e7d7d39" `); diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts index 1062a7b014..6a5a108799 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts @@ -1,7 +1,6 @@ import { Column, Entity, - Generated, JoinColumn, OneToOne, PrimaryGeneratedColumn, diff --git a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts index a95bd35b47..c116adc6d8 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts @@ -1,10 +1,11 @@ -import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, ManyToOne, OneToOne } from 'typeorm'; import { NS } from '../../common/constants'; import { IJob } from '../../common/interfaces'; import { JobStatus } from '../../common/enums/job'; import { BaseEntity } from '../../database/base.entity'; import { UserEntity } from '../user/user.entity'; +import { PaymentEntity } from '../payment/payment.entity'; @Entity({ schema: NS, name: 'jobs' }) @Index(['chainId', 'escrowAddress'], { unique: true }) @@ -39,6 +40,9 @@ export class JobEntity extends BaseEntity implements IJob { @Column({ type: 'int' }) public userId: number; + @OneToOne(() => PaymentEntity, (payment) => payment.job) + public payment: PaymentEntity; + @Column({ type: 'int', default: 0 }) public retriesCount: number; 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 c08ceeb408..b57dfcd452 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 @@ -161,6 +161,7 @@ describe('JobService', () => { describe('createJob', () => { const userId = 1; + const jobId = 123; const fortuneJobDto: JobFortuneDto = { chainId: MOCK_CHAIN_ID, submissionsRequired: MOCK_SUBMISSION_REQUIRED, @@ -187,11 +188,27 @@ describe('JobService', () => { const userBalance = 25; getUserBalanceMock.mockResolvedValue(userBalance); + const mockJobEntity: Partial = { + id: jobId, + userId: userId, + chainId: ChainId.LOCALHOST, + manifestUrl: MOCK_FILE_URL, + manifestHash: MOCK_FILE_HASH, + escrowAddress: MOCK_ADDRESS, + fee, + fundAmount, + status: JobStatus.PENDING, + save: jest.fn().mockResolvedValue(true), + }; + + jobRepository.create = jest.fn().mockResolvedValue(mockJobEntity); + await jobService.createJob(userId, JobRequestType.FORTUNE, fortuneJobDto); expect(paymentService.getUserBalance).toHaveBeenCalledWith(userId); expect(paymentRepository.create).toHaveBeenCalledWith({ userId, + jobId, source: PaymentSource.BALANCE, type: PaymentType.WITHDRAWAL, currency: TokenId.HMT, @@ -278,6 +295,7 @@ describe('JobService', () => { describe('createJob with image label binary type', () => { const userId = 1; + const jobId = 123; const imageLabelBinaryJobDto: JobCvatDto = { chainId: MOCK_CHAIN_ID, @@ -309,6 +327,21 @@ describe('JobService', () => { const userBalance = 25; getUserBalanceMock.mockResolvedValue(userBalance); + const mockJobEntity: Partial = { + id: jobId, + userId: userId, + chainId: ChainId.LOCALHOST, + manifestUrl: MOCK_FILE_URL, + manifestHash: MOCK_FILE_HASH, + escrowAddress: MOCK_ADDRESS, + fee, + fundAmount, + status: JobStatus.PENDING, + save: jest.fn().mockResolvedValue(true), + }; + + jobRepository.create = jest.fn().mockResolvedValue(mockJobEntity); + await jobService.createJob( userId, JobRequestType.IMAGE_LABEL_BINARY, @@ -318,6 +351,7 @@ describe('JobService', () => { expect(paymentService.getUserBalance).toHaveBeenCalledWith(userId); expect(paymentRepository.create).toHaveBeenCalledWith({ userId, + jobId, source: PaymentSource.BALANCE, type: PaymentType.WITHDRAWAL, currency: TokenId.HMT, 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 dc343bbb0b..8ce0d31b63 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 @@ -187,6 +187,7 @@ export class JobService { try { await this.paymentRepository.create({ userId, + jobId: jobEntity.id, source: PaymentSource.BALANCE, type: PaymentType.WITHDRAWAL, amount: -tokenTotalAmount, diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index 698c653111..7906d05a6a 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -49,6 +49,7 @@ export class PaymentCreateDto { public type?: PaymentType; public chainId?: number; public status?: PaymentStatus; + public jobId?: number; } export class GetRateDto { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts index f38323ee7b..4069295337 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts @@ -1,8 +1,9 @@ -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; import { NS } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; import { PaymentSource, PaymentStatus, PaymentType } from '../../common/enums/payment'; import { UserEntity } from '../user/user.entity'; +import { JobEntity } from '../job/job.entity'; @Entity({ schema: NS, name: 'payments' }) @Index(['chainId', 'transaction'], { @@ -53,4 +54,11 @@ export class PaymentEntity extends BaseEntity { @Column({ type: 'int' }) public userId: number; + + @JoinColumn() + @OneToOne(() => JobEntity, (job) => job.payment) + public job: JobEntity; + + @Column({ type: 'int', nullable: true }) + public jobId: number; } From bdaf45cc39b6dff23c61c77fc77afd3bdd17c148 Mon Sep 17 00:00:00 2001 From: Eugene Voronov Date: Mon, 28 Aug 2023 15:13:06 +0300 Subject: [PATCH 9/9] Updated unit tests --- .../src/modules/job/job.service.spec.ts | 36 +++++++++++++------ .../server/src/modules/job/job.service.ts | 6 ++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index b57dfcd452..45364479a8 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 @@ -57,6 +57,7 @@ import { RoutingProtocolService } from './routing-protocol.service'; import { PaymentRepository } from '../payment/payment.repository'; import { div, mul } from '../../common/utils/decimal'; import { EventType } from '../../common/enums/webhook'; +import { PaymentEntity } from '../payment/payment.entity'; const rate = 1.5; jest.mock('@human-protocol/sdk', () => ({ @@ -739,10 +740,12 @@ describe('JobService', () => { describe('cancelJob', () => { let escrowClientMock: any, getManifestMock: any, - saveMock: any, + jobSaveMock: any, + findOnePaymentMock: any, buildMock: any, sendWebhookMock: any, - jobEntityMock: Partial + jobEntityMock: Partial, + paymentEntityMock: Partial beforeEach(() => { escrowClientMock = { @@ -752,6 +755,7 @@ describe('JobService', () => { }; jobEntityMock = { + id: 1, escrowAddress: MOCK_ADDRESS, chainId: 1, manifestUrl: MOCK_FILE_URL, @@ -759,10 +763,19 @@ describe('JobService', () => { save: jest.fn(), }; + paymentEntityMock = { + chainId: 1, + jobId: jobEntityMock.id, + status: PaymentStatus.SUCCEEDED, + save: jest.fn(), + }; + getManifestMock = jest.spyOn(jobService, 'getManifest'); - saveMock = jest.spyOn(jobEntityMock, 'save'); + jobSaveMock = jest.spyOn(jobEntityMock, 'save'); + findOnePaymentMock = jest.spyOn(paymentRepository, 'findOne'); buildMock = jest.spyOn(EscrowClient, 'build'); sendWebhookMock = jest.spyOn(jobService, 'sendWebhook'); + findOnePaymentMock.mockResolvedValueOnce(paymentEntityMock as PaymentEntity); }); afterEach(() => { @@ -771,18 +784,19 @@ describe('JobService', () => { it('cancels a job successfully', async () => { jobEntityMock.escrowAddress = undefined; - saveMock.mockResolvedValue(jobEntityMock as JobEntity); + jobSaveMock.mockResolvedValue(jobEntityMock as JobEntity); const result = await jobService.cancelJob(jobEntityMock as any); expect(result).toBe(true); expect(escrowClientMock.cancel).toBeCalledTimes(0); - expect(saveMock).toHaveBeenCalledWith(); + expect(paymentEntityMock.status).toBe(PaymentStatus.FAILED); + expect(jobSaveMock).toHaveBeenCalledWith(); }); it('should throw an error if the escrow has invalid status', async () => { escrowClientMock.getStatus = jest.fn().mockResolvedValue(EscrowStatus.Complete); - saveMock.mockResolvedValue(jobEntityMock as JobEntity); + jobSaveMock.mockResolvedValue(jobEntityMock as JobEntity); buildMock.mockResolvedValue(escrowClientMock as any); await expect( @@ -795,7 +809,7 @@ describe('JobService', () => { it('should throw an error if the escrow has invalid balance', async () => { escrowClientMock.getStatus = jest.fn().mockResolvedValue(EscrowStatus.Launched); escrowClientMock.getBalance = jest.fn().mockResolvedValue(BigNumber.from(0)); - saveMock.mockResolvedValue(jobEntityMock as JobEntity); + jobSaveMock.mockResolvedValue(jobEntityMock as JobEntity); buildMock.mockResolvedValue(escrowClientMock as any); await expect( @@ -814,7 +828,7 @@ describe('JobService', () => { requestType: JobRequestType.FORTUNE }; - saveMock.mockResolvedValue(jobEntityMock as JobEntity); + jobSaveMock.mockResolvedValue(jobEntityMock as JobEntity); getManifestMock.mockResolvedValue(manifest); buildMock.mockResolvedValue(escrowClientMock as any); sendWebhookMock.mockResolvedValue(true); @@ -823,7 +837,7 @@ describe('JobService', () => { expect(result).toBe(true); expect(escrowClientMock.cancel).toHaveBeenCalledWith(jobEntityMock.escrowAddress); - expect(saveMock).toHaveBeenCalledWith(); + expect(jobSaveMock).toHaveBeenCalledWith(); expect(sendWebhookMock).toHaveBeenCalledWith( expect.any(String), { @@ -854,7 +868,7 @@ describe('JobService', () => { job_bounty: '1', }; - saveMock.mockResolvedValue(jobEntityMock as JobEntity); + jobSaveMock.mockResolvedValue(jobEntityMock as JobEntity); getManifestMock.mockResolvedValue(manifest); buildMock.mockResolvedValue(escrowClientMock as any); sendWebhookMock.mockResolvedValue(true); @@ -863,7 +877,7 @@ describe('JobService', () => { expect(result).toBe(true); expect(escrowClientMock.cancel).toHaveBeenCalledWith(jobEntityMock.escrowAddress); - expect(saveMock).toHaveBeenCalledWith(); + expect(jobSaveMock).toHaveBeenCalledWith(); expect(sendWebhookMock).toHaveBeenCalledWith( expect.any(String), { 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 8ce0d31b63..8f9c02a5a7 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 @@ -343,6 +343,12 @@ export class JobService { } } + const paymentEntity = await this.paymentRepository.findOne({ jobId: jobEntity.id, type: PaymentType.WITHDRAWAL, status: PaymentStatus.SUCCEEDED }); + if (paymentEntity) { + paymentEntity.status = PaymentStatus.FAILED; + await paymentEntity.save(); + } + jobEntity.status = JobStatus.CANCELED; await jobEntity.save();