diff --git a/.github/workflows/ci-test-human-app.yaml b/.github/workflows/ci-test-human-app.yaml new file mode 100644 index 0000000000..505b1f8c98 --- /dev/null +++ b/.github/workflows/ci-test-human-app.yaml @@ -0,0 +1,23 @@ +name: Human App Check + +on: + push: + branches: + - "main" + pull_request: + paths: + - "packages/core/**" + - "packages/sdk/typescript/human-protocol-sdk/**" + - "packages/apps/human-app/**" + workflow_dispatch: + +jobs: + job-app-server-test: + name: Human App Server Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm install --global yarn && yarn + name: Install dependencies + - run: yarn human-app-server:test + name: Run Job Human App unit tests diff --git a/package.json b/package.json index fcfc615753..bdaee43839 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "job-launcher-server:lint": "yarn workspace @human-protocol/job-launcher-server lint", "job-launcher-client:test": "yarn workspace @human-protocol/job-launcher-client test", "job-launcher-client:lint": "yarn workspace @human-protocol/job-launcher-client lint", + "human-app-server:test": "yarn workspace @human-protocol/human-app-server test", + "human-app-server:lint": "yarn workspace @human-protocol/human-app-server lint", "reputation-oracle:test": "yarn workspace @human-protocol/reputation-oracle test", "reputation-oracle:lint": "yarn workspace @human-protocol/reputation-oracle lint", "sdk:test": "yarn workspace @human-protocol/sdk test", diff --git a/packages/apps/human-app/server/.dockerignore b/packages/apps/human-app/server/.dockerignore new file mode 100644 index 0000000000..1d7d55faac --- /dev/null +++ b/packages/apps/human-app/server/.dockerignore @@ -0,0 +1,5 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +dist \ No newline at end of file diff --git a/packages/apps/human-app/server/.env.example b/packages/apps/human-app/server/.env.example index 8587caea67..17951d7812 100644 --- a/packages/apps/human-app/server/.env.example +++ b/packages/apps/human-app/server/.env.example @@ -1,5 +1,19 @@ -HOST= -PORT= -REPUTATION_ORACLE_URL= -E2E_TESTING_EMAIL_ADDRESS= -E2E_TESTING_PASSWORD= +HOST= # string, example: localhost +PORT= # number, example: 5010 +REPUTATION_ORACLE_URL= # string +REPUTATION_ORACLE_ADDRESS= # string +REDIS_HOST= # string, example: localhost +REDIS_PORT= # number, example: 6379 +CACHE_TTL_ORACLE_DISCOVERY= # number, example: 43200 +CACHE_TTL_ORACLE_STATS= # number, example: 900 +CACHE_TTL_USER_STATS= # number, example: 86400 +E2E_TESTING_EMAIL_ADDRESS= # string +E2E_TESTING_PASSWORD= # string +E2E_TESTING_EXCHANGE_ORACLE_URL= # string +E2E_TESTING_ESCROW_ADDRESS= # string +E2E_TESTING_ESCROW_CHAIN_ID= # number +RPC_URL= # string +CORS_ENABLED= # boolean, example: true +CORS_ALLOWED_ORIGIN= # string example: http://localhost:5173 +CORS_ALLOWED_HEADERS= # string, example: 'Content-Type,Accept' +CHAIN_IDS_ENABLED= # number array, example: 80002,80001 \ No newline at end of file diff --git a/packages/apps/human-app/server/.gitignore b/packages/apps/human-app/server/.gitignore index 22f55adc56..b73e03d590 100644 --- a/packages/apps/human-app/server/.gitignore +++ b/packages/apps/human-app/server/.gitignore @@ -32,4 +32,7 @@ lerna-debug.log* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json + +# Redis Data +./redis_data \ No newline at end of file diff --git a/packages/apps/human-app/server/Dockerfile b/packages/apps/human-app/server/Dockerfile new file mode 100644 index 0000000000..2d5efe5498 --- /dev/null +++ b/packages/apps/human-app/server/Dockerfile @@ -0,0 +1,17 @@ +# Base image +FROM node:18 + +# Create app directory +WORKDIR /usr/src/app + +# Bundle app source +COPY . . + +# Install app dependencies +RUN yarn install + +# Creates a "dist" folder with the production build +RUN yarn workspace @human-protocol/human-app-server build + +# Start the server using the production build +CMD [ "node", "packages/apps/human-app/server/dist/src/main.js" ] diff --git a/packages/apps/human-app/server/README.md b/packages/apps/human-app/server/README.md index 812d2d3228..a90a442909 100644 --- a/packages/apps/human-app/server/README.md +++ b/packages/apps/human-app/server/README.md @@ -40,7 +40,7 @@ following subcategories: - **Service**: Contains the main business logic. - **Mapper**: Manages mapping between DTOs received from the frontend and the business logic domain datatype, using NestJS AutoMapper. - **Module**: Manages the dependency injection for the component. -- **Interfaces**: For more details, see the **Interfaces** section below. +- **Model**: For more details, see the **Model** section below. - **Spec**: Contains unit tests for the module. - **Mock**: Provides mock implementations for the module. @@ -59,9 +59,9 @@ and transformation of data. NestJS provides many built-in pipes, so check the do - **Filters**: Classes implementing the `ExceptionFilter` interface, used to handle exceptions. - **Interceptors**: Used to bind extra logic before or after method execution or to extend the behavior of the method. -### Interfaces +### Model -Interfaces are used to define the shape and responsibilities of the data: +Models are used to define the shape and responsibilities of the data: - **Dto (Data Transfer Object)**: Data sent from/to the frontend. - **Command**: Datatype used for data manipulation in business logic. diff --git a/packages/apps/human-app/server/docker-compose.yml b/packages/apps/human-app/server/docker-compose.yml new file mode 100644 index 0000000000..7b9c3d7c24 --- /dev/null +++ b/packages/apps/human-app/server/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + human-app: + container_name: human-app + restart: unless-stopped + build: + context: ../../../../ + dockerfile: packages/apps/human-app/server/Dockerfile + expose: + - '${PORT}' + ports: + - '${LOCAL_PORT}:${PORT}' + environment: + NODE_ENV: ${NODE_ENV} + HOST: ${HOST} + PORT: ${PORT} + REPUTATION_ORACLE_URL: ${REPUTATION_ORACLE_URL} + REPUTATION_ORACLE_ADDRESS: ${REPUTATION_ORACLE_ADDRESS} + REDIS_PORT: ${REDIS_PORT} + REDIS_HOST: redis + CACHE_TTL_ORACLE_DISCOVERY: ${CACHE_TTL_ORACLE_DISCOVERY} + RPC_URL: ${RPC_URL} + depends_on: + - redis + redis: + image: redis:latest + container_name: human_app_cache + ports: + - '${REDIS_PORT}:6379' + volumes: + - ./redis_data:/data \ No newline at end of file diff --git a/packages/apps/human-app/server/package.json b/packages/apps/human-app/server/package.json index 52de5d8aca..ee19624e30 100644 --- a/packages/apps/human-app/server/package.json +++ b/packages/apps/human-app/server/package.json @@ -1,5 +1,5 @@ { - "name": "@human-protocol/human-app", + "name": "@human-protocol/human-app-server", "version": "0.0.1", "description": "", "author": "", @@ -23,13 +23,21 @@ "@automapper/classes": "^8.8.1", "@automapper/core": "^8.8.1", "@automapper/nestjs": "^8.8.1", + "@human-protocol/sdk": "*", "@nestjs/axios": "^2.0.0", + "@nestjs/cache-manager": "^2.2.1", "@nestjs/common": "^10.2.7", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.2.8", "@nestjs/platform-express": "^10.3.8", "@nestjs/swagger": "^7.1.13", + "cache-manager": "^5.4.0", + "cache-manager-redis-store": "^3.0.1", + "ioredis": "^5.3.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "ethers": "^6.11.0", + "joi": "^17.12.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" }, diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 1440fe8d2e..2cfeb33b57 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -6,30 +6,80 @@ import { WorkerModule } from './modules/user-worker/worker.module'; import { ReputationOracleModule } from './integrations/reputation-oracle/reputation-oracle.module'; import { AutomapperModule } from '@automapper/nestjs'; import { classes } from '@automapper/classes'; -import { envValidator } from './common/config/environment-config.service'; import { OperatorModule } from './modules/user-operator/operator.module'; import { OperatorController } from './modules/user-operator/operator.controller'; import { WorkerController } from './modules/user-worker/worker.controller'; import { CommonConfigModule } from './common/config/common-config.module'; +import { CacheFactoryConfig } from './common/config/cache-factory.config'; +import { CacheModule } from '@nestjs/cache-manager'; +import { OracleDiscoveryController } from './modules/oracle-discovery/oracle-discovery.controller'; +import { OracleDiscoveryModule } from './modules/oracle-discovery/oracle-discovery.module'; +import { JobsDiscoveryModule } from './modules/jobs-discovery/jobs-discovery.module'; +import { JobsDiscoveryController } from './modules/jobs-discovery/jobs-discovery.controller'; +import { JobAssignmentController } from './modules/job-assignment/job-assignment.controller'; +import { JobAssignmentModule } from './modules/job-assignment/job-assignment.module'; +import { StatisticsModule } from './modules/statistics/statistics.module'; +import { StatisticsController } from './modules/statistics/statistics.controller'; +import { ExchangeOracleModule } from './integrations/exchange-oracle/exchange-oracle.module'; +import { KvStoreModule } from './integrations/kv-store/kv-store.module'; +import { EscrowUtilsModule } from './integrations/escrow/escrow-utils.module'; +import Joi from 'joi'; +import { ChainId } from '@human-protocol/sdk'; @Module({ imports: [ ConfigModule.forRoot({ envFilePath: '.env', isGlobal: true, - validationSchema: envValidator, + validationSchema: Joi.object({ + HOST: Joi.string().required(), + PORT: Joi.number().required(), + REPUTATION_ORACLE_URL: Joi.string().required(), + REPUTATION_ORACLE_ADDRESS: Joi.string().required(), + REDIS_PORT: Joi.number().required(), + REDIS_HOST: Joi.string().required(), + RPC_URL: Joi.string().required(), + CHAIN_IDS_ENABLED: Joi.string() + .custom((value) => { + const chainIds = value.split(','); + for (const id of chainIds) { + if (!Object.values(ChainId).includes(Number(id.trim()))) { + throw new Error( + `Invalid chain ID: Chain ID ${id} is not included in the HUMAN SDK.`, + ); + } + } + return value; + }) + .required(), + }), }), AutomapperModule.forRoot({ strategyInitializer: classes(), }), + CacheModule.registerAsync(CacheFactoryConfig), HttpModule, WorkerModule, OperatorModule, + JobsDiscoveryModule, + JobAssignmentModule, ReputationOracleModule, + ExchangeOracleModule, CommonConfigModule, + OracleDiscoveryModule, + StatisticsModule, + KvStoreModule, + EscrowUtilsModule, + ], + controllers: [ + AppController, + OperatorController, + WorkerController, + JobsDiscoveryController, + OracleDiscoveryController, + JobAssignmentController, + StatisticsController, ], - controllers: [AppController, OperatorController, WorkerController], - providers: [], exports: [HttpModule], }) export class AppModule {} diff --git a/packages/apps/human-app/server/src/common/config/cache-factory.config.ts b/packages/apps/human-app/server/src/common/config/cache-factory.config.ts new file mode 100644 index 0000000000..278680dfcc --- /dev/null +++ b/packages/apps/human-app/server/src/common/config/cache-factory.config.ts @@ -0,0 +1,20 @@ +import { CacheModuleAsyncOptions } from '@nestjs/common/cache'; +import { ConfigModule } from '@nestjs/config'; +import { EnvironmentConfigService } from './environment-config.service'; +import { redisStore } from 'cache-manager-redis-store'; +export const CacheFactoryConfig: CacheModuleAsyncOptions = { + isGlobal: true, + imports: [ConfigModule], + useFactory: async (configService: EnvironmentConfigService) => { + const store = await redisStore({ + socket: { + host: configService.cacheHost, + port: configService.cachePort, + }, + }); + return { + store: () => store, + }; + }, + inject: [EnvironmentConfigService], +}; diff --git a/packages/apps/human-app/server/src/common/config/environment-config.service.ts b/packages/apps/human-app/server/src/common/config/environment-config.service.ts index 2ebf782fca..99de8b5674 100644 --- a/packages/apps/human-app/server/src/common/config/environment-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/environment-config.service.ts @@ -1,23 +1,71 @@ -import Joi from 'joi'; import { ConfigService } from '@nestjs/config'; import { Injectable } from '@nestjs/common'; - +const DEFAULT_CACHE_TTL_ORACLE_STATS = 12 * 60 * 60; +const DEFAULT_CACHE_TTL_USER_STATS = 15 * 60; +const DEFAULT_CACHE_TTL_ORACLE_DISCOVERY = 24 * 60 * 60; +const DEFAULT_CORS_ALLOWED_ORIGIN = 'http://localhost:5173'; +const DEFAULT_CORS_ALLOWED_HEADERS = 'Content-Type, Accept'; @Injectable() export class EnvironmentConfigService { constructor(private configService: ConfigService) {} get host(): string { - return this.configService.get('HOST', 'localhost'); + return this.configService.getOrThrow('HOST'); } - get port(): string { - return this.configService.get('PORT', '5010'); + get port(): number { + return this.configService.getOrThrow('PORT'); } get reputationOracleUrl(): string { - return this.configService.get('REPUTATION_ORACLE_URL', ''); + return this.configService.getOrThrow('REPUTATION_ORACLE_URL'); + } + get reputationOracleAddress(): string { + return this.configService.getOrThrow('REPUTATION_ORACLE_ADDRESS'); + } + get cachePort(): number { + return this.configService.getOrThrow('REDIS_PORT'); + } + get cacheHost(): string { + return this.configService.getOrThrow('REDIS_HOST'); + } + get cacheTtlOracleStats(): number { + return this.configService.get( + 'CACHE_TTL_ORACLE_STATS', + DEFAULT_CACHE_TTL_ORACLE_STATS, + ); + } + + get cacheTtlUserStats(): number { + return this.configService.get( + 'CACHE_TTL_USER_STATS', + DEFAULT_CACHE_TTL_USER_STATS, + ); } -} -export const envValidator = Joi.object({ - HOST: Joi.string().default('localhost'), - PORT: Joi.string().default(5010), - REPUTATION_ORACLE_URL: Joi.string().required(), -}); + get cacheTtlOracleDiscovery(): number { + return this.configService.get( + 'CACHE_TTL_ORACLE_DISCOVERY', + DEFAULT_CACHE_TTL_ORACLE_DISCOVERY, + ); + } + get rpcUrl(): string { + return this.configService.getOrThrow('RPC_URL'); + } + get isCorsEnabled(): boolean { + return this.configService.get('CORS_ENABLED', false); + } + get corsEnabledOrigin(): string { + return this.configService.get( + 'CORS_ALLOWED_ORIGIN', + DEFAULT_CORS_ALLOWED_ORIGIN, + ); + } + get corsAllowedHeaders(): string { + return this.configService.get( + 'CORS_ALLOWED_HEADERS', + DEFAULT_CORS_ALLOWED_HEADERS, + ); + } + get chainIdsEnabled(): string[] { + const chainIds = this.configService.getOrThrow('CHAIN_IDS_ENABLED'); + return chainIds.split(',').map((id) => id.trim()); + } +} diff --git a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts index 0a351c8716..36a022cbbb 100644 --- a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts @@ -3,9 +3,13 @@ import { ExternalApiName } from '../enums/external-api-name'; import { EndpointName } from '../enums/endpoint-name'; import { GatewayConfig, Gateways } from '../interfaces/endpoint.interface'; import { EnvironmentConfigService } from './environment-config.service'; +import { HttpMethod } from '../enums/http-method'; @Injectable() export class GatewayConfigService { + JSON_HEADER = { + 'Content-Type': 'application/json', + }; constructor(private envConfig: EnvironmentConfigService) {} private getGatewaysConfig(): Gateways { @@ -16,24 +20,18 @@ export class GatewayConfigService { endpoints: { [EndpointName.WORKER_SIGNUP]: { endpoint: '/auth/signup', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + method: HttpMethod.POST, + headers: this.JSON_HEADER, }, [EndpointName.OPERATOR_SIGNUP]: { endpoint: '/auth/web3/signup', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + method: HttpMethod.POST, + headers: this.JSON_HEADER, }, [EndpointName.WORKER_SIGNIN]: { endpoint: '/auth/signin', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + method: HttpMethod.POST, + headers: this.JSON_HEADER, }, }, }, diff --git a/packages/apps/human-app/server/src/common/config/params-decorators.ts b/packages/apps/human-app/server/src/common/config/params-decorators.ts new file mode 100644 index 0000000000..c773a72741 --- /dev/null +++ b/packages/apps/human-app/server/src/common/config/params-decorators.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const Authorization = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.headers['authorization']; + }, +); \ No newline at end of file diff --git a/packages/apps/human-app/server/src/common/config/test-environment-config.service.ts b/packages/apps/human-app/server/src/common/config/test-environment-config.service.ts index 59d01d3355..f69e94f407 100644 --- a/packages/apps/human-app/server/src/common/config/test-environment-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/test-environment-config.service.ts @@ -11,9 +11,24 @@ export class TestEnvironmentConfigService { get e2eTestingPassword(): string { return this.configService.get('E2E_TESTING_PASSWORD', ''); } + get e2eTestingExchangeOracleUrl(): string { + return this.configService.get( + 'E2E_TESTING_EXCHANGE_ORACLE_URL', + '', + ); + } + get e2eTestingEscrowAddress(): string { + return this.configService.get('E2E_TESTING_ESCROW_ADDRESS', ''); + } + get e2eTestingEscrowChainId(): string { + return this.configService.get('E2E_TESTING_ESCROW_CHAIN_ID', ''); + } } export const testEnvValidator = Joi.object({ E2E_TESTING_EMAIL_ADDRESS: Joi.string().required(), E2E_TESTING_PASSWORD: Joi.string().required(), + E2E_TESTING_EXCHANGE_ORACLE_URL: Joi.string().required(), + E2E_TESTING_ESCROW_ADDRESS: Joi.string().required(), + E2E_TESTING_ESCROW_CHAIN_ID: Joi.string().required(), }); diff --git a/packages/apps/human-app/server/src/common/enums/global-common.ts b/packages/apps/human-app/server/src/common/enums/global-common.ts new file mode 100644 index 0000000000..d9cda684ec --- /dev/null +++ b/packages/apps/human-app/server/src/common/enums/global-common.ts @@ -0,0 +1,37 @@ +export enum JobDiscoveryFieldName { + JobDescription = 'job_description', + RewardAmount = 'reward_amount', + RewardToken = 'reward_token', + CreatedAt = 'created_at', +} +export enum JobStatus { + ACTIVE = 'ACTIVE', + COMPLETED = 'COMPLETED', + CANCELED = 'CANCELED', +} +export enum AssignmentStatus { + ACTIVE = 'ACTIVE', + VALIDATION = 'VALIDATION', + COMPLETED = 'COMPLETED', + EXPIRED = 'EXPIRED', + CANCELED = 'CANCELED', + REJECTED = 'REJECTED', +} +export enum JobDiscoverySortField { + CHAIN_ID = 'chain_id', + JOB_TYPE = 'job_type', + REWARD_AMOUNT = 'reward_amount', + CREATED_AT = 'created_at', +} +export enum SortOrder { + ASC = 'ASC', + DESC = 'DESC', +} +export enum AssignmentSortField { + CHAIN_ID = 'chain_id', + JOB_TYPE = 'job_type', + STATUS = 'status', + REWARD_AMOUNT = 'reward_amount', + CREATED_AT = 'created_at', + EXPIRES_AT = 'expires_at', +} diff --git a/packages/apps/human-app/server/src/common/enums/http-method.ts b/packages/apps/human-app/server/src/common/enums/http-method.ts new file mode 100644 index 0000000000..5a3bbf6de1 --- /dev/null +++ b/packages/apps/human-app/server/src/common/enums/http-method.ts @@ -0,0 +1,4 @@ +export enum HttpMethod { + GET = 'GET', + POST = 'POST', +} diff --git a/packages/apps/human-app/server/src/common/filter/global-exception.filter.spec.ts b/packages/apps/human-app/server/src/common/filter/global-exception.filter.spec.ts new file mode 100644 index 0000000000..5dfcb113bf --- /dev/null +++ b/packages/apps/human-app/server/src/common/filter/global-exception.filter.spec.ts @@ -0,0 +1,58 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { GlobalExceptionsFilter } from './global-exceptions.filter'; +import { Test, TestingModule } from '@nestjs/testing'; + +describe('GlobalExceptionsFilter', () => { + let filter: GlobalExceptionsFilter; + let mockJson: jest.Mock; + let mockStatus: jest.Mock; + let mockGetResponse: jest.Mock; + let mockHttpArgumentsHost: jest.Mock; + let mockArgumentsHost: any; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [GlobalExceptionsFilter], + }).compile(); + filter = module.get(GlobalExceptionsFilter); + mockJson = jest.fn(); + mockStatus = jest.fn().mockImplementation(() => ({ + json: mockJson, + })); + mockGetResponse = jest.fn().mockImplementation(() => ({ + status: mockStatus, + })); + mockHttpArgumentsHost = jest.fn().mockImplementation(() => ({ + getResponse: mockGetResponse, + getRequest: jest.fn(), + })); + + mockArgumentsHost = { + switchToHttp: mockHttpArgumentsHost, + getArgByIndex: jest.fn(), + getArgs: jest.fn(), + getType: jest.fn(), + switchToRpc: jest.fn(), + switchToWs: jest.fn(), + }; + }); + it('should be defined', () => { + expect(filter).toBeDefined(); + }); + + it('should handle HttpException', () => { + filter.catch( + new HttpException('Http exception', HttpStatus.BAD_REQUEST), + mockArgumentsHost, + ); + expect(mockHttpArgumentsHost).toBeCalledTimes(1); + expect(mockHttpArgumentsHost).toBeCalledWith(); + expect(mockGetResponse).toBeCalledTimes(1); + expect(mockGetResponse).toBeCalledWith(); + expect(mockStatus).toBeCalledTimes(1); + expect(mockStatus).toBeCalledWith(HttpStatus.BAD_REQUEST); + expect(mockJson).toBeCalledTimes(1); + expect(mockJson).toBeCalledWith('Http exception'); + }); +}); diff --git a/packages/apps/human-app/server/src/common/filter/global-exceptions.filter.ts b/packages/apps/human-app/server/src/common/filter/global-exceptions.filter.ts new file mode 100644 index 0000000000..ca43abd14b --- /dev/null +++ b/packages/apps/human-app/server/src/common/filter/global-exceptions.filter.ts @@ -0,0 +1,27 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; + +@Catch() +export class GlobalExceptionsFilter implements ExceptionFilter { + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message: any = 'Internal Server Error'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + message = exception.getResponse(); + } else if (exception.response) { + status = exception.response.data.statusCode; + message = exception.response.data.message; + } + + response.status(status).json(message); + } +} diff --git a/packages/apps/human-app/server/src/common/utils/pageable.model.ts b/packages/apps/human-app/server/src/common/utils/pageable.model.ts new file mode 100644 index 0000000000..1d9cb8dba9 --- /dev/null +++ b/packages/apps/human-app/server/src/common/utils/pageable.model.ts @@ -0,0 +1,65 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator'; +import { SortOrder } from '../enums/global-common'; +import { AutoMap } from '@automapper/classes'; + +export abstract class PageableDto { + @AutoMap() + @ApiPropertyOptional({ + minimum: 0, + default: 0, + }) + @Type(() => Number) + @IsNumber() + @Min(0) + @IsOptional() + page?: number = 0; + + @AutoMap() + @ApiPropertyOptional({ + minimum: 1, + maximum: 10, + default: 5, + name: 'page_size', + }) + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(10) + @IsOptional() + page_size?: number = 5; + + @AutoMap() + @ApiPropertyOptional({ enum: SortOrder }) + @IsEnum(SortOrder) + @IsOptional() + sort?: SortOrder = SortOrder.ASC; + + @IsOptional() + abstract sort_field?: any; +} +export abstract class PageableParams { + @AutoMap() + page?: number; + + @AutoMap() + pageSize?: number; + + @AutoMap() + sort?: SortOrder; + + abstract sortField?: any; +} +export abstract class PageableData { + @AutoMap() + page?: number; + + @AutoMap() + page_size?: number; + + @AutoMap() + sort?: SortOrder; + + abstract sort_field?: any; +} diff --git a/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts b/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts new file mode 100644 index 0000000000..9c9e7eb183 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts @@ -0,0 +1,16 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ChainId, EscrowUtils } from '@human-protocol/sdk'; + +@Injectable() +export class EscrowUtilsGateway { + async getExchangeOracleAddressByEscrowAddress( + chainId: ChainId, + address: string, + ): Promise { + const escrowsData = await EscrowUtils.getEscrow(chainId, address); + if (!escrowsData.exchangeOracle) { + throw new NotFoundException('Exchange Oracle not found'); + } + return escrowsData.exchangeOracle; + } +} diff --git a/packages/apps/human-app/server/src/integrations/escrow/escrow-utils.module.ts b/packages/apps/human-app/server/src/integrations/escrow/escrow-utils.module.ts new file mode 100644 index 0000000000..a2f45c1332 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/escrow/escrow-utils.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { EscrowUtilsGateway } from './escrow-utils-gateway.service'; + +@Module({ + providers: [EscrowUtilsGateway], + exports: [EscrowUtilsGateway], +}) +export class EscrowUtilsModule {} diff --git a/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts b/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts new file mode 100644 index 0000000000..e8a521709e --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EscrowUtilsGateway } from '../escrow-utils-gateway.service'; +import { EscrowUtils, ChainId } from '@human-protocol/sdk'; +import { NotFoundException } from '@nestjs/common'; + +jest.mock('@human-protocol/sdk', () => { + return { + EscrowUtils: { + getEscrow: jest.fn().mockResolvedValue({ + exchangeOracle: '0x', + }), + }, + ChainId: { + POLYGON_AMOY: 1, + }, + }; +}); + +describe('EscrowUtilsGateway', () => { + let service: EscrowUtilsGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [EscrowUtilsGateway], + }).compile(); + + service = module.get(EscrowUtilsGateway); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getExchangeOracleAddressByEscrowAddress', () => { + it('should fetch data from EscrowUtils', async () => { + const escrowAddress = 'escrowAddress'; + const expectedUrl = '0x'; + + const result = await service.getExchangeOracleAddressByEscrowAddress( + ChainId.POLYGON_AMOY, + escrowAddress, + ); + expect(EscrowUtils.getEscrow).toHaveBeenCalledWith( + ChainId.POLYGON_AMOY, + escrowAddress, + ); + expect(result).toBe(expectedUrl); + }); + + it('should throw error if Exchange Oracle not found', async () => { + const escrowAddress = 'escrowAddress'; + + (EscrowUtils.getEscrow as jest.Mock).mockResolvedValue({}); + + await expect( + service.getExchangeOracleAddressByEscrowAddress( + ChainId.POLYGON_AMOY, + escrowAddress, + ), + ).rejects.toThrow(new NotFoundException('Exchange Oracle not found')); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.gateway.ts new file mode 100644 index 0000000000..e407dd7030 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.gateway.ts @@ -0,0 +1,132 @@ +import { Injectable } from '@nestjs/common'; +import { AxiosRequestConfig } from 'axios'; +import { lastValueFrom } from 'rxjs'; +import { + UserStatisticsDetails, + UserStatisticsResponse, +} from '../../modules/statistics/model/user-statistics.model'; +import { HttpService } from '@nestjs/axios'; +import { + OracleStatisticsDetails, + OracleStatisticsResponse, +} from '../../modules/statistics/model/oracle-statistics.model'; +import { + JobAssignmentData, + JobAssignmentDetails, + JobAssignmentParams, + JobAssignmentResponse, + JobsFetchParams, + JobsFetchParamsData, + JobsFetchParamsDetails, + JobsFetchResponse, +} from '../../modules/job-assignment/model/job-assignment.model'; +import { + JobsDiscoveryParams, + JobsDiscoveryParamsData, + JobsDiscoveryParamsDetails, + JobsDiscoveryResponse, +} from '../../modules/jobs-discovery/model/jobs-discovery.model'; +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { instanceToPlain } from 'class-transformer'; +import { HttpMethod } from '../../common/enums/http-method'; + +@Injectable() +export class ExchangeOracleGateway { + constructor( + private httpService: HttpService, + @InjectMapper() private mapper: Mapper, + ) {} + + private cleanParams(obj: any): any { + return Object.entries(obj) + .filter(([_, v]) => v != null) + .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}); + } + private toCleanObjParams(params: any): any { + const plainParams = instanceToPlain(params); + return this.cleanParams(plainParams); + } + private async callExternalHttpUtilRequest( + options: AxiosRequestConfig, + ): Promise { + const response = await lastValueFrom(this.httpService.request(options)); + return response.data; + } + async fetchUserStatistics( + details: UserStatisticsDetails, + ): Promise { + const options: AxiosRequestConfig = { + method: HttpMethod.GET, + url: `${details.exchangeOracleUrl}/stats/assignment`, + headers: { + Authorization: details.token, + }, + }; + return this.callExternalHttpUtilRequest(options); + } + async fetchOracleStatistics( + details: OracleStatisticsDetails, + ): Promise { + const options: AxiosRequestConfig = { + method: HttpMethod.GET, + url: `${details.exchangeOracleUrl}/stats`, + }; + return this.callExternalHttpUtilRequest(options); + } + async fetchAssignedJobs( + details: JobsFetchParamsDetails, + ): Promise { + const jobFetchParamsData = this.mapper.map( + details.data, + JobsFetchParams, + JobsFetchParamsData, + ); + const reducedParams = this.toCleanObjParams(jobFetchParamsData); + const options: AxiosRequestConfig = { + method: HttpMethod.GET, + url: `${details.exchangeOracleUrl}/assignment`, + params: reducedParams, + headers: { + Authorization: details.token, + Accept: 'application/json', + }, + }; + return this.callExternalHttpUtilRequest(options); + } + async postNewJobAssignment( + details: JobAssignmentDetails, + ): Promise { + const options: AxiosRequestConfig = { + method: HttpMethod.POST, + url: `${details.exchangeOracleUrl}/assignment`, + data: this.mapper.map( + details.data, + JobAssignmentParams, + JobAssignmentData, + ), + headers: { + Authorization: details.token, + }, + }; + return this.callExternalHttpUtilRequest(options); + } + async fetchJobs(details: JobsDiscoveryParamsDetails) { + const jobsDiscoveryParamsData = this.mapper.map( + details.data, + JobsDiscoveryParams, + JobsDiscoveryParamsData, + ); + const reducedParams = this.toCleanObjParams(jobsDiscoveryParamsData); + const options: AxiosRequestConfig = { + method: HttpMethod.GET, + url: `${details.exchangeOracleUrl}/job`, + params: reducedParams, + headers: { + Authorization: details.token, + Accept: 'application/json', + }, + }; + return this.callExternalHttpUtilRequest(options); + } +} diff --git a/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.mapper.ts b/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.mapper.ts new file mode 100644 index 0000000000..02ed07be77 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.mapper.ts @@ -0,0 +1,65 @@ +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + CamelCaseNamingConvention, + createMap, + forMember, + mapFrom, + Mapper, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; +import { + JobAssignmentData, + JobAssignmentParams, + JobsFetchParams, + JobsFetchParamsData, +} from '../../modules/job-assignment/model/job-assignment.model'; +import { + JobsDiscoveryParams, + JobsDiscoveryParamsData, +} from '../../modules/jobs-discovery/model/jobs-discovery.model'; + +@Injectable() +export class ExchangeOracleProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + JobAssignmentParams, + JobAssignmentData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); + createMap( + mapper, + JobsFetchParams, + JobsFetchParamsData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); + createMap( + mapper, + JobsDiscoveryParams, + JobsDiscoveryParamsData, + // Automapper has problem with mapping arrays, thus explicit conversion + forMember( + (destination) => destination.fields, + mapFrom((source) => source.fields), + ), + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); + }; + } +} diff --git a/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.module.ts b/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.module.ts new file mode 100644 index 0000000000..890249e7c1 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/exchange-oracle/exchange-oracle.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ExchangeOracleGateway } from './exchange-oracle.gateway'; +import { ExchangeOracleProfile } from './exchange-oracle.mapper'; + +@Module({ + imports: [HttpModule], + providers: [ExchangeOracleGateway, ExchangeOracleProfile], + exports: [ExchangeOracleGateway], +}) +export class ExchangeOracleModule {} diff --git a/packages/apps/human-app/server/src/integrations/exchange-oracle/spec/exchange-oracle.gateway.mock.ts b/packages/apps/human-app/server/src/integrations/exchange-oracle/spec/exchange-oracle.gateway.mock.ts new file mode 100644 index 0000000000..2f25a37d5e --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/exchange-oracle/spec/exchange-oracle.gateway.mock.ts @@ -0,0 +1,7 @@ +export const exchangeOracleGatewayMock = { + fetchOracleStatistics: jest.fn(), + fetchUserStatistics: jest.fn(), + fetchAssignedJobs: jest.fn(), + postNewJobAssignment: jest.fn(), + fetchDiscoveredJobs: jest.fn(), +}; diff --git a/packages/apps/human-app/server/src/integrations/exchange-oracle/spec/exchange-oracle.gateway.spec.ts b/packages/apps/human-app/server/src/integrations/exchange-oracle/spec/exchange-oracle.gateway.spec.ts new file mode 100644 index 0000000000..61b11b1457 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/exchange-oracle/spec/exchange-oracle.gateway.spec.ts @@ -0,0 +1,171 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { ExchangeOracleGateway } from '../exchange-oracle.gateway'; +import { + oracleStatsDetailsFixture, + statisticsExchangeOracleUrl, + userStatsDetailsFixture, +} from '../../../modules/statistics/spec/statistics.fixtures'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import nock, { RequestBodyMatcher } from 'nock'; +import { of, throwError } from 'rxjs'; +import { + jobAssignmentDataFixture, + jobAssignmentDetailsFixture, + jobAssignmentOracleUrl, + jobsFetchParamsDataFixtureAsString, + jobsFetchParamsDetailsFixture, +} from '../../../modules/job-assignment/spec/job-assignment.fixtures'; +import { ExchangeOracleProfile } from '../exchange-oracle.mapper'; +import { + jobsDiscoveryParamsDetailsFixture, + paramsDataFixtureAsString, +} from '../../../modules/jobs-discovery/spec/jobs-discovery.fixtures'; +import { GoneException, HttpException } from '@nestjs/common'; +import { UserStatisticsDetails } from '../../../modules/statistics/model/user-statistics.model'; +import { HttpMethod } from '../../../common/enums/http-method'; + +describe('ExchangeOracleApiGateway', () => { + let gateway: ExchangeOracleGateway; + let httpService: HttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + ExchangeOracleProfile, + ExchangeOracleGateway, + { + provide: HttpService, + useValue: { + request: jest.fn().mockReturnValue(of({ data: 'mocked response' })), + }, + }, + ], + }).compile(); + + gateway = module.get(ExchangeOracleGateway); + httpService = module.get(HttpService); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + expect(httpService).toBeDefined(); + }); + + describe('fetchUserStatistics', () => { + it('should successfully call the requested url for user statistics', async () => { + const details = userStatsDetailsFixture; + nock(statisticsExchangeOracleUrl) + .get('/stats/assignment') + .matchHeader('Authorization', `Bearer ${details.token}`) + .reply(200); + await gateway.fetchUserStatistics(details); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: details.exchangeOracleUrl + '/stats/assignment', + method: HttpMethod.GET, + }), + ); + }); + it('should handle errors on fetchUserStatistics', async () => { + const details = { + exchangeOracleUrl: 'https://example.com', + token: 'dummyToken', + } as UserStatisticsDetails; + jest + .spyOn(httpService, 'request') + .mockReturnValue( + throwError(() => new HttpException('Service Unavailable', 503)), + ); + + await expect(gateway.fetchUserStatistics(details)).rejects.toThrow( + HttpException, + ); + }); + }); + describe('fetchOracleStatistics', () => { + it('should successfully call the requested url for oracle statistics', async () => { + const details = oracleStatsDetailsFixture; + nock(statisticsExchangeOracleUrl).get('/stats').reply(200); + await gateway.fetchOracleStatistics(details); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: details.exchangeOracleUrl + '/stats', + method: HttpMethod.GET, + }), + ); + }); + it('should handle errors on fetchOracleStatistics', async () => { + const details = { + exchangeOracleUrl: 'https://example.com', + token: 'dummyToken', + } as UserStatisticsDetails; + jest + .spyOn(httpService, 'request') + .mockReturnValue(throwError(() => new GoneException())); + + await expect(gateway.fetchUserStatistics(details)).rejects.toThrow( + GoneException, + ); + }); + }); + describe('fetchAssignedJobs', () => { + it('should successfully call get assigned jobs', async () => { + const details = jobsFetchParamsDetailsFixture; + nock(jobAssignmentOracleUrl) + .get(`/assignment${jobsFetchParamsDataFixtureAsString}`) + .reply(200); + await gateway.fetchAssignedJobs(details); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: details.exchangeOracleUrl + '/assignment', + method: HttpMethod.GET, + }), + ); + }); + }); + describe('postNewJobAssignment', () => { + it('should successfully post new job assignment', async () => { + const details = jobAssignmentDetailsFixture; + const data = jobAssignmentDataFixture; + const matcher: RequestBodyMatcher = { + escrowAddress: data.escrow_address, + chainId: data.chain_id, + }; + nock(jobAssignmentOracleUrl).post('/assignment', matcher).reply(200); + await gateway.postNewJobAssignment(details); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: details.exchangeOracleUrl + '/assignment', + method: HttpMethod.POST, + }), + ); + }); + }); + + describe('fetchDiscoveredJobs', () => { + it('should successfully call get discovered jobs', async () => { + const details = jobsDiscoveryParamsDetailsFixture; + nock(jobAssignmentOracleUrl) + .get(`/assignment${paramsDataFixtureAsString}`) + .reply(200); + await gateway.fetchJobs(details); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: details.exchangeOracleUrl + '/job', + method: HttpMethod.GET, + }), + ); + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); +}); diff --git a/packages/apps/human-app/server/src/integrations/kv-store/kv-store-gateway.service.ts b/packages/apps/human-app/server/src/integrations/kv-store/kv-store-gateway.service.ts new file mode 100644 index 0000000000..4a3c3d8f26 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/kv-store/kv-store-gateway.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { EnvironmentConfigService } from '../../common/config/environment-config.service'; +import { ethers } from 'ethers'; +import { KVStoreClient, KVStoreKeys } from '@human-protocol/sdk'; + +@Injectable() +export class KvStoreGateway { + private kvStoreClient: KVStoreClient; + constructor(private environmentConfig: EnvironmentConfigService) {} + async onModuleInit(): Promise { + this.kvStoreClient = await KVStoreClient.build( + new ethers.JsonRpcProvider(this.environmentConfig.rpcUrl), + ); + } + async getExchangeOracleUrlByAddress(address: string): Promise { + return this.kvStoreClient.get(address, KVStoreKeys.url); + } +} diff --git a/packages/apps/human-app/server/src/integrations/kv-store/kv-store.module.ts b/packages/apps/human-app/server/src/integrations/kv-store/kv-store.module.ts new file mode 100644 index 0000000000..eab49e00e2 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/kv-store/kv-store.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { KvStoreGateway } from './kv-store-gateway.service'; + +@Module({ + providers: [KvStoreGateway], + exports: [KvStoreGateway], +}) +export class KvStoreModule {} diff --git a/packages/apps/human-app/server/src/integrations/kv-store/spec/kv-store.gateway.spec.ts b/packages/apps/human-app/server/src/integrations/kv-store/spec/kv-store.gateway.spec.ts new file mode 100644 index 0000000000..0405ddc762 --- /dev/null +++ b/packages/apps/human-app/server/src/integrations/kv-store/spec/kv-store.gateway.spec.ts @@ -0,0 +1,85 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { KVStoreClient, KVStoreKeys } from '@human-protocol/sdk'; +import { KvStoreGateway } from '../kv-store-gateway.service'; +import { EnvironmentConfigService } from '../../../common/config/environment-config.service'; +import { ethers } from 'ethers'; + +jest.mock('@human-protocol/sdk', () => { + const actualSdk = jest.requireActual('@human-protocol/sdk'); + return { + ...actualSdk, + KVStoreClient: { + build: jest.fn().mockImplementation(() => + Promise.resolve({ + get: jest.fn().mockResolvedValue('https://example.com'), + }), + ), + }, + }; +}); + +describe('KvStoreGateway', () => { + let service: KvStoreGateway; + let mockEnvironmentConfigService: EnvironmentConfigService; + let mockKVStoreClient: any; + + beforeEach(async () => { + mockEnvironmentConfigService = { + rpcUrl: 'https://localhost:8545', + } as any; + + mockKVStoreClient = await KVStoreClient.build( + new ethers.JsonRpcProvider('test'), + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KvStoreGateway, + { + provide: EnvironmentConfigService, + useValue: mockEnvironmentConfigService, + }, + { + provide: KVStoreClient, + useValue: mockKVStoreClient, + }, + ], + }).compile(); + service = module.get(KvStoreGateway); + await service.onModuleInit(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('onModuleInit', () => { + it('should initialize kvstoreClient', async () => { + const buildSpy = jest + .spyOn(KVStoreClient, 'build') + .mockResolvedValue(mockKVStoreClient); + await service.onModuleInit(); + expect(buildSpy).toHaveBeenCalledWith(expect.anything()); + expect(service['kvStoreClient']).toBe(mockKVStoreClient); + }); + }); + + describe('getExchangeOracleUrlByAddress', () => { + it('should get data from kvStoreClient', async () => { + const testAddress = 'testAddress'; + const expectedUrl = 'https://example.com'; + mockKVStoreClient.get.mockResolvedValue(expectedUrl); + const result = await service.getExchangeOracleUrlByAddress(testAddress); + + expect(service['kvStoreClient'].get).toHaveBeenCalledWith( + testAddress, + KVStoreKeys.url, + ); + + expect(result).toBe(expectedUrl); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index d833108da5..caec9f3dec 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { lastValueFrom } from 'rxjs'; import { InjectMapper } from '@automapper/nestjs'; @@ -6,23 +6,22 @@ import { Mapper } from '@automapper/core'; import { SignupWorkerCommand, SignupWorkerData, -} from '../../modules/user-worker/interfaces/worker-registration.interface'; +} from '../../modules/user-worker/model/worker-registration.model'; import { SignupOperatorCommand, SignupOperatorData, -} from '../../modules/user-operator/interfaces/operator-registration.interface'; +} from '../../modules/user-operator/model/operator-registration.model'; import { GatewayConfigService } from '../../common/config/gateway-config.service'; import { GatewayConfig } from '../../common/interfaces/endpoint.interface'; import { ExternalApiName } from '../../common/enums/external-api-name'; import { EndpointName } from '../../common/enums/endpoint-name'; import { AxiosRequestConfig } from 'axios'; -import { - RequestDataType -} from './reputation-oracle.interface'; +import { RequestDataType } from './reputation-oracle.interface'; import { SigninWorkerCommand, - SigninWorkerData, SigninWorkerResponse, -} from '../../modules/user-worker/interfaces/worker-signin.interface'; + SigninWorkerData, + SigninWorkerResponse, +} from '../../modules/user-worker/model/worker-signin.model'; @Injectable() export class ReputationOracleGateway { @@ -53,19 +52,8 @@ export class ReputationOracleGateway { private async handleRequestToReputationOracle( options: AxiosRequestConfig, ): Promise { - try { - const response = await lastValueFrom(this.httpService.request(options)); - return response.data; - } catch (error) { - if (error.response) { - throw new HttpException(error.response.data, error.response.status); - } else { - throw new HttpException( - 'Error occurred while redirecting request.', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } + const response = await lastValueFrom(this.httpService.request(options)); + return response.data; } async sendWorkerSignup(command: SignupWorkerCommand): Promise { const signupWorkerData = this.mapper.map( diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts index 2e5fa9bbb9..44060bb709 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts @@ -1,6 +1,6 @@ -import { SignupWorkerData } from '../../modules/user-worker/interfaces/worker-registration.interface'; -import { SignupOperatorData } from '../../modules/user-operator/interfaces/operator-registration.interface'; -import { SigninWorkerData } from '../../modules/user-worker/interfaces/worker-signin.interface'; +import { SignupWorkerData } from '../../modules/user-worker/model/worker-registration.model'; +import { SignupOperatorData } from '../../modules/user-operator/model/operator-registration.model'; +import { SigninWorkerData } from '../../modules/user-worker/model/worker-signin.model'; export type RequestDataType = | SignupWorkerData diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.ts index 2eefabab9b..bddb6a4a64 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.ts @@ -1,15 +1,26 @@ import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; import { Injectable } from '@nestjs/common'; -import { createMap, Mapper } from '@automapper/core'; +import { + CamelCaseNamingConvention, + createMap, + forMember, + mapFrom, + Mapper, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; import { SignupOperatorCommand, SignupOperatorData, -} from '../../modules/user-operator/interfaces/operator-registration.interface'; +} from '../../modules/user-operator/model/operator-registration.model'; import { SignupWorkerCommand, SignupWorkerData, -} from '../../modules/user-worker/interfaces/worker-registration.interface'; -import { SigninWorkerCommand, SigninWorkerData } from '../../modules/user-worker/interfaces/worker-signin.interface'; +} from '../../modules/user-worker/model/worker-registration.model'; +import { + SigninWorkerCommand, + SigninWorkerData, +} from '../../modules/user-worker/model/worker-signin.model'; @Injectable() export class ReputationOracleProfile extends AutomapperProfile { @@ -19,9 +30,33 @@ export class ReputationOracleProfile extends AutomapperProfile { override get profile() { return (mapper: Mapper) => { - createMap(mapper, SignupWorkerCommand, SignupWorkerData); + createMap( + mapper, + SignupWorkerCommand, + SignupWorkerData, + forMember( + (destination) => destination.h_captcha_token, + mapFrom((source) => source.hCaptchaToken), + ), + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); createMap(mapper, SignupOperatorCommand, SignupOperatorData); - createMap(mapper, SigninWorkerCommand, SigninWorkerData); + createMap( + mapper, + SigninWorkerCommand, + SigninWorkerData, + forMember( + (destination) => destination.h_captcha_token, + mapFrom((source) => source.hCaptchaToken), + ), + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts index 260c70ab2a..df76aa4aee 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts @@ -3,13 +3,13 @@ import { HttpService } from '@nestjs/axios'; import { GatewayConfigService } from '../../../common/config/gateway-config.service'; import { of, throwError } from 'rxjs'; import { ReputationOracleGateway } from '../reputation-oracle.gateway'; -import { SignupWorkerCommand } from '../../../modules/user-worker/interfaces/worker-registration.interface'; +import { SignupWorkerCommand } from '../../../modules/user-worker/model/worker-registration.model'; import nock from 'nock'; import { HttpException, HttpStatus } from '@nestjs/common'; -import { SignupOperatorCommand } from '../../../modules/user-operator/interfaces/operator-registration.interface'; +import { SignupOperatorCommand } from '../../../modules/user-operator/model/operator-registration.model'; import { gatewayConfigServiceMock } from '../../../common/config/gateway-config.service.mock'; import { ethers } from 'ethers'; -import { SigninWorkerCommand } from '../../../modules/user-worker/interfaces/worker-signin.interface'; +import { SigninWorkerCommand } from '../../../modules/user-worker/model/worker-signin.model'; describe('ReputationOracleGateway', () => { let service: ReputationOracleGateway; @@ -54,6 +54,7 @@ describe('ReputationOracleGateway', () => { const command = new SignupWorkerCommand( 'asfdsafdd@asdf.cvd', 'asdfasdf2133!!dasfA', + 'Bearer sadf234efaddasf234sadgv43rz89al', ); const expectedData = { email: 'asfdsafdd@asdf.cvd', @@ -68,35 +69,39 @@ describe('ReputationOracleGateway', () => { await expect(service.sendWorkerSignup(command)).resolves.not.toThrow(); expect(httpService.request).toHaveBeenCalled(); }); - it('should handle http error response correctly', async () => { - jest.spyOn(httpService, 'request').mockReturnValue( - throwError(() => ({ - response: { - data: { message: 'Bad request' }, - status: 400, - }, - })), - ); - - const command = new SignupWorkerCommand('', ''); + jest + .spyOn(httpService, 'request') + .mockReturnValue( + throwError( + () => + new HttpException( + { message: 'Bad request' }, + HttpStatus.BAD_REQUEST, + ), + ), + ); + + const command = new SignupWorkerCommand('', '', ''); await expect(service.sendWorkerSignup(command)).rejects.toThrow( - new HttpException({ message: 'Bad request' }, 400), + new HttpException({ message: 'Bad request' }, HttpStatus.BAD_REQUEST), ); }); + it('should handle network or unknown errors correctly', async () => { jest .spyOn(httpService, 'request') - .mockReturnValue(throwError(() => new Error('Network failure'))); + .mockReturnValue(throwError(() => new Error('Internal Server Error'))); const command = new SignupWorkerCommand( 'asfdsafdd@asdf.cvd', 'asdfasdf2133!!dasfA', + 'Bearer sadf234efaddasf234sadgv43rz89al', ); await expect(service.sendWorkerSignup(command)).rejects.toThrow( new HttpException( - 'Error occurred while redirecting request.', + 'Internal Server Error', HttpStatus.INTERNAL_SERVER_ERROR, ), ); @@ -135,10 +140,12 @@ describe('ReputationOracleGateway', () => { const command: SigninWorkerCommand = { email: 'johndoe@example.com', password: 's3cr3tP@ssw0rd', + hCaptchaToken: 'token', }; const expectedData = { email: 'johndoe@example.com', password: 's3cr3tP@ssw0rd', + h_captcha_token: 'token', }; nock('https://expample.com') @@ -150,18 +157,22 @@ describe('ReputationOracleGateway', () => { }); it('should handle http error response correctly', async () => { - jest.spyOn(httpService, 'request').mockReturnValue( - throwError(() => ({ - response: { - data: { message: 'Bad request' }, - status: 400, - }, - })), - ); + jest + .spyOn(httpService, 'request') + .mockReturnValue( + throwError( + () => + new HttpException( + { message: 'Bad request' }, + HttpStatus.BAD_REQUEST, + ), + ), + ); const command: SigninWorkerCommand = { email: '', password: '', + hCaptchaToken: '', }; await expect(service.sendWorkerSignin(command)).rejects.toThrow( new HttpException({ message: 'Bad request' }, 400), @@ -170,16 +181,17 @@ describe('ReputationOracleGateway', () => { it('should handle network or unknown errors correctly', async () => { jest .spyOn(httpService, 'request') - .mockReturnValue(throwError(() => new Error('Network failure'))); + .mockReturnValue(throwError(() => new Error('Internal Server Error'))); const command: SigninWorkerCommand = { email: 'johndoe@example.com', password: 's3cr3tP@ssw0rd', + hCaptchaToken: 'token', }; await expect(service.sendWorkerSignin(command)).rejects.toThrow( new HttpException( - 'Error occurred while redirecting request.', + 'Internal Server Error', HttpStatus.INTERNAL_SERVER_ERROR, ), ); diff --git a/packages/apps/human-app/server/src/main.ts b/packages/apps/human-app/server/src/main.ts index 7c11a30aa2..9eda2e0b99 100644 --- a/packages/apps/human-app/server/src/main.ts +++ b/packages/apps/human-app/server/src/main.ts @@ -4,6 +4,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; import { Logger } from '@nestjs/common'; import { EnvironmentConfigService } from './common/config/environment-config.service'; +import { GlobalExceptionsFilter } from './common/filter/global-exceptions.filter'; async function bootstrap() { const logger = new Logger('bootstrap'); @@ -11,7 +12,12 @@ async function bootstrap() { const configService: ConfigService = app.get(ConfigService); const envConfigService = new EnvironmentConfigService(configService); - + if (envConfigService.isCorsEnabled) { + app.enableCors({ + origin: envConfigService.corsEnabledOrigin, + allowedHeaders: envConfigService.corsAllowedHeaders, + }); + } const config = new DocumentBuilder() .addBearerAuth() .setTitle('Human APP API') @@ -24,6 +30,8 @@ async function bootstrap() { const host = envConfigService.host; const port = envConfigService.port; + app.useGlobalFilters(new GlobalExceptionsFilter()); + await app.listen(port, host, async () => { logger.log(`Human APP server is running on http://${host}:${port}`); }); diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts new file mode 100644 index 0000000000..b5565b152f --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Get, + Post, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { JobAssignmentService } from './job-assignment.service'; +import { + JobAssignmentDto, + JobAssignmentCommand, + JobAssignmentResponse, + JobsFetchParamsDto, + JobsFetchParamsCommand, + JobsFetchResponse, +} from './model/job-assignment.model'; +import { Authorization } from '../../common/config/params-decorators'; + +@Controller() +export class JobAssignmentController { + constructor( + private readonly jobAssignmentService: JobAssignmentService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiTags('Job-Assignment') + @Post('/assignment/job') + @ApiOperation({ + summary: 'Request to assign a job to a logged user', + }) + @ApiBearerAuth() + @UsePipes(new ValidationPipe()) + public async assignJob( + @Body() jobAssignmentDto: JobAssignmentDto, + @Authorization() token: string, + ): Promise { + const jobAssignmentCommand = this.mapper.map( + jobAssignmentDto, + JobAssignmentDto, + JobAssignmentCommand, + ); + jobAssignmentCommand.token = token; + return this.jobAssignmentService.processJobAssignment(jobAssignmentCommand); + } + + @ApiTags('Job-Assignment') + @Get('/assignment/job') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Request to get a jobs assigned to a logged user', + }) + public async getAssignedJobs( + @Query() jobsAssignmentParamsDto: JobsFetchParamsDto, + @Authorization() token: string, + ): Promise { + const jobsAssignmentParamsCommand = this.mapper.map( + jobsAssignmentParamsDto, + JobsFetchParamsDto, + JobsFetchParamsCommand, + ); + jobsAssignmentParamsCommand.token = token; + return this.jobAssignmentService.processGetAssignedJobs( + jobsAssignmentParamsCommand, + ); + } +} diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.mapper.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.mapper.ts new file mode 100644 index 0000000000..7b09672321 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.mapper.ts @@ -0,0 +1,91 @@ +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + CamelCaseNamingConvention, + createMap, + forMember, + mapFrom, + Mapper, + mapWith, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; +import { + JobAssignmentCommand, + JobAssignmentDetails, + JobAssignmentDto, + JobAssignmentParams, + JobsFetchParams, + JobsFetchParamsCommand, + JobsFetchParamsDetails, + JobsFetchParamsDto, +} from './model/job-assignment.model'; + +@Injectable() +export class JobAssignmentProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + JobAssignmentDto, + JobAssignmentParams, + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap( + mapper, + JobAssignmentDto, + JobAssignmentCommand, + forMember( + (destination) => destination.data, + mapWith(JobAssignmentParams, JobAssignmentDto, (source) => source), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap( + mapper, + JobsFetchParamsDto, + JobsFetchParams, + // forMember usage cause: https://github.com/nartc/mapper/issues/583 + forMember( + (destination) => destination.pageSize, + mapFrom((source: JobsFetchParamsDto) => source.page_size), + ), + forMember( + (destination) => destination.sortField, + mapFrom((source: JobsFetchParamsDto) => source.sort_field), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap( + mapper, + JobsFetchParamsDto, + JobsFetchParamsCommand, + forMember( + (destination) => destination.data, + mapFrom((source: JobsFetchParamsDto) => + mapper.map(source, JobsFetchParamsDto, JobsFetchParams), + ), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap(mapper, JobAssignmentCommand, JobAssignmentDetails); + createMap(mapper, JobsFetchParamsCommand, JobsFetchParamsDetails); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.module.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.module.ts new file mode 100644 index 0000000000..3c1d6d22e5 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.module.ts @@ -0,0 +1,13 @@ +import { JobAssignmentService } from './job-assignment.service'; +import { JobAssignmentProfile } from './job-assignment.mapper'; +import { Module } from '@nestjs/common'; +import { ExchangeOracleModule } from '../../integrations/exchange-oracle/exchange-oracle.module'; +import { KvStoreModule } from '../../integrations/kv-store/kv-store.module'; +import { EscrowUtilsModule } from '../../integrations/escrow/escrow-utils.module'; + +@Module({ + imports: [ExchangeOracleModule, KvStoreModule, EscrowUtilsModule], + providers: [JobAssignmentService, JobAssignmentProfile], + exports: [JobAssignmentService], +}) +export class JobAssignmentModule {} diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts new file mode 100644 index 0000000000..e8a85502fd --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { + JobsFetchParamsCommand, + JobAssignmentResponse, + JobAssignmentCommand, + JobsFetchResponse, + JobAssignmentDetails, + JobsFetchParamsDetails, +} from './model/job-assignment.model'; +import { ExchangeOracleGateway } from '../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { KvStoreGateway } from '../../integrations/kv-store/kv-store-gateway.service'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { EscrowUtilsGateway } from '../../integrations/escrow/escrow-utils-gateway.service'; +@Injectable() +export class JobAssignmentService { + constructor( + private readonly kvStoreGateway: KvStoreGateway, + private readonly exchangeOracleGateway: ExchangeOracleGateway, + private readonly escrowUtilsGateway: EscrowUtilsGateway, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + async processJobAssignment( + command: JobAssignmentCommand, + ): Promise { + const exchangeOracleAddress = + await this.escrowUtilsGateway.getExchangeOracleAddressByEscrowAddress( + command.data.chainId, + command.data.escrowAddress, + ); + const exchangeOracleUrl = + await this.kvStoreGateway.getExchangeOracleUrlByAddress( + exchangeOracleAddress, + ); + const details = this.mapper.map( + command, + JobAssignmentCommand, + JobAssignmentDetails, + ); + details.exchangeOracleUrl = exchangeOracleUrl; + return this.exchangeOracleGateway.postNewJobAssignment(details); + } + + async processGetAssignedJobs( + command: JobsFetchParamsCommand, + ): Promise { + const exchangeOracleUrl = + await this.kvStoreGateway.getExchangeOracleUrlByAddress(command.address); + const details = this.mapper.map( + command, + JobsFetchParamsCommand, + JobsFetchParamsDetails, + ); + details.exchangeOracleUrl = exchangeOracleUrl; + return this.exchangeOracleGateway.fetchAssignedJobs(details); + } +} diff --git a/packages/apps/human-app/server/src/modules/job-assignment/model/job-assignment.model.ts b/packages/apps/human-app/server/src/modules/job-assignment/model/job-assignment.model.ts new file mode 100644 index 0000000000..3b97052f1e --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/model/job-assignment.model.ts @@ -0,0 +1,167 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AutoMap } from '@automapper/classes'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { + PageableData, + PageableDto, + PageableParams, +} from '../../../common/utils/pageable.model'; +import { + AssignmentSortField, + AssignmentStatus, +} from '../../../common/enums/global-common'; +import { Type } from 'class-transformer'; + +export class JobAssignmentDto { + @AutoMap() + @IsString() + @ApiProperty() + escrow_address: string; + @AutoMap() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ default: 80002 }) + chain_id: number; +} + +export class JobAssignmentParams { + @AutoMap() + chainId: number; + @AutoMap() + escrowAddress: string; +} +export class JobAssignmentCommand { + @AutoMap() + data: JobAssignmentParams; + @AutoMap() + token: string; +} + +export class JobAssignmentDetails { + @AutoMap() + data: JobAssignmentParams; + @AutoMap() + token: string; + exchangeOracleUrl: string; +} +export class JobAssignmentData { + @AutoMap() + escrow_address: string; + @AutoMap() + chain_id: number; +} + +export class JobAssignmentResponse { + assignment_id: string; + escrow_address: string; + chain_id: number; + job_type: string; + url?: string; //Only for ACTIVE status + status: string; + reward_amount: string; + reward_token: string; + created_at: string; + updated_at?: string; //Only for COMPLETED, EXPIRED, CANCELED and REJECTED status + expires_at: string; +} + +export class JobsFetchParamsDto extends PageableDto { + @AutoMap() + @ApiProperty() + address: string; + @AutoMap() + @Type(() => Number) + @IsOptional() + @IsNumber() + @ApiPropertyOptional() + assignment_id: number; + @IsOptional() + @AutoMap() + @IsString() + @ApiPropertyOptional() + escrow_address: string; + @AutoMap() + @IsNumber() + @IsOptional() + @Type(() => Number) + @ApiPropertyOptional({ default: 80002 }) + chain_id: number; + @AutoMap() + @IsString() + @IsOptional() + @ApiPropertyOptional() + job_type: string; + @AutoMap() + @IsOptional() + @IsEnum(AssignmentStatus) + @ApiPropertyOptional({ enum: AssignmentStatus }) + status: AssignmentStatus; + @AutoMap() + @IsOptional() + @IsEnum(AssignmentSortField, { each: true }) + @ApiPropertyOptional({ enum: AssignmentSortField, isArray: true }) + sort_field: AssignmentSortField; +} + +export class JobsFetchParams extends PageableParams { + @AutoMap() + escrowAddress: string; + @AutoMap() + chainId: number; + @AutoMap() + jobType: string; + @AutoMap() + status: AssignmentStatus; + @AutoMap() + sortField: AssignmentSortField; + @AutoMap() + assignmentId: number; +} +export class JobsFetchParamsCommand { + @AutoMap() + address: string; + @AutoMap() + data: JobsFetchParams; + @AutoMap() + token: string; +} +export class JobsFetchParamsDetails { + exchangeOracleUrl: string; + @AutoMap() + data: JobsFetchParams; + @AutoMap() + token: string; +} + +export class JobsFetchParamsData extends PageableData { + @AutoMap() + escrow_address: string; + @AutoMap() + assignment_id: number; + @AutoMap() + chain_id: number; + @AutoMap() + job_type: string; + @AutoMap() + status: AssignmentStatus; + @AutoMap() + sort_field: AssignmentSortField; +} + +export class JobsFetchResponseItem { + assignment_id: string; + escrow_address: string; + chain_id: number; + job_type: string; + url?: string; //Only for ACTIVE status + status: string; + reward_amount: string; + reward_token: string; + created_at: string; + updated_at: string; //Only for VALIDATION, COMPLETED, EXPIRED, CANCELED and REJECTED status + expires_at: string; +} + +export class JobsFetchResponse { + data: JobsFetchResponseItem[]; +} diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts new file mode 100644 index 0000000000..b65f39996b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts @@ -0,0 +1,97 @@ +import { JobAssignmentService } from '../job-assignment.service'; +import { JobAssignmentController } from '../job-assignment.controller'; +import { Test, TestingModule } from '@nestjs/testing'; +import { jobAssignmentServiceMock } from './job-assignment.service.mock'; +import { + JobAssignmentCommand, + JobAssignmentDto, + JobsFetchParamsCommand, + JobsFetchParamsDto, +} from '../model/job-assignment.model'; +import { + jobAssignmentDtoFixture, + jobAssignmentCommandFixture, + jobAssignmentResponseFixture, + jobsFetchParamsDtoFixture, + jobsFetchParamsCommandFixture, + jobsFetchResponseFixture, + jobAssignmentToken, +} from './job-assignment.fixtures'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import { JobAssignmentProfile } from '../job-assignment.mapper'; +import { HttpService } from '@nestjs/axios'; + +const httpServiceMock = { + request: jest.fn().mockImplementation((options) => { + if (options.url.includes('processGetAssignedJobs')) { + return Promise.resolve({ data: jobsFetchResponseFixture }); + } else if (options.url.includes('processJobAssignment')) { + return Promise.resolve({ data: jobAssignmentResponseFixture }); + } + }), +}; + +describe('JobAssignmentController', () => { + let controller: JobAssignmentController; + let jobAssignmentService: JobAssignmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [JobAssignmentController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + JobAssignmentService, + JobAssignmentProfile, + { + provide: HttpService, + useValue: httpServiceMock, + }, + ], + }) + .overrideProvider(JobAssignmentService) + .useValue(jobAssignmentServiceMock) + .compile(); + + controller = module.get(JobAssignmentController); + jobAssignmentService = + module.get(JobAssignmentService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('jobAssignmentDiscovery', () => { + it('should call service processJobAssignment method with proper fields set', async () => { + const dto: JobAssignmentDto = jobAssignmentDtoFixture; + const command: JobAssignmentCommand = jobAssignmentCommandFixture; + await controller.assignJob(dto, jobAssignmentToken); + expect(jobAssignmentService.processJobAssignment).toHaveBeenCalledWith( + command, + ); + }); + + it('should return the result of service processJobAssignment method', async () => { + const dto: JobAssignmentDto = jobAssignmentDtoFixture; + const command: JobAssignmentCommand = jobAssignmentCommandFixture; + const result = await controller.assignJob(dto, jobAssignmentToken); + expect(result).toEqual( + jobAssignmentServiceMock.processJobAssignment(command), + ); + }); + + it('should call service processGetAssignedJobs method with proper fields set', async () => { + const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; + const command: JobsFetchParamsCommand = jobsFetchParamsCommandFixture; + await controller.getAssignedJobs(dto, jobAssignmentToken); + expect(jobAssignmentService.processGetAssignedJobs).toHaveBeenCalledWith( + command, + ); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.fixtures.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.fixtures.ts new file mode 100644 index 0000000000..4cc7266e08 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.fixtures.ts @@ -0,0 +1,143 @@ +import { + JobAssignmentCommand, + JobAssignmentData, + JobAssignmentDetails, + JobAssignmentDto, + JobAssignmentParams, + JobAssignmentResponse, + JobsFetchParams, + JobsFetchParamsCommand, + JobsFetchParamsData, + JobsFetchParamsDetails, + JobsFetchParamsDto, + JobsFetchResponse, + JobsFetchResponseItem, +} from '../model/job-assignment.model'; +import { + AssignmentSortField, + AssignmentStatus, + SortOrder, +} from '../../../common/enums/global-common'; +const EXCHANGE_ORACLE_URL = 'https://www.example.com/api'; +const EXCHANGE_ORACLE_ADDRESS = '0x34df642'; +const ESCROW_ADDRESS = 'test_address'; +const CHAIN_ID = 1; +const JOB_TYPE = 'FORTUNE'; +const STATUS = AssignmentStatus.ACTIVE; +const PAGE_SIZE = 5; +const PAGE = 0; +const SORT = SortOrder.ASC; +const SORT_FIELD = AssignmentSortField.CREATED_AT; +const ASSIGNMENT_ID = 'test_id'; +const REWARD_AMOUNT = 'test_amount'; +const REWARD_TOKEN = 'test'; +const CREATED_AT = 'test_date_1'; +const UPDATED_AT = 'test_date_2'; +const EXPIRES_AT = 'test_date_3'; +const JOB_ASSIGNMENT_ID = 1; +const URL = 'test_url'; +const TOKEN = 'test_user_token'; +export const jobAssignmentToken = TOKEN; +export const jobAssignmentOracleUrl = EXCHANGE_ORACLE_URL; +export const jobAssignmentDtoFixture: JobAssignmentDto = { + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, +}; + +const jobAssignmentParams: JobAssignmentParams = { + chainId: CHAIN_ID, + escrowAddress: ESCROW_ADDRESS, +}; +export const jobAssignmentCommandFixture: JobAssignmentCommand = { + data: jobAssignmentParams, + token: TOKEN, +}; +export const jobAssignmentDetailsFixture: JobAssignmentDetails = { + data: jobAssignmentParams, + token: TOKEN, + exchangeOracleUrl: EXCHANGE_ORACLE_URL, +}; +export const jobAssignmentDataFixture: JobAssignmentData = { + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, +}; + +export const jobAssignmentResponseFixture: JobAssignmentResponse = { + assignment_id: ASSIGNMENT_ID, + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, + job_type: JOB_TYPE, + url: URL, + status: STATUS, + reward_amount: REWARD_AMOUNT, + reward_token: REWARD_TOKEN, + created_at: CREATED_AT, + updated_at: UPDATED_AT, + expires_at: EXPIRES_AT, +}; + +export const jobsFetchParamsDtoFixture: JobsFetchParamsDto = { + address: EXCHANGE_ORACLE_ADDRESS, + escrow_address: ESCROW_ADDRESS, + assignment_id: JOB_ASSIGNMENT_ID, + chain_id: CHAIN_ID, + job_type: JOB_TYPE, + status: STATUS, + page_size: PAGE_SIZE, + page: PAGE, + sort: SORT, + sort_field: SORT_FIELD, +}; + +const jobsFetchParams: JobsFetchParams = { + escrowAddress: ESCROW_ADDRESS, + assignmentId: JOB_ASSIGNMENT_ID, + chainId: CHAIN_ID, + jobType: JOB_TYPE, + status: STATUS, + pageSize: PAGE_SIZE, + page: PAGE, + sort: SORT, + sortField: SORT_FIELD, +}; +export const jobsFetchParamsCommandFixture: JobsFetchParamsCommand = { + data: jobsFetchParams, + address: EXCHANGE_ORACLE_ADDRESS, + token: TOKEN, +}; +export const jobsFetchParamsDetailsFixture: JobsFetchParamsDetails = { + data: jobsFetchParams, + exchangeOracleUrl: EXCHANGE_ORACLE_URL, + token: TOKEN, +}; + +export const jobsFetchParamsDataFixture: JobsFetchParamsData = { + escrow_address: ESCROW_ADDRESS, + assignment_id: JOB_ASSIGNMENT_ID, + chain_id: CHAIN_ID, + job_type: JOB_TYPE, + status: STATUS, + page_size: PAGE_SIZE, + page: PAGE, + sort: SORT, + sort_field: SORT_FIELD, +}; + +export const jobsFetchParamsDataFixtureAsString = `assignment_id=${ASSIGNMENT_ID}&escrow_address=${ESCROW_ADDRESS}&chain_id=${CHAIN_ID}&job_type=${JOB_TYPE}&status=${STATUS}&page_size=${PAGE_SIZE}&page=${PAGE}&sort=${SORT}&sort_field=${SORT_FIELD}`; +export const jobsFetchResponseItemFixture: JobsFetchResponseItem = { + assignment_id: ASSIGNMENT_ID, + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, + job_type: JOB_TYPE, + url: URL, + status: STATUS, + reward_amount: REWARD_AMOUNT, + reward_token: REWARD_TOKEN, + created_at: CREATED_AT, + updated_at: UPDATED_AT, + expires_at: EXPIRES_AT, +}; + +export const jobsFetchResponseFixture: JobsFetchResponse = { + data: [jobsFetchResponseItemFixture], +}; diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.service.mock.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.service.mock.ts new file mode 100644 index 0000000000..20c8b4fc99 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.service.mock.ts @@ -0,0 +1,9 @@ +import { + jobAssignmentResponseFixture, + jobsFetchResponseFixture, +} from './job-assignment.fixtures'; + +export const jobAssignmentServiceMock = { + processJobAssignment: jest.fn().mockReturnValue(jobAssignmentResponseFixture), + processGetAssignedJobs: jest.fn().mockReturnValue(jobsFetchResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.service.spec.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.service.spec.ts new file mode 100644 index 0000000000..4cbc2987de --- /dev/null +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.service.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JobAssignmentService } from '../job-assignment.service'; +import { KvStoreGateway } from '../../../integrations/kv-store/kv-store-gateway.service'; +import { ExchangeOracleGateway } from '../../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { + jobAssignmentCommandFixture, + jobAssignmentDetailsFixture, + jobAssignmentOracleUrl, + jobsFetchParamsCommandFixture, + jobsFetchParamsDetailsFixture, +} from './job-assignment.fixtures'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import { JobAssignmentProfile } from '../job-assignment.mapper'; +import { EscrowUtilsGateway } from '../../../integrations/escrow/escrow-utils-gateway.service'; + +describe('JobAssignmentService', () => { + let service: JobAssignmentService; + let exchangeOracleGatewayMock: Partial; + let kvStoreGatewayMock: Partial; + let escrowUtilsGatewayMock: Partial; + beforeEach(async () => { + exchangeOracleGatewayMock = { + postNewJobAssignment: jest.fn(), + fetchAssignedJobs: jest.fn(), + }; + kvStoreGatewayMock = { + getExchangeOracleUrlByAddress: jest.fn(), + }; + escrowUtilsGatewayMock = { + getExchangeOracleAddressByEscrowAddress: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + JobAssignmentService, + JobAssignmentProfile, + { provide: ExchangeOracleGateway, useValue: exchangeOracleGatewayMock }, + { provide: KvStoreGateway, useValue: kvStoreGatewayMock }, + { provide: EscrowUtilsGateway, useValue: escrowUtilsGatewayMock }, + ], + }).compile(); + + service = module.get(JobAssignmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('processJobAssignment', () => { + it('should process job assignment correctly', async () => { + const escrowUtilExchangeOracleAddress = '0x'; + const command = jobAssignmentCommandFixture; + const details = jobAssignmentDetailsFixture; + ( + escrowUtilsGatewayMock.getExchangeOracleAddressByEscrowAddress as jest.Mock + ).mockResolvedValue(escrowUtilExchangeOracleAddress); + ( + kvStoreGatewayMock.getExchangeOracleUrlByAddress as jest.Mock + ).mockResolvedValue(jobAssignmentOracleUrl); + ( + exchangeOracleGatewayMock.postNewJobAssignment as jest.Mock + ).mockResolvedValue({ + assignment_id: '123', + }); + + const result = await service.processJobAssignment(command); + + expect( + escrowUtilsGatewayMock.getExchangeOracleAddressByEscrowAddress, + ).toHaveBeenCalledWith(command.data.chainId, command.data.escrowAddress); + expect( + kvStoreGatewayMock.getExchangeOracleUrlByAddress, + ).toHaveBeenCalledWith(escrowUtilExchangeOracleAddress); + expect( + exchangeOracleGatewayMock.postNewJobAssignment, + ).toHaveBeenCalledWith(details); + expect(result).toEqual({ assignment_id: '123' }); + }); + }); + + describe('processGetAssignedJobs', () => { + it('should fetch assigned jobs correctly', async () => { + const command = jobsFetchParamsCommandFixture; + const details = jobsFetchParamsDetailsFixture; + + ( + kvStoreGatewayMock.getExchangeOracleUrlByAddress as jest.Mock + ).mockResolvedValue(jobAssignmentOracleUrl); + ( + exchangeOracleGatewayMock.fetchAssignedJobs as jest.Mock + ).mockResolvedValue({ + data: [], + }); + + const result = await service.processGetAssignedJobs(command); + + expect( + kvStoreGatewayMock.getExchangeOracleUrlByAddress, + ).toHaveBeenCalledWith(command.address); + expect(exchangeOracleGatewayMock.fetchAssignedJobs).toHaveBeenCalledWith( + details, + ); + expect(result).toEqual({ data: [] }); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts new file mode 100644 index 0000000000..a0016472ee --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts @@ -0,0 +1,46 @@ +import { + Controller, + Get, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { JobsDiscoveryService } from './jobs-discovery.service'; +import { + JobsDiscoveryParamsCommand, + JobsDiscoveryParamsDto, + JobsDiscoveryResponse, +} from './model/jobs-discovery.model'; +import { Authorization } from '../../common/config/params-decorators'; + +@Controller() +export class JobsDiscoveryController { + constructor( + private readonly jobsDiscoveryService: JobsDiscoveryService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiTags('Jobs-Discovery') + @Get('/jobs') + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Retrieve a list of filtered available jobs for passed Exchange Oracle url', + }) + public async getJobs( + @Query() jobsDiscoveryParamsDto: JobsDiscoveryParamsDto, + @Authorization() token: string, + ): Promise { + const jobsDiscoveryParamsCommand: JobsDiscoveryParamsCommand = + this.mapper.map( + jobsDiscoveryParamsDto, + JobsDiscoveryParamsDto, + JobsDiscoveryParamsCommand, + ); + jobsDiscoveryParamsCommand.token = token; + return await this.jobsDiscoveryService.processJobsDiscovery( + jobsDiscoveryParamsCommand, + ); + } +} diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.mapper.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.mapper.ts new file mode 100644 index 0000000000..99ad07f1ce --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.mapper.ts @@ -0,0 +1,67 @@ +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + CamelCaseNamingConvention, + createMap, + forMember, + mapFrom, + Mapper, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; +import { + JobsDiscoveryParams, + JobsDiscoveryParamsCommand, JobsDiscoveryParamsDetails, + JobsDiscoveryParamsDto, +} from './model/jobs-discovery.model'; + +@Injectable() +export class JobsDiscoveryProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + JobsDiscoveryParamsDto, + JobsDiscoveryParams, + // forMember usage cause: https://github.com/nartc/mapper/issues/583 + forMember( + (destination) => destination.pageSize, + mapFrom((source) => source.page_size), + ), + forMember( + (destination) => destination.sortField, + mapFrom((source) => source.sort_field), + ), + // Automapper has problem with mapping arrays, thus explicit conversion + forMember( + (destination) => destination.fields, + mapFrom((source) => source.fields), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap( + mapper, + JobsDiscoveryParamsDto, + JobsDiscoveryParamsCommand, + forMember( + (destination) => destination.data, + mapFrom((source: JobsDiscoveryParamsDto) => + mapper.map(source, JobsDiscoveryParamsDto, JobsDiscoveryParams), + ), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap(mapper, JobsDiscoveryParamsCommand, JobsDiscoveryParamsDetails); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.module.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.module.ts new file mode 100644 index 0000000000..40c81628a5 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.module.ts @@ -0,0 +1,12 @@ +import { JobsDiscoveryService } from './jobs-discovery.service'; +import { JobsDiscoveryProfile } from './jobs-discovery.mapper'; +import { Module } from '@nestjs/common'; +import { ExchangeOracleModule } from '../../integrations/exchange-oracle/exchange-oracle.module'; +import { KvStoreModule } from '../../integrations/kv-store/kv-store.module'; + +@Module({ + imports: [ExchangeOracleModule, KvStoreModule], + providers: [JobsDiscoveryService, JobsDiscoveryProfile], + exports: [JobsDiscoveryService], +}) +export class JobsDiscoveryModule {} diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts new file mode 100644 index 0000000000..4e6fa317ff --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { + JobsDiscoveryParamsCommand, + JobsDiscoveryParamsDetails, + JobsDiscoveryResponse, +} from './model/jobs-discovery.model'; +import { ExchangeOracleGateway } from '../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { KvStoreGateway } from '../../integrations/kv-store/kv-store-gateway.service'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +@Injectable() +export class JobsDiscoveryService { + constructor( + private readonly kvStoreGateway: KvStoreGateway, + @InjectMapper() private mapper: Mapper, + private readonly exchangeOracleGateway: ExchangeOracleGateway, + ) {} + + async processJobsDiscovery( + command: JobsDiscoveryParamsCommand, + ): Promise { + const exchangeOracleUrl = + await this.kvStoreGateway.getExchangeOracleUrlByAddress(command.oracleAddress); + const details = this.mapper.map( + command, + JobsDiscoveryParamsCommand, + JobsDiscoveryParamsDetails, + ); + details.exchangeOracleUrl = exchangeOracleUrl; + return this.exchangeOracleGateway.fetchJobs(details); + } +} diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts new file mode 100644 index 0000000000..7382b299dc --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts @@ -0,0 +1,110 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AutoMap } from '@automapper/classes'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + JobDiscoveryFieldName, + JobDiscoverySortField, + JobStatus, +} from '../../../common/enums/global-common'; +import { + PageableData, + PageableDto, + PageableParams, +} from '../../../common/utils/pageable.model'; + +export class JobsDiscoveryParamsDto extends PageableDto { + @AutoMap() + @IsString() + @ApiProperty() + oracle_address?: string; + @AutoMap() + @IsOptional() + @IsString() + @ApiPropertyOptional() + escrow_address?: string; + @AutoMap() + @Type(() => Number) + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + chain_id?: number; + @AutoMap() + @IsEnum(JobDiscoverySortField) + @ApiPropertyOptional({ enum: JobDiscoverySortField }) + sort_field?: JobDiscoverySortField; + @AutoMap() + @IsString() + @ApiPropertyOptional() + job_type?: string; + @AutoMap() + @IsOptional() + @IsEnum(JobDiscoveryFieldName, { each: true }) + @ApiPropertyOptional({ enum: JobDiscoveryFieldName, isArray: true }) + fields: JobDiscoveryFieldName[]; + @AutoMap() + @ApiPropertyOptional({ enum: JobStatus }) + @IsEnum(JobStatus) + @IsOptional() + status: JobStatus; +} + +export class JobsDiscoveryParams extends PageableParams { + @AutoMap() + escrowAddress?: string; + @AutoMap() + chainId?: number; + @AutoMap() + sortField?: JobDiscoverySortField; + @AutoMap() + jobType?: string; + @AutoMap() + fields: JobDiscoveryFieldName[]; + @AutoMap() + status: JobStatus; +} +export class JobsDiscoveryParamsData extends PageableData { + @AutoMap() + escrow_address?: string; + @AutoMap() + chain_id?: number; + @AutoMap() + sort_field?: JobDiscoverySortField; + @AutoMap() + job_type?: string; + @AutoMap() + fields: JobDiscoveryFieldName[]; + @AutoMap() + status: JobStatus; +} +export class JobsDiscoveryParamsCommand { + @AutoMap() + oracleAddress: string; + @AutoMap() + token: string; + @AutoMap() + data: JobsDiscoveryParams; +} + +export class JobsDiscoveryParamsDetails { + exchangeOracleUrl: string; + @AutoMap() + token: string; + @AutoMap() + data: JobsDiscoveryParams; +} + +export class JobsDiscoveryResponseItem { + escrow_address: string; + chain_id: number; + job_type: string; + job_title: string; + job_description: string; + reward_amount: string; + reward_token: string; + created_at: string; +} + +export class JobsDiscoveryResponse { + data: JobsDiscoveryResponseItem[]; +} diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts new file mode 100644 index 0000000000..1895760a4a --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts @@ -0,0 +1,66 @@ +import { JobsDiscoveryService } from '../jobs-discovery.service'; +import { JobsDiscoveryController } from '../jobs-discovery.controller'; +import { Test, TestingModule } from '@nestjs/testing'; +import { jobsDiscoveryServiceMock } from './jobs-discovery.service.mock'; +import { + jobsDiscoveryParamsCommandFixture, + dtoFixture, + jobDiscoveryToken, + responseFixture, +} from './jobs-discovery.fixtures'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import { JobsDiscoveryProfile } from '../jobs-discovery.mapper'; +import { HttpService } from '@nestjs/axios'; + +describe('JobsDiscoveryController', () => { + let controller: JobsDiscoveryController; + let jobsDiscoveryService: JobsDiscoveryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [JobsDiscoveryController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + JobsDiscoveryService, + JobsDiscoveryProfile, + { + provide: HttpService, + useValue: { + request: jest + .fn() + .mockImplementation(() => + Promise.resolve({ data: responseFixture }), + ), + }, + }, + ], + }) + .overrideProvider(JobsDiscoveryService) + .useValue(jobsDiscoveryServiceMock) + .compile(); + + controller = module.get(JobsDiscoveryController); + jobsDiscoveryService = + module.get(JobsDiscoveryService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('processJobsDiscovery', () => { + it('should call service processJobsDiscovery method with proper fields set', async () => { + const dto = dtoFixture; + const command = jobsDiscoveryParamsCommandFixture; + await controller.getJobs(dto, jobDiscoveryToken); + expect(jobsDiscoveryService.processJobsDiscovery).toHaveBeenCalledWith( + command, + ); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts new file mode 100644 index 0000000000..7def3e6c04 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts @@ -0,0 +1,96 @@ +import { + JobsDiscoveryParams, + JobsDiscoveryParamsCommand, + JobsDiscoveryParamsData, + JobsDiscoveryParamsDetails, + JobsDiscoveryParamsDto, + JobsDiscoveryResponseItem, +} from '../model/jobs-discovery.model'; +import { + JobDiscoveryFieldName, + JobDiscoverySortField, + JobStatus, + SortOrder, +} from '../../../common/enums/global-common'; +const EXCHANGE_ORACLE_URL = 'https://www.test_url.org'; +const ESCROW_ADDRESS = 'test_address'; +const CHAIN_ID = 1; +const PAGE_SIZE = 10; +const PAGE = 1; +const SORT = SortOrder.ASC; +const SORT_FIELD = JobDiscoverySortField.CREATED_AT; +const JOB_TYPE = 'FORTUNE'; +const FIELDS = [ + JobDiscoveryFieldName.CreatedAt, + JobDiscoveryFieldName.JobDescription, +]; +const TOKEN = 'test-token'; +const JOB_DESCRIPTION = 'Description of the test job'; +const REWARD_AMOUNT = '100'; +const REWARD_TOKEN = 'ETH'; +const CREATED_AT = '2024-03-01T12:00:00Z'; +const JOB_TITLE = 'test job'; +const EXCHANGE_ORACLE_ADDRESS = '0x3dfa342'; +const STATUS = JobStatus.ACTIVE; +export const jobsDiscoveryOracleUrlFixture = EXCHANGE_ORACLE_URL; +export const jobDiscoveryToken = TOKEN; +export const dtoFixture: JobsDiscoveryParamsDto = { + oracle_address: EXCHANGE_ORACLE_ADDRESS, + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, + page_size: PAGE_SIZE, + page: PAGE, + sort: SORT, + sort_field: SORT_FIELD, + job_type: JOB_TYPE, + fields: FIELDS, + status: STATUS, +}; + +const dataFixture: JobsDiscoveryParams = { + escrowAddress: ESCROW_ADDRESS, + chainId: CHAIN_ID, + pageSize: PAGE_SIZE, + page: PAGE, + sort: SORT, + sortField: SORT_FIELD, + jobType: JOB_TYPE, + fields: FIELDS, + status: STATUS, +}; +const paramsDataFixture: JobsDiscoveryParamsData = { + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, + page_size: PAGE_SIZE, + page: PAGE, + sort: SORT, + sort_field: SORT_FIELD, + job_type: JOB_TYPE, + fields: FIELDS, + status: STATUS, +}; +export const paramsDataFixtureAsString = `?escrow_address=${paramsDataFixture.escrow_address}&chain_id=${paramsDataFixture.chain_id}&page_size=${paramsDataFixture.page_size}&page=${paramsDataFixture.page}&sort=${paramsDataFixture.sort}&sort_field=${paramsDataFixture.sort_field}&job_type=${paramsDataFixture.job_type}&fields=${paramsDataFixture.fields.join(',')}`; +export const jobsDiscoveryParamsCommandFixture: JobsDiscoveryParamsCommand = { + data: dataFixture, + oracleAddress: EXCHANGE_ORACLE_ADDRESS, + token: TOKEN, +}; +export const jobsDiscoveryParamsDetailsFixture: JobsDiscoveryParamsDetails = { + data: dataFixture, + exchangeOracleUrl: EXCHANGE_ORACLE_URL, + token: TOKEN, +}; +export const responseItemFixture: JobsDiscoveryResponseItem = { + escrow_address: ESCROW_ADDRESS, + chain_id: CHAIN_ID, + job_type: JOB_TYPE, + job_title: JOB_TITLE, + job_description: JOB_DESCRIPTION, + reward_amount: REWARD_AMOUNT, + reward_token: REWARD_TOKEN, + created_at: CREATED_AT, +}; + +export const responseFixture: JobsDiscoveryResponseItem[] = [ + responseItemFixture, +]; diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.service.mock.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.service.mock.ts new file mode 100644 index 0000000000..b16d952239 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.service.mock.ts @@ -0,0 +1,5 @@ +import { responseFixture } from './jobs-discovery.fixtures'; + +export const jobsDiscoveryServiceMock = { + processJobsDiscovery: jest.fn().mockReturnValue(responseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.service.spec.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.service.spec.ts new file mode 100644 index 0000000000..0c0d5be161 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.service.spec.ts @@ -0,0 +1,66 @@ +import { JobsDiscoveryService } from '../jobs-discovery.service'; +import { KvStoreGateway } from '../../../integrations/kv-store/kv-store-gateway.service'; +import { ExchangeOracleGateway } from '../../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import { JobsDiscoveryProfile } from '../jobs-discovery.mapper'; +import { + jobsDiscoveryOracleUrlFixture, + jobsDiscoveryParamsCommandFixture, + jobsDiscoveryParamsDetailsFixture, + responseFixture, +} from './jobs-discovery.fixtures'; + +describe('JobsDiscoveryService', () => { + let service: JobsDiscoveryService; + let exchangeOracleGatewayMock: Partial; + let kvStoreGatewayMock: Partial; + beforeEach(async () => { + exchangeOracleGatewayMock = { + fetchJobs: jest.fn(), + }; + kvStoreGatewayMock = { + getExchangeOracleUrlByAddress: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + JobsDiscoveryService, + JobsDiscoveryProfile, + { provide: ExchangeOracleGateway, useValue: exchangeOracleGatewayMock }, + { provide: KvStoreGateway, useValue: kvStoreGatewayMock }, + ], + }).compile(); + + service = module.get(JobsDiscoveryService); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); + describe('processJobsDiscovery', () => { + it('should get oracle url and call api for jobs fetch', async () => { + const command = jobsDiscoveryParamsCommandFixture; + const details = jobsDiscoveryParamsDetailsFixture; + + ( + kvStoreGatewayMock.getExchangeOracleUrlByAddress as jest.Mock + ).mockResolvedValue(jobsDiscoveryOracleUrlFixture); + (exchangeOracleGatewayMock.fetchJobs as jest.Mock).mockResolvedValue( + responseFixture, + ); + + const result = await service.processJobsDiscovery(command); + expect( + kvStoreGatewayMock.getExchangeOracleUrlByAddress, + ).toHaveBeenCalledWith(command.oracleAddress); + expect(exchangeOracleGatewayMock.fetchJobs).toHaveBeenCalledWith(details); + expect(result).toEqual(responseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/model/oracle-discovery.model.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/model/oracle-discovery.model.ts new file mode 100644 index 0000000000..7be07be657 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/model/oracle-discovery.model.ts @@ -0,0 +1,14 @@ +import { IOperator } from '@human-protocol/sdk'; + +export class OracleDiscoveryResponse implements IOperator { + address: string; + role?: string; + url?: string; + jobTypes?: string[]; + constructor(address: string, role: string, url: string, jobTypes: string[]) { + this.address = address; + this.role = role; + this.url = url; + this.jobTypes = jobTypes; + } +} diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts new file mode 100644 index 0000000000..e980f63eeb --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { OracleDiscoveryService } from './oracle-discovery.service'; +import { OracleDiscoveryResponse } from './model/oracle-discovery.model'; + +@Controller() +export class OracleDiscoveryController { + constructor(private readonly service: OracleDiscoveryService) {} + @ApiTags('Oracle-Discovery') + @Get('/oracles') + @ApiOperation({ summary: 'Oracles discovery' }) + @UsePipes(new ValidationPipe()) + public getOracles(): Promise { + return this.service.processOracleDiscovery(); + } +} diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.module.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.module.ts new file mode 100644 index 0000000000..73b346fe94 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OracleDiscoveryService } from './oracle-discovery.service'; + +@Module({ + providers: [OracleDiscoveryService], + exports: [OracleDiscoveryService], +}) +export class OracleDiscoveryModule {} diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.service.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.service.ts new file mode 100644 index 0000000000..5046b5a312 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { OracleDiscoveryResponse } from './model/oracle-discovery.model'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { OperatorUtils } from '@human-protocol/sdk'; +import { EnvironmentConfigService } from '../../common/config/environment-config.service'; + +@Injectable() +export class OracleDiscoveryService { + logger = new Logger('OracleDiscoveryService'); + EXCHANGE_ORACLE = 'Exchange Oracle'; + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private configService: EnvironmentConfigService, + ) {} + + async processOracleDiscovery(): Promise { + const address = this.configService.reputationOracleAddress.toLowerCase(); + const chainIds = this.configService.chainIdsEnabled; + + const allData = await Promise.all( + chainIds.map(async (chainId) => { + let data: OracleDiscoveryResponse[] | undefined = + await this.cacheManager.get(chainId); + if (!data) { + try { + data = await OperatorUtils.getReputationNetworkOperators( + Number(chainId), + address, + this.EXCHANGE_ORACLE, + ); + await this.cacheManager.set( + chainId, + data, + this.configService.cacheTtlOracleDiscovery, + ); + } catch (error) { + this.logger.error(`Error processing chainId ${chainId}:`, error); + } + } + return data; + }), + ); + + return allData.flat().filter(Boolean) as OracleDiscoveryResponse[]; + } +} diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts new file mode 100644 index 0000000000..e2b5191f0e --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts @@ -0,0 +1,43 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import { OracleDiscoveryController } from '../oracle-discovery.controller'; +import { OracleDiscoveryService } from '../oracle-discovery.service'; +import { oracleDiscoveryServiceMock } from './oracle-discovery.service.mock'; +import { OracleDiscoveryResponse } from '../model/oracle-discovery.model'; +import { generateOracleDiscoveryResponseBody } from './oracle-discovery.fixture'; + +describe('OracleDiscoveryController', () => { + let controller: OracleDiscoveryController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OracleDiscoveryController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [OracleDiscoveryService], + }) + .overrideProvider(OracleDiscoveryService) + .useValue(oracleDiscoveryServiceMock) + .compile(); + + controller = module.get( + OracleDiscoveryController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('oracle discovery', () => { + it('oracle discovery should be return OracleDiscoveryData', async () => { + const result: OracleDiscoveryResponse[] = await controller.getOracles(); + const expectedResponse = generateOracleDiscoveryResponseBody(); + expect(result).toEqual(expectedResponse); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.fixture.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.fixture.ts new file mode 100644 index 0000000000..94bce62e53 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.fixture.ts @@ -0,0 +1,13 @@ +import { OracleDiscoveryResponse } from '../model/oracle-discovery.model'; + +export function generateOracleDiscoveryResponseBody() { + const response1: OracleDiscoveryResponse = { + address: '0xd06eac24a0c47c776Ce6826A93162c4AfC029047', + role: 'role1', + }; + const response2: OracleDiscoveryResponse = { + address: '0xd10c3402155c058D78e4D5fB5f50E125F06eb39d', + role: 'role2', + }; + return [response1, response2]; +} diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.service.mock.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.service.mock.ts new file mode 100644 index 0000000000..b9d98d5256 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.service.mock.ts @@ -0,0 +1,7 @@ +import { generateOracleDiscoveryResponseBody } from './oracle-discovery.fixture'; + +export const oracleDiscoveryServiceMock = { + processOracleDiscovery: jest + .fn() + .mockResolvedValue(generateOracleDiscoveryResponseBody()), +}; diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.service.spec.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.service.spec.ts new file mode 100644 index 0000000000..99e6341e42 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.service.spec.ts @@ -0,0 +1,103 @@ +import { Test } from '@nestjs/testing'; +import { Cache } from 'cache-manager'; +import { OracleDiscoveryService } from '../oracle-discovery.service'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { OperatorUtils } from '@human-protocol/sdk'; +import { OracleDiscoveryResponse } from '../model/oracle-discovery.model'; +import { EnvironmentConfigService } from '../../../common/config/environment-config.service'; +import { CommonConfigModule } from '../../../common/config/common-config.module'; +import { ConfigModule } from '@nestjs/config'; + +jest.mock('@human-protocol/sdk', () => ({ + OperatorUtils: { + getReputationNetworkOperators: jest.fn(), + }, +})); + +describe('OracleDiscoveryService', () => { + const EXCHANGE_ORACLE = 'Exchange Oracle'; + let oracleDiscoveryService: OracleDiscoveryService; + let cacheManager: Cache; + let configService: EnvironmentConfigService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + CommonConfigModule, + ConfigModule.forRoot({ + envFilePath: '.env', + isGlobal: true, + }), + ], + providers: [ + OracleDiscoveryService, + { + provide: CACHE_MANAGER, + useValue: { + get: jest.fn(), + set: jest.fn(), + }, + }, + { + provide: EnvironmentConfigService, + useValue: { + reputationOracleAddress: 'mockedaddress', + cacheTtlOracleDiscovery: 86400, + chainIdsEnabled: ['80001'], + }, + }, + ], + }).compile(); + configService = moduleRef.get( + EnvironmentConfigService, + ); + oracleDiscoveryService = moduleRef.get( + OracleDiscoveryService, + ); + cacheManager = moduleRef.get(CACHE_MANAGER); + }); + it('should be defined', () => { + expect(oracleDiscoveryService).toBeDefined(); + }); + + it('should return cached data if available', async () => { + const mockData: OracleDiscoveryResponse[] = [ + { address: 'mockAddress1', role: 'validator' }, + { address: 'mockAddress2', role: 'validator' }, + ]; + jest.spyOn(cacheManager, 'get').mockResolvedValue(mockData); + + const result = await oracleDiscoveryService.processOracleDiscovery(); + + expect(result).toEqual(mockData); + expect(OperatorUtils.getReputationNetworkOperators).not.toHaveBeenCalled(); + }); + + it('should fetch and cache data if not already cached', async () => { + const mockData: OracleDiscoveryResponse[] = [ + { address: 'mockAddress1', role: 'validator' }, + { address: 'mockAddress2', role: 'validator' }, + ]; + const chainId = 80001; + + jest.spyOn(cacheManager, 'get').mockResolvedValue(undefined); + jest + .spyOn(OperatorUtils, 'getReputationNetworkOperators') + .mockResolvedValue(mockData); + + const result = await oracleDiscoveryService.processOracleDiscovery(); + + expect(result).toEqual(mockData); + expect(cacheManager.get).toHaveBeenCalledWith(chainId.toString()); + expect(cacheManager.set).toHaveBeenCalledWith( + chainId.toString(), + mockData, + configService.cacheTtlOracleDiscovery, + ); + expect(OperatorUtils.getReputationNetworkOperators).toHaveBeenCalledWith( + chainId, + 'mockedaddress', + EXCHANGE_ORACLE, + ); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/statistics/model/oracle-statistics.model.ts b/packages/apps/human-app/server/src/modules/statistics/model/oracle-statistics.model.ts new file mode 100644 index 0000000000..2b869f2fa8 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/model/oracle-statistics.model.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class OracleStatisticsResponse { + escrows_processed: number; + escrows_active: number; + escrows_cancelled: number; + workers_amount: number; + assignments_completed: number; + assignments_rejected: number; + assignments_expired: number; +} +export class OracleStatisticsCommand { + oracleAddress: string; +} +export class OracleStatisticsDetails { + exchangeOracleUrl: string; +} + +export class OracleStatisticsDto { + @IsString() + @ApiProperty({ example: 'string' }) + oracle_address: string; +} diff --git a/packages/apps/human-app/server/src/modules/statistics/model/user-statistics.model.ts b/packages/apps/human-app/server/src/modules/statistics/model/user-statistics.model.ts new file mode 100644 index 0000000000..586283ab44 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/model/user-statistics.model.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class UserStatisticsResponse { + assignments_amount: number; + submissions_sent: number; + assignments_completed: number; + assignments_rejected: number; + assignments_expired: number; +} +export class UserStatisticsCommand { + oracleAddress: string; + token: string; +} +export class UserStatisticsDto { + @ApiProperty({ example: 'string' }) + @IsString() + oracle_address: string; +} +export class UserStatisticsDetails { + exchangeOracleUrl: string; + token: string; +} diff --git a/packages/apps/human-app/server/src/modules/statistics/spec/statistics.controller.spec.ts b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.controller.spec.ts new file mode 100644 index 0000000000..34ec0f52a8 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.controller.spec.ts @@ -0,0 +1,65 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StatisticsController } from '../statistics.controller'; +import { StatisticsService } from '../statistics.service'; +import { statisticsServiceMock } from './statistics.service.mock'; +import { + oracleStatsCommandFixture, + oracleStatsResponseFixture, + statisticsExchangeOracleAddress, + statisticsToken, + userStatsCommandFixture, + userStatsResponseFixture, +} from './statistics.fixtures'; +import { OracleStatisticsDto } from '../model/oracle-statistics.model'; +import { UserStatisticsDto } from '../model/user-statistics.model'; + +describe('StatisticsController', () => { + let controller: StatisticsController; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StatisticsController], + providers: [StatisticsService], + }) + .overrideProvider(StatisticsService) + .useValue(statisticsServiceMock) + .compile(); + + controller = module.get(StatisticsController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getOracleStatistics', () => { + it('should call getOracleStats service method with correct parameters', async () => { + const dto: OracleStatisticsDto = { + oracle_address: statisticsExchangeOracleAddress, + }; + const result = await controller.getOracleStatistics(dto); + + expect(statisticsServiceMock.getOracleStats).toHaveBeenCalledWith( + oracleStatsCommandFixture, + ); + expect(result).toEqual(oracleStatsResponseFixture); + }); + }); + + describe('getUserStatistics', () => { + it('should call getUserStats service method with correct parameters', async () => { + const dto: UserStatisticsDto = { + oracle_address: statisticsExchangeOracleAddress, + }; + const result = await controller.getUserStatistics(dto, statisticsToken); + + expect(statisticsServiceMock.getUserStats).toHaveBeenCalledWith( + userStatsCommandFixture, + ); + expect(result).toEqual(userStatsResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/statistics/spec/statistics.fixtures.ts b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.fixtures.ts new file mode 100644 index 0000000000..303a890967 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.fixtures.ts @@ -0,0 +1,78 @@ +import { + UserStatisticsCommand, + UserStatisticsDetails, + UserStatisticsResponse, +} from '../model/user-statistics.model'; +import { + OracleStatisticsCommand, + OracleStatisticsDetails, + OracleStatisticsResponse, +} from '../model/oracle-statistics.model'; +import { AxiosRequestConfig } from 'axios'; + +const ASSIGNMENTS_AMOUNT = 2137; +const SUBMISSIONS_SENT = 1969; +const ASSIGNMENTS_COMPLETED_USER = 3; +const ASSIGNMENTS_REJECTED_USER = 666; +const ASSIGNMENTS_EXPIRED_USER = 42; +const ESCROWS_PROCESSED = 34290311; +const ESCROWS_ACTIVE = 451132343; +const ESCROWS_CANCELLED = 7833; +const WORKERS_AMOUNT = 3409; +const ASSIGNMENTS_COMPLETED_ORACLE = 154363; +const ASSIGNMENTS_REJECTED_ORACLE = 231; +const ASSIGNMENTS_EXPIRED_ORACLE = 434; +const EXCHANGE_ORACLE_ADDRESS = '0x32df932'; +const EXCHANGE_ORACLE_URL = 'https://test.oracle.com'; +const TOKEN = 'test-token'; +export const statisticsToken = TOKEN; +export const statisticsExchangeOracleAddress = EXCHANGE_ORACLE_ADDRESS; +export const statisticsExchangeOracleUrl = EXCHANGE_ORACLE_URL; +export const userStatsResponseFixture: UserStatisticsResponse = { + assignments_amount: ASSIGNMENTS_AMOUNT, + submissions_sent: SUBMISSIONS_SENT, + assignments_completed: ASSIGNMENTS_COMPLETED_USER, + assignments_rejected: ASSIGNMENTS_REJECTED_USER, + assignments_expired: ASSIGNMENTS_EXPIRED_USER, +}; + +export const oracleStatsResponseFixture: OracleStatisticsResponse = { + escrows_processed: ESCROWS_PROCESSED, + escrows_active: ESCROWS_ACTIVE, + escrows_cancelled: ESCROWS_CANCELLED, + workers_amount: WORKERS_AMOUNT, + assignments_completed: ASSIGNMENTS_COMPLETED_ORACLE, + assignments_rejected: ASSIGNMENTS_REJECTED_ORACLE, + assignments_expired: ASSIGNMENTS_EXPIRED_ORACLE, +}; + +export const userStatsCommandFixture: UserStatisticsCommand = { + oracleAddress: EXCHANGE_ORACLE_ADDRESS, + token: TOKEN, +}; +export const userStatsDetailsFixture: UserStatisticsDetails = { + exchangeOracleUrl: EXCHANGE_ORACLE_ADDRESS, + token: TOKEN, +}; + +export const oracleStatsCommandFixture: OracleStatisticsCommand = { + oracleAddress: EXCHANGE_ORACLE_ADDRESS, +}; +export const oracleStatsDetailsFixture: OracleStatisticsDetails = { + exchangeOracleUrl: EXCHANGE_ORACLE_URL, +}; +export const requestContextFixture = { + token: TOKEN, +}; +export const userStatsOptionsFixture: AxiosRequestConfig = { + method: 'GET', + url: `${EXCHANGE_ORACLE_URL}/stats/assignment`, + headers: { + Authorization: `Bearer ${TOKEN}`, + }, +}; + +export const oracleStatsOptionsFixture: AxiosRequestConfig = { + method: 'GET', + url: `${EXCHANGE_ORACLE_URL}/stats`, +}; diff --git a/packages/apps/human-app/server/src/modules/statistics/spec/statistics.service.mock.ts b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.service.mock.ts new file mode 100644 index 0000000000..9b1fd241f1 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.service.mock.ts @@ -0,0 +1,9 @@ +import { + oracleStatsResponseFixture, + userStatsResponseFixture, +} from './statistics.fixtures'; + +export const statisticsServiceMock = { + getUserStats: jest.fn().mockResolvedValue(userStatsResponseFixture), + getOracleStats: jest.fn().mockResolvedValue(oracleStatsResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/statistics/spec/statistics.service.spec.ts b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.service.spec.ts new file mode 100644 index 0000000000..f32309cf7f --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/spec/statistics.service.spec.ts @@ -0,0 +1,155 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { exchangeOracleGatewayMock } from '../../../integrations/exchange-oracle/spec/exchange-oracle.gateway.mock'; +import { StatisticsService } from '../statistics.service'; +import { ExchangeOracleGateway } from '../../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { EnvironmentConfigService } from '../../../common/config/environment-config.service'; +import { Cache } from 'cache-manager'; +import { + UserStatisticsCommand, + UserStatisticsDetails, +} from '../model/user-statistics.model'; +import { KvStoreGateway } from '../../../integrations/kv-store/kv-store-gateway.service'; +import { + OracleStatisticsCommand, + OracleStatisticsDetails, + OracleStatisticsResponse, +} from '../model/oracle-statistics.model'; +const EXCHANGE_ORACLE_URL = 'https://exchangeOracle.url'; +const EXCHANGE_ORACLE_ADDRESS = '0x8f238b21aa2056'; +const TOKEN = 'token1'; +describe('StatisticsService', () => { + let service: StatisticsService; + let cacheManager: Cache & { get: jest.Mock; set: jest.Mock }; // Explicitly type as jest.Mock + let exchangeGateway: ExchangeOracleGateway & { + fetchOracleStatistics: jest.Mock; + fetchUserStatistics: jest.Mock; + }; + let configService: EnvironmentConfigService; + let kvStoreGateway: KvStoreGateway; + + beforeEach(async () => { + const cacheManagerMock = { + get: jest.fn(), + set: jest.fn(), + }; + const configServiceMock = { + cacheTtlOracleStats: 300, + cacheTtlUserStats: 300, + }; + const kvStoreGatewayMock = { + getExchangeOracleUrlByAddress: jest + .fn() + .mockResolvedValue(EXCHANGE_ORACLE_URL), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StatisticsService, + { provide: KvStoreGateway, useValue: kvStoreGatewayMock }, + { provide: CACHE_MANAGER, useValue: cacheManagerMock }, + { provide: ExchangeOracleGateway, useValue: exchangeOracleGatewayMock }, + { provide: EnvironmentConfigService, useValue: configServiceMock }, + ], + }).compile(); + + kvStoreGateway = module.get(KvStoreGateway); + service = module.get(StatisticsService); + cacheManager = module.get(CACHE_MANAGER); + exchangeGateway = module.get(ExchangeOracleGateway); + configService = module.get(EnvironmentConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getOracleStats', () => { + it('should return cached data if available', async () => { + const cachedData = { some: 'data' }; + cacheManager.get.mockResolvedValue(cachedData); + + const command: OracleStatisticsCommand = { + oracleAddress: EXCHANGE_ORACLE_ADDRESS, + }; + const result: OracleStatisticsResponse = + await service.getOracleStats(command); + + expect(cacheManager.get).toHaveBeenCalledWith(command.oracleAddress); + expect(result).toEqual(cachedData); + expect(exchangeGateway.fetchOracleStatistics).not.toHaveBeenCalled(); + }); + + it('should fetch, cache, and return new data if not in cache', async () => { + const newData = { newData: 'data' }; + cacheManager.get.mockResolvedValue(undefined); + exchangeGateway.fetchOracleStatistics.mockResolvedValue(newData); + + const command = { oracleAddress: EXCHANGE_ORACLE_ADDRESS }; + const result = await service.getOracleStats(command); + const details: OracleStatisticsDetails = { + exchangeOracleUrl: EXCHANGE_ORACLE_URL, + }; + + expect(kvStoreGateway.getExchangeOracleUrlByAddress).toHaveBeenCalledWith( + command.oracleAddress, + ); + expect(cacheManager.get).toHaveBeenCalledWith(command.oracleAddress); + expect(exchangeGateway.fetchOracleStatistics).toHaveBeenCalledWith( + details, + ); + expect(cacheManager.set).toHaveBeenCalledWith( + command.oracleAddress, + newData, + configService.cacheTtlOracleStats, + ); + expect(result).toEqual(newData); + }); + }); + + describe('getUserStats', () => { + it('should return cached data if available', async () => { + const cachedData = { userData: 'data' }; + const userCacheKey = EXCHANGE_ORACLE_ADDRESS + TOKEN; + cacheManager.get.mockResolvedValue(cachedData); + + const command = { + oracleAddress: EXCHANGE_ORACLE_ADDRESS, + token: TOKEN, + }; + const result = await service.getUserStats(command); + + expect(cacheManager.get).toHaveBeenCalledWith(userCacheKey); + expect(result).toEqual(cachedData); + expect(exchangeGateway.fetchUserStatistics).not.toHaveBeenCalled(); + }); + + it('should fetch, cache, and return new data if not in cache', async () => { + const newData = { newData: 'data' }; + const userCacheKey = EXCHANGE_ORACLE_ADDRESS + TOKEN; + cacheManager.get.mockResolvedValue(undefined); + exchangeGateway.fetchUserStatistics.mockResolvedValue(newData); + + const command = { + oracleAddress: EXCHANGE_ORACLE_ADDRESS, + token: TOKEN, + } as UserStatisticsCommand; + const details = { + exchangeOracleUrl: EXCHANGE_ORACLE_URL, + token: TOKEN, + } as UserStatisticsDetails; + const result = await service.getUserStats(command); + expect(kvStoreGateway.getExchangeOracleUrlByAddress).toHaveBeenCalledWith( + command.oracleAddress, + ); + expect(cacheManager.get).toHaveBeenCalledWith(userCacheKey); + expect(exchangeGateway.fetchUserStatistics).toHaveBeenCalledWith(details); + expect(cacheManager.set).toHaveBeenCalledWith( + userCacheKey, + newData, + configService.cacheTtlUserStats, + ); + expect(result).toEqual(newData); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/statistics/statistics.controller.ts b/packages/apps/human-app/server/src/modules/statistics/statistics.controller.ts new file mode 100644 index 0000000000..f790686e3b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/statistics.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Get, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { StatisticsService } from './statistics.service'; +import { + OracleStatisticsCommand, + OracleStatisticsDto, + OracleStatisticsResponse, +} from './model/oracle-statistics.model'; +import { + UserStatisticsCommand, + UserStatisticsDto, + UserStatisticsResponse, +} from './model/user-statistics.model'; +import { Authorization } from '../../common/config/params-decorators'; + +@Controller() +export class StatisticsController { + constructor(private readonly service: StatisticsService) {} + @ApiTags('Statistics') + @Get('/stats') + @ApiOperation({ summary: 'General Oracle Statistics' }) + @UsePipes(new ValidationPipe()) + public getOracleStatistics( + @Query() dto: OracleStatisticsDto, + ): Promise { + const command = { + oracleAddress: dto.oracle_address, + } as OracleStatisticsCommand; + return this.service.getOracleStats(command); + } + + @ApiTags('Statistics') + @Get('stats/assignment') + @ApiOperation({ summary: 'Statistics for requesting user' }) + @ApiBearerAuth() + @UsePipes(new ValidationPipe()) + public getUserStatistics( + @Query() dto: UserStatisticsDto, + @Authorization() token: string, + ): Promise { + const command: UserStatisticsCommand = { + oracleAddress: dto.oracle_address, + token: token, + } as UserStatisticsCommand; + return this.service.getUserStats(command); + } +} diff --git a/packages/apps/human-app/server/src/modules/statistics/statistics.module.ts b/packages/apps/human-app/server/src/modules/statistics/statistics.module.ts new file mode 100644 index 0000000000..bd97871ab3 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/statistics.module.ts @@ -0,0 +1,11 @@ +import { StatisticsService } from './statistics.service'; +import { Module } from '@nestjs/common'; +import { ExchangeOracleModule } from '../../integrations/exchange-oracle/exchange-oracle.module'; +import { KvStoreModule } from '../../integrations/kv-store/kv-store.module'; + +@Module({ + imports: [ExchangeOracleModule, KvStoreModule], + providers: [StatisticsService], + exports: [StatisticsService], +}) +export class StatisticsModule {} diff --git a/packages/apps/human-app/server/src/modules/statistics/statistics.service.ts b/packages/apps/human-app/server/src/modules/statistics/statistics.service.ts new file mode 100644 index 0000000000..1bafb54329 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/statistics/statistics.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + UserStatisticsCommand, + UserStatisticsDetails, + UserStatisticsResponse, +} from './model/user-statistics.model'; +import { + OracleStatisticsCommand, + OracleStatisticsDetails, + OracleStatisticsResponse, +} from './model/oracle-statistics.model'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { EnvironmentConfigService } from '../../common/config/environment-config.service'; +import { ExchangeOracleGateway } from '../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { KvStoreGateway } from '../../integrations/kv-store/kv-store-gateway.service'; + +@Injectable() +export class StatisticsService { + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private readonly exchangeOracleGateway: ExchangeOracleGateway, + private readonly kvStoreGateway: KvStoreGateway, + private readonly configService: EnvironmentConfigService, + ) {} + async getOracleStats( + command: OracleStatisticsCommand, + ): Promise { + const url = command.oracleAddress; + const cachedStatistics: OracleStatisticsResponse | undefined = + await this.cacheManager.get(url); + if (cachedStatistics) { + return cachedStatistics; + } + const exchangeOracleUrl = + await this.kvStoreGateway.getExchangeOracleUrlByAddress(command.oracleAddress); + const details = { + exchangeOracleUrl: exchangeOracleUrl, + } as OracleStatisticsDetails; + const response: OracleStatisticsResponse = + await this.exchangeOracleGateway.fetchOracleStatistics(details); + await this.cacheManager.set( + url, + response, + this.configService.cacheTtlOracleStats, + ); + return response; + } + async getUserStats( + command: UserStatisticsCommand, + ): Promise { + const userCacheKey = command.oracleAddress + command.token; + const cachedStatistics: UserStatisticsResponse | undefined = + await this.cacheManager.get(userCacheKey); + if (cachedStatistics) { + return cachedStatistics; + } + const exchangeOracleUrl = + await this.kvStoreGateway.getExchangeOracleUrlByAddress(command.oracleAddress); + const details = { + exchangeOracleUrl: exchangeOracleUrl, + token: command.token, + } as UserStatisticsDetails; + const response = + await this.exchangeOracleGateway.fetchUserStatistics(details); + await this.cacheManager.set( + userCacheKey, + response, + this.configService.cacheTtlUserStats, + ); + return response; + } +} diff --git a/packages/apps/human-app/server/src/modules/user-operator/interfaces/operator-registration.interface.ts b/packages/apps/human-app/server/src/modules/user-operator/model/operator-registration.model.ts similarity index 100% rename from packages/apps/human-app/server/src/modules/user-operator/interfaces/operator-registration.interface.ts rename to packages/apps/human-app/server/src/modules/user-operator/model/operator-registration.model.ts diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts index a86b0c2ea8..1d7721b518 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts @@ -12,7 +12,7 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { SignupOperatorCommand, SignupOperatorDto, -} from './interfaces/operator-registration.interface'; +} from './model/operator-registration.model'; @Controller() export class OperatorController { diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.mapper.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.mapper.ts index 691c031a8c..98a5dd1199 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.mapper.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.mapper.ts @@ -4,7 +4,7 @@ import { createMap, Mapper } from '@automapper/core'; import { SignupOperatorCommand, SignupOperatorDto, -} from './interfaces/operator-registration.interface'; +} from './model/operator-registration.model'; @Injectable() export class OperatorProfile extends AutomapperProfile { diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts index fcb7d5cbc3..4dd6f55a6f 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; import { OperatorService } from './operator.service'; import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; import { OperatorProfile } from './operator.mapper'; +import { Module } from '@nestjs/common'; @Module({ imports: [ReputationOracleModule], diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts index 99cd2f1d41..7b0799890f 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { SignupOperatorCommand } from './interfaces/operator-registration.interface'; +import { SignupOperatorCommand } from './model/operator-registration.model'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; @Injectable() diff --git a/packages/apps/human-app/server/src/modules/user-operator/spec/operator.conroller.spec.ts b/packages/apps/human-app/server/src/modules/user-operator/spec/operator.conroller.spec.ts index 339ec6487b..d83b1b6446 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/spec/operator.conroller.spec.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/spec/operator.conroller.spec.ts @@ -7,7 +7,7 @@ import { OperatorProfile } from '../operator.mapper'; import { SignupOperatorCommand, SignupOperatorDto, -} from '../interfaces/operator-registration.interface'; +} from '../model/operator-registration.model'; import { UserType } from '../../../common/enums/user'; import { operatorServiceMock } from './operator.service.mock'; diff --git a/packages/apps/human-app/server/src/modules/user-worker/interfaces/worker-registration.interface.ts b/packages/apps/human-app/server/src/modules/user-worker/model/worker-registration.model.ts similarity index 72% rename from packages/apps/human-app/server/src/modules/user-worker/interfaces/worker-registration.interface.ts rename to packages/apps/human-app/server/src/modules/user-worker/model/worker-registration.model.ts index 9ff595031f..d110101c73 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/interfaces/worker-registration.interface.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/model/worker-registration.model.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsString } from 'class-validator'; import { AutoMap } from '@automapper/classes'; -import { UserType } from '../../../common/enums/user'; export class SignupWorkerDto { @AutoMap() @@ -13,6 +12,10 @@ export class SignupWorkerDto { @ApiProperty({ example: 'string' }) @IsString() password: string; + @AutoMap() + @ApiProperty({ example: 'string' }) + @IsString() + h_captcha_token: string; } export class SignupWorkerCommand { @@ -21,12 +24,12 @@ export class SignupWorkerCommand { @AutoMap() password: string; @AutoMap() - type: UserType; + hCaptchaToken: string; - constructor(email: string, password: string) { + constructor(email: string, password: string, token: string) { this.email = email; this.password = password; - this.type = UserType.WORKER; + this.hCaptchaToken = token; } } @@ -36,5 +39,5 @@ export class SignupWorkerData { @AutoMap() password: string; @AutoMap() - type: string; + h_captcha_token: string; } diff --git a/packages/apps/human-app/server/src/modules/user-worker/interfaces/worker-signin.interface.ts b/packages/apps/human-app/server/src/modules/user-worker/model/worker-signin.model.ts similarity index 78% rename from packages/apps/human-app/server/src/modules/user-worker/interfaces/worker-signin.interface.ts rename to packages/apps/human-app/server/src/modules/user-worker/model/worker-signin.model.ts index 5f66b7aaa0..ab2cf138c9 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/interfaces/worker-signin.interface.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/model/worker-signin.model.ts @@ -12,6 +12,10 @@ export class SigninWorkerDto { @ApiProperty({ example: 'string' }) @IsString() password: string; + @AutoMap() + @ApiProperty({ example: 'string' }) + @IsString() + h_captcha_token: string; } export class SigninWorkerCommand { @@ -19,6 +23,8 @@ export class SigninWorkerCommand { email: string; @AutoMap() password: string; + @AutoMap() + hCaptchaToken: string; } export class SigninWorkerData { @@ -26,6 +32,8 @@ export class SigninWorkerData { email: string; @AutoMap() password: string; + @AutoMap() + h_captcha_token: string; } export class SigninWorkerResponse { diff --git a/packages/apps/human-app/server/src/modules/user-worker/spec/worker.controller.spec.ts b/packages/apps/human-app/server/src/modules/user-worker/spec/worker.controller.spec.ts index 714f9c808a..2c33000be6 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/spec/worker.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/spec/worker.controller.spec.ts @@ -4,19 +4,17 @@ import { Mapper } from '@automapper/core'; import { SignupWorkerCommand, SignupWorkerDto, -} from '../interfaces/worker-registration.interface'; +} from '../model/worker-registration.model'; import { Test, TestingModule } from '@nestjs/testing'; -import { UserType } from '../../../common/enums/user'; import { AutomapperModule } from '@automapper/nestjs'; import { classes } from '@automapper/classes'; import { WorkerProfile } from '../worker.mapper'; import { workerServiceMock } from './worker.service.mock'; -import { SigninWorkerDto } from '../interfaces/worker-signin.interface'; +import { SigninWorkerDto } from '../model/worker-signin.model'; describe('WorkerController', () => { let controller: WorkerController; let workerService: WorkerService; - let mapper: Mapper; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -45,12 +43,13 @@ describe('WorkerController', () => { const dto: SignupWorkerDto = { email: 'email@example.com', password: 'Pa55word!', + h_captcha_token: 'hcaptchatonsdkfa', }; await controller.signupWorker(dto); const expectedCommand = { email: dto.email, password: dto.password, - type: UserType.WORKER, + hCaptchaToken: dto.h_captcha_token, } as SignupWorkerCommand; expect(workerService.signupWorker).toHaveBeenCalledWith(expectedCommand); }); @@ -61,11 +60,13 @@ describe('WorkerController', () => { const dto: SigninWorkerDto = { email: 'email@example.com', password: 'Pa55word!', + h_captcha_token: 'hcaptchatonsdkfa', }; await controller.signinWorker(dto); const expectedCommand = { email: dto.email, password: dto.password, + hCaptchaToken: dto.h_captcha_token, }; expect(workerService.signinWorker).toHaveBeenCalledWith(expectedCommand); }); diff --git a/packages/apps/human-app/server/src/modules/user-worker/spec/worker.service.spec.ts b/packages/apps/human-app/server/src/modules/user-worker/spec/worker.service.spec.ts index adc59ea589..304361b245 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/spec/worker.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/spec/worker.service.spec.ts @@ -3,6 +3,7 @@ import { WorkerService } from '../worker.service'; import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; import { reputationOracleGatewayMock } from '../../../integrations/reputation-oracle/spec/reputation-oracle.gateway.mock'; import { UserType } from '../../../common/enums/user'; +import { SignupWorkerCommand } from '../model/worker-registration.model'; describe('WorkerService', () => { let service: WorkerService; @@ -28,10 +29,10 @@ describe('WorkerService', () => { describe('signupWorker', () => { it('should call reputation oracle gateway without doing anything else', async () => { - const command = { + const command: SignupWorkerCommand = { email: 'email@example.com', password: 'Pa55word!', - type: UserType.WORKER, + hCaptchaToken: 'hcaptchatkn', }; await service.signupWorker(command); expect(reputationOracleGateway.sendWorkerSignup).toHaveBeenCalledWith( @@ -45,6 +46,7 @@ describe('WorkerService', () => { const command = { email: 'email@example.com', password: 'Pa55word!', + hCaptchaToken: 'token', }; await service.signinWorker(command); expect(reputationOracleGateway.sendWorkerSignin).toHaveBeenCalledWith( diff --git a/packages/apps/human-app/server/src/modules/user-worker/worker.controller.ts b/packages/apps/human-app/server/src/modules/user-worker/worker.controller.ts index 200748b462..9f1aa0d2a6 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/worker.controller.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/worker.controller.ts @@ -9,15 +9,15 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { SignupWorkerCommand, SignupWorkerDto, -} from './interfaces/worker-registration.interface'; +} from './model/worker-registration.model'; import { InjectMapper } from '@automapper/nestjs'; import { Mapper } from '@automapper/core'; import { WorkerService } from './worker.service'; import { SigninWorkerCommand, SigninWorkerDto, -} from './interfaces/worker-signin.interface'; -import { SigninWorkerResponse } from './interfaces/worker-signin.interface'; +} from './model/worker-signin.model'; +import { SigninWorkerResponse } from './model/worker-signin.model'; @Controller() export class WorkerController { diff --git a/packages/apps/human-app/server/src/modules/user-worker/worker.mapper.ts b/packages/apps/human-app/server/src/modules/user-worker/worker.mapper.ts index 0163f956af..1824599846 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/worker.mapper.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/worker.mapper.ts @@ -1,14 +1,22 @@ import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; import { Injectable } from '@nestjs/common'; -import { createMap, Mapper } from '@automapper/core'; +import { + CamelCaseNamingConvention, + createMap, + forMember, + mapFrom, + Mapper, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; import { SignupWorkerCommand, SignupWorkerDto, -} from './interfaces/worker-registration.interface'; +} from './model/worker-registration.model'; import { SigninWorkerCommand, SigninWorkerDto, -} from './interfaces/worker-signin.interface'; +} from './model/worker-signin.model'; @Injectable() export class WorkerProfile extends AutomapperProfile { @@ -18,8 +26,32 @@ export class WorkerProfile extends AutomapperProfile { override get profile() { return (mapper: Mapper) => { - createMap(mapper, SignupWorkerDto, SignupWorkerCommand); - createMap(mapper, SigninWorkerDto, SigninWorkerCommand); + createMap( + mapper, + SignupWorkerDto, + SignupWorkerCommand, + forMember( + (destination) => destination.hCaptchaToken, + mapFrom((source) => source.h_captcha_token), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + createMap( + mapper, + SigninWorkerDto, + SigninWorkerCommand, + forMember( + (destination) => destination.hCaptchaToken, + mapFrom((source) => source.h_captcha_token), + ), + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/modules/user-worker/worker.module.ts b/packages/apps/human-app/server/src/modules/user-worker/worker.module.ts index be82e27b90..8b2c04cbbd 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/worker.module.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/worker.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; import { WorkerService } from './worker.service'; import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; import { WorkerProfile } from './worker.mapper'; +import { Module } from '@nestjs/common'; @Module({ imports: [ReputationOracleModule], diff --git a/packages/apps/human-app/server/src/modules/user-worker/worker.service.ts b/packages/apps/human-app/server/src/modules/user-worker/worker.service.ts index 49e230dec3..84daf5db0f 100644 --- a/packages/apps/human-app/server/src/modules/user-worker/worker.service.ts +++ b/packages/apps/human-app/server/src/modules/user-worker/worker.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; -import { SignupWorkerCommand } from './interfaces/worker-registration.interface'; -import { SigninWorkerCommand } from './interfaces/worker-signin.interface'; +import { SignupWorkerCommand } from './model/worker-registration.model'; +import { SigninWorkerCommand } from './model/worker-signin.model'; @Injectable() export class WorkerService { diff --git a/packages/apps/human-app/server/test/app.e2e-spec.ts b/packages/apps/human-app/server/test/app.e2e-spec.ts index 745370b50b..cead57d7a5 100644 --- a/packages/apps/human-app/server/test/app.e2e-spec.ts +++ b/packages/apps/human-app/server/test/app.e2e-spec.ts @@ -3,12 +3,15 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { generateWorkerSignupRequestBody } from './fixtures/user-worker.fixture'; -import { SignupWorkerData } from '../src/modules/user-worker/interfaces/worker-registration.interface'; +import { SignupWorkerData } from '../src/modules/user-worker/model/worker-registration.model'; import { beforeAll } from '@jest/globals'; import { generateOperatorSignupRequestBody } from './fixtures/user-operator.fixture'; -import { SignupOperatorData } from '../src/modules/user-operator/interfaces/operator-registration.interface'; +import { SignupOperatorData } from '../src/modules/user-operator/model/operator-registration.model'; import { ConfigService } from '@nestjs/config'; -import { TestEnvironmentConfigService, testEnvValidator } from '../src/common/config/test-environment-config.service'; +import { + TestEnvironmentConfigService, + testEnvValidator, +} from '../src/common/config/test-environment-config.service'; describe('Human APP (e2e) tests', () => { let app: INestApplication; @@ -27,6 +30,10 @@ describe('Human APP (e2e) tests', () => { const { error } = testEnvValidator.validate({ E2E_TESTING_EMAIL_ADDRESS: envConfigService.e2eTestingEmailAddress, E2E_TESTING_PASSWORD: envConfigService.e2eTestingPassword, + E2E_TESTING_EXCHANGE_ORACLE_URL: + envConfigService.e2eTestingExchangeOracleUrl, + E2E_TESTING_ESCROW_ADDRESS: envConfigService.e2eTestingEscrowAddress, + E2E_TESTING_ESCROW_CHAIN_ID: envConfigService.e2eTestingEscrowChainId, }); if (error) { @@ -90,6 +97,33 @@ describe('Human APP (e2e) tests', () => { }); }); }); + describe('Jobs discovery', () => { + it('should successfully process the jobs discovery request', async () => { + const exchangeOracleUrl = envConfigService.e2eTestingExchangeOracleUrl; + return request(app.getHttpServer()) + .get(`${exchangeOracleUrl}/jobs`) + .expect(200); + }); + }); + describe('Job assignment', () => { + it('should successfully assign a job to a user', async () => { + const exchangeOracleUrl = envConfigService.e2eTestingExchangeOracleUrl; + return request(app.getHttpServer()) + .post(`${exchangeOracleUrl}/assignment`) + .send({ + escrow_address: envConfigService.e2eTestingEscrowAddress, + chain_id: envConfigService.e2eTestingEscrowChainId, + }) + .expect(201); + }); + it('should successfully get jobs assigned to a user', async () => { + const exchangeOracleUrl = envConfigService.e2eTestingExchangeOracleUrl; + return request(app.getHttpServer()) + .get(`${exchangeOracleUrl}/assignment`) + .query({}) + .expect(200); + }); + }); afterAll(async () => { await app.close(); diff --git a/packages/apps/human-app/server/test/fixtures/user-operator.fixture.ts b/packages/apps/human-app/server/test/fixtures/user-operator.fixture.ts index f09b4c62f6..da24772ed2 100644 --- a/packages/apps/human-app/server/test/fixtures/user-operator.fixture.ts +++ b/packages/apps/human-app/server/test/fixtures/user-operator.fixture.ts @@ -1,6 +1,6 @@ import { UserType } from '../../src/common/enums/user'; import { ethers } from 'ethers'; -import { SignupOperatorData } from '../../src/modules/user-operator/interfaces/operator-registration.interface'; +import { SignupOperatorData } from '../../src/modules/user-operator/model/operator-registration.model'; export async function generateOperatorSignupRequestBody() { const wallet = ethers.Wallet.createRandom(); const flatSig = await wallet.signMessage('signup'); diff --git a/packages/apps/human-app/server/test/fixtures/user-worker.fixture.ts b/packages/apps/human-app/server/test/fixtures/user-worker.fixture.ts index b5c0e03cee..7e4098fdac 100644 --- a/packages/apps/human-app/server/test/fixtures/user-worker.fixture.ts +++ b/packages/apps/human-app/server/test/fixtures/user-worker.fixture.ts @@ -5,6 +5,7 @@ export function generateWorkerSignupRequestBody() { return { email: `john_doe${randomElement}@example.com`, password: 'v3ry57r0n9P455w0r[)!', + h_captcha_token: '342dsfgisg8932resadz231y58sdf9adsf', type: UserType.WORKER.toString(), }; } diff --git a/packages/apps/human-app/server/tsconfig.json b/packages/apps/human-app/server/tsconfig.json index b23fdd9e74..21da75fe79 100644 --- a/packages/apps/human-app/server/tsconfig.json +++ b/packages/apps/human-app/server/tsconfig.json @@ -7,7 +7,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "allowJs": true, - "target": "es2017", + "target": "es2020", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/packages/apps/human-app/server/vercel.json b/packages/apps/human-app/server/vercel.json new file mode 100644 index 0000000000..3e7126787b --- /dev/null +++ b/packages/apps/human-app/server/vercel.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "builds": [ + { + "src": "src/main.ts", + "use": "@vercel/node" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "src/main.ts", + "headers": { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "X-Requested-With,Content-Type,Accept" + } + } + ], + "ignoreCommand": "git diff HEAD^ HEAD --quiet ." +} diff --git a/yarn.lock b/yarn.lock index cc2d9fd790..b4498a8f6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2857,6 +2857,11 @@ dependencies: "@swc/helpers" "^0.5.0" +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@ipld/car@^3.0.1", "@ipld/car@^3.2.3": version "3.2.4" resolved "https://registry.yarnpkg.com/@ipld/car/-/car-3.2.4.tgz#115951ba2255ec51d865773a074e422c169fb01c" @@ -3483,6 +3488,11 @@ resolved "https://registry.yarnpkg.com/@nestjs/axios/-/axios-2.0.0.tgz#2116fad483e232ef102a877b503a9f19926bd102" integrity sha512-F6oceoQLEn031uun8NiommeMkRIojQqVryxQy/mK7fx0CI0KbgkJL3SloCQcsOD+agoEnqKJKXZpEvL6FNswJg== +"@nestjs/cache-manager@^2.2.1": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@nestjs/cache-manager/-/cache-manager-2.2.2.tgz#4b0e7c4112c7b8c2a869d64f998aaf8a1bf0040d" + integrity sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA== + "@nestjs/cli@^9.4.3": version "9.5.0" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-9.5.0.tgz#ddf1b0e21b5507c151e0cd1a4cfcf6c55df7cb2e" @@ -4650,6 +4660,40 @@ resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-3.0.1.tgz#8056fe046a8d10a275e321ec0557ae652d7a4d06" integrity sha512-XjDVbs3ZU16CO1h5Q3Ew2RPJqmZBDE/EVf1LYp6ePEffs3V/MX9ZbL5bJr8qiK5SbGmUMuDoaFgyKacYz8prRA== +"@redis/bloom@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71" + integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg== + +"@redis/client@1.5.14": + version "1.5.14" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.5.14.tgz#1107893464d092f140d77c468b018a6ed306a180" + integrity sha512-YGn0GqsRBFUQxklhY7v562VMOP0DcmlrHHs3IV1mFE3cbxe31IITUkqhBcIhVSI/2JqtWAJXg5mjV4aU+zD0HA== + dependencies: + cluster-key-slot "1.1.2" + generic-pool "3.9.0" + yallist "4.0.0" + +"@redis/graph@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.1.tgz#8c10df2df7f7d02741866751764031a957a170ea" + integrity sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw== + +"@redis/json@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.6.tgz#b7a7725bbb907765d84c99d55eac3fcf772e180e" + integrity sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw== + +"@redis/search@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.6.tgz#33bcdd791d9ed88ab6910243a355d85a7fedf756" + integrity sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw== + +"@redis/time-series@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.5.tgz#a6d70ef7a0e71e083ea09b967df0a0ed742bc6ad" + integrity sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg== + "@reduxjs/toolkit@1.9.7", "@reduxjs/toolkit@^1.9.0", "@reduxjs/toolkit@^1.9.5": version "1.9.7" resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.7.tgz#7fc07c0b0ebec52043f8cb43510cf346405f78a6" @@ -9474,6 +9518,23 @@ cache-content-type@^1.0.0: mime-types "^2.1.18" ylru "^1.2.0" +cache-manager-redis-store@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/cache-manager-redis-store/-/cache-manager-redis-store-3.0.1.tgz#8eeb211212763d04cef4058666182d624f714299" + integrity sha512-o560kw+dFqusC9lQJhcm6L2F2fMKobJ5af+FoR2PdnMVdpQ3f3Bz6qzvObTGyvoazQJxjQNWgMQeChP4vRTuXQ== + dependencies: + redis "^4.3.1" + +cache-manager@^5.4.0: + version "5.5.1" + resolved "https://registry.yarnpkg.com/cache-manager/-/cache-manager-5.5.1.tgz#b86a3995e9c0d2b26f799c6c483fda470a3d5794" + integrity sha512-QYZFOjZTTennYdN3NNCKh+yq452+wQ4ChyL40jkEyghIgg5Ugwb4YO8ARIIF1fvTBkgDLlLTYFaxZVaPGmQ92A== + dependencies: + eventemitter3 "^5.0.1" + lodash.clonedeep "^4.5.0" + lru-cache "^10.2.0" + promise-coalesce "^1.1.2" + cacheable-lookup@^5.0.3: version "5.0.4" resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" @@ -10039,6 +10100,11 @@ clsx@^2.0.0, clsx@^2.1.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== +cluster-key-slot@1.1.2, cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co-body@^5.1.1: version "5.2.0" resolved "https://registry.yarnpkg.com/co-body/-/co-body-5.2.0.tgz#5a0a658c46029131e0e3a306f67647302f71c124" @@ -11194,6 +11260,11 @@ delete-empty@^3.0.0: path-starts-with "^2.0.0" rimraf "^2.6.2" +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -13522,6 +13593,11 @@ gauge@^3.0.0: strip-ansi "^6.0.1" wide-align "^1.1.2" +generic-pool@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4" + integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -14908,6 +14984,21 @@ io-ts@1.10.4: dependencies: fp-ts "^1.0.0" +ioredis@^5.3.2: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.1.tgz#1c56b70b759f01465913887375ed809134296f40" + integrity sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + ip-regex@^4.0.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" @@ -17114,6 +17205,21 @@ lodash.deburr@^4.1.0: resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b" integrity sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ== +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.difference@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" + integrity sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA== + +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g== + lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" @@ -17124,6 +17230,11 @@ lodash.includes@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" @@ -19734,6 +19845,11 @@ progress-stream@^2.0.0: speedometer "~1.0.0" through2 "~2.0.3" +promise-coalesce@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/promise-coalesce/-/promise-coalesce-1.1.2.tgz#5d3bc4d0b2cf2e41e9df7cbeb6519b2a09459e3d" + integrity sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg== + promise@^8.0.0: version "8.3.0" resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a" @@ -20523,6 +20639,30 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + +redis@^4.3.1: + version "4.6.13" + resolved "https://registry.yarnpkg.com/redis/-/redis-4.6.13.tgz#e247267c5f3ba35ab8277b57343d3a56acb2f0a6" + integrity sha512-MHgkS4B+sPjCXpf+HfdetBwbRz6vCtsceTmw1pHNYJAsYxrfpOP6dz+piJWGos8wqG7qb3vj/Rrc5qOlmInUuA== + dependencies: + "@redis/bloom" "1.2.0" + "@redis/client" "1.5.14" + "@redis/graph" "1.1.1" + "@redis/json" "1.0.6" + "@redis/search" "1.1.6" + "@redis/time-series" "1.0.5" + reduce-flatten@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27" @@ -21826,6 +21966,11 @@ stacktrace-parser@^0.1.10: dependencies: type-fest "^0.7.1" +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -24567,16 +24712,16 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== +yallist@4.0.0, yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - yaml@1.10.2, yaml@^1.10.0, yaml@^1.10.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"