@nestjs/common#UnprocessableEntityException TypeScript Examples

The following examples show how to use @nestjs/common#UnprocessableEntityException. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: blocks.controller.ts    From ironfish-api with Mozilla Public License 2.0 6 votes vote down vote up
@ApiOperation({ summary: 'Gets metrics for blocks' })
  @Get('metrics')
  async metrics(
    @Query(
      new ValidationPipe({
        errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
        transform: true,
      }),
    )
    query: BlocksMetricsQueryDto,
  ): Promise<List<SerializedBlockMetrics>> {
    const { isValid, error } = this.isValidMetricsQuery(query);
    if (!isValid) {
      throw new UnprocessableEntityException(error);
    }

    const records = await this.blocksDailyService.list(query.start, query.end);
    return {
      object: 'list',
      data: records.map(serializedBlockMetricsFromRecord),
    };
  }
Example #2
Source File: blocks-transactions.service.ts    From ironfish-api with Mozilla Public License 2.0 6 votes vote down vote up
async list(
    options: ListBlockTransactionOptions,
  ): Promise<BlockTransaction[]> {
    if (options.blockId) {
      return this.prisma.blockTransaction.findMany({
        where: {
          block_id: options.blockId,
        },
      });
    } else if (options.transactionId) {
      return this.prisma.blockTransaction.findMany({
        where: {
          transaction_id: options.transactionId,
        },
      });
    } else {
      throw new UnprocessableEntityException();
    }
  }
Example #3
Source File: int-is-safe-for-prisma.pipe.ts    From ironfish-api with Mozilla Public License 2.0 6 votes vote down vote up
transform(value: string): number {
    const isNumeric =
      ['string', 'number'].includes(typeof value) &&
      /^-?\d+$/.test(value) &&
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      isFinite(value as any);
    if (!isNumeric) {
      throw new UnprocessableEntityException(
        'Validation failed (numeric string is expected)',
      );
    }
    const parsed = parseInt(value, 10);
    assertValueIsSafeForPrisma(parsed);
    return parsed;
  }
Example #4
Source File: faucet-transactions.service.ts    From ironfish-api with Mozilla Public License 2.0 6 votes vote down vote up
async complete(
    faucetTransaction: FaucetTransaction,
    options?: CompleteFaucetTransactionOptions,
  ): Promise<FaucetTransaction> {
    if (faucetTransaction.completed_at) {
      throw new UnprocessableEntityException();
    }
    return this.prisma.$transaction(async (prisma) => {
      return prisma.faucetTransaction.update({
        data: {
          completed_at: new Date().toISOString(),
          hash: options?.hash,
        },
        where: {
          id: faucetTransaction.id,
        },
      });
    });
  }
Example #5
Source File: faucet-transactions.service.ts    From ironfish-api with Mozilla Public License 2.0 6 votes vote down vote up
async start(
    faucetTransaction: FaucetTransaction,
  ): Promise<FaucetTransaction> {
    if (faucetTransaction.completed_at) {
      throw new UnprocessableEntityException();
    }
    return this.prisma.$transaction(async (prisma) => {
      return prisma.faucetTransaction.update({
        data: {
          started_at: new Date().toISOString(),
          tries: {
            increment: 1,
          },
        },
        where: {
          id: faucetTransaction.id,
        },
      });
    });
  }
Example #6
Source File: faucet-transactions.service.ts    From ironfish-api with Mozilla Public License 2.0 6 votes vote down vote up
async create({
    email,
    publicKey,
    createdAt,
  }: CreateFaucetTransactionOptions): Promise<FaucetTransaction> {
    return this.prisma.$transaction(async (prisma) => {
      const count = await this.prisma.faucetTransaction.count({
        where: {
          created_at: { gte: new Date(Date.now() - FAUCET_TIME_LIMIT_MS) },
          OR: [
            { email },
            {
              public_key: publicKey,
            },
          ],
        },
      });

      if (count >= FAUCET_REQUESTS_LIMIT) {
        throw new UnprocessableEntityException({
          code: 'faucet_max_requests_reached',
          message: 'Too many faucet requests',
        });
      }

      return prisma.faucetTransaction.create({
        data: {
          created_at: createdAt,
          email,
          public_key: publicKey,
        },
      });
    });
  }
Example #7
Source File: users.service.ts    From ironfish-api with Mozilla Public License 2.0 5 votes vote down vote up
async create({
    email,
    graffiti,
    country_code: countryCode,
    discord,
    telegram,
    github,
  }: CreateUserDto): Promise<User> {
    email = standardizeEmail(email);
    const existingRecord = await this.prisma.user.findFirst({
      where: {
        OR: [
          {
            email,
          },
          {
            graffiti,
          },
        ],
      },
    });
    if (existingRecord) {
      throw new UnprocessableEntityException(
        `User already exists for '${
          graffiti === existingRecord.graffiti ? graffiti : email
        }'`,
      );
    }
    return this.prisma.$transaction(async (prisma) => {
      const user = await prisma.user.create({
        data: {
          email,
          graffiti,
          discord,
          telegram,
          github,
          country_code: countryCode,
        },
      });
      await this.userPointsService.upsertWithClient(
        { userId: user.id },
        prisma,
      );
      return user;
    });
  }
Example #8
Source File: blocks.controller.ts    From ironfish-api with Mozilla Public License 2.0 5 votes vote down vote up
@ApiOperation({
    summary: 'Returns a paginated list of blocks from the chain',
  })
  @Get()
  async list(
    @Query(
      new ValidationPipe({
        errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
        transform: true,
      }),
    )
    {
      after,
      before,
      limit,
      main,
      sequence_gte: sequenceGte,
      sequence_lt: sequenceLt,
      search,
      transaction_id: transactionId,
      with_transactions: withTransactions,
    }: BlocksQueryDto,
  ): Promise<PaginatedList<SerializedBlock | SerializedBlockWithTransactions>> {
    const maxBlocksToReturn = 1000;
    if (sequenceGte !== undefined && sequenceLt !== undefined) {
      if (sequenceGte >= sequenceLt) {
        throw new UnprocessableEntityException(
          `'sequence_gte' must be strictly less than 'sequence_lt'.`,
        );
      }
      if (sequenceLt - sequenceGte > maxBlocksToReturn) {
        throw new UnprocessableEntityException(
          `Range is too long. Max sequence difference is ${maxBlocksToReturn}.`,
        );
      }
    }

    const { data, hasNext, hasPrevious } = await this.blocksService.list({
      after,
      before,
      limit,
      main,
      sequenceGte,
      sequenceLt,
      search,
      transactionId,
      withTransactions,
    });
    return {
      object: 'list',
      data: data.map((block) => {
        if ('transactions' in block) {
          return serializedBlockFromRecordWithTransactions(block);
        } else {
          return serializedBlockFromRecord(block);
        }
      }),
      metadata: {
        has_next: hasNext,
        has_previous: hasPrevious,
      },
    };
  }
Example #9
Source File: users-updater.ts    From ironfish-api with Mozilla Public License 2.0 5 votes vote down vote up
async update(user: User, options: UpdateUserOptions): Promise<User> {
    return this.prisma.$transaction(async (prisma) => {
      const { discord, github, graffiti, telegram } = options;

      if (graffiti && user.graffiti !== graffiti) {
        await prisma.$executeRawUnsafe(
          'SELECT pg_advisory_xact_lock(HASHTEXT($1));',
          graffiti,
        );
        const minedBlocksForCurrentGraffiti =
          await this.blocksService.countByGraffiti(user.graffiti, prisma);
        if (minedBlocksForCurrentGraffiti > 0) {
          throw new UnprocessableEntityException({
            code: 'user_graffiti_already_used',
            message: `Current graffiti '${user.graffiti}' has already mined blocks`,
          });
        }
      }

      const users = await this.usersService.findDuplicateUser(
        user,
        options,
        prisma,
      );
      if (users.length) {
        const duplicateUser = users[0];
        let error;

        if (discord && discord === duplicateUser.discord) {
          error = {
            code: 'duplicate_user_discord',
            message: `User with Discord '${discord}' already exists`,
          };
        } else if (github && github === duplicateUser.github) {
          error = {
            code: 'duplicate_user_github',
            message: `User with github '${github}' already exists`,
          };
        } else if (graffiti && graffiti === duplicateUser.graffiti) {
          error = {
            code: 'duplicate_user_graffiti',
            message: `User with graffiti '${graffiti}' already exists`,
          };
        } else if (telegram && telegram === duplicateUser.telegram) {
          error = {
            code: 'duplicate_user_telegram',
            message: `User with Telegram '${telegram}' already exists`,
          };
        } else {
          throw new Error('Unexpected database response');
        }

        throw new UnprocessableEntityException(error);
      }

      return this.usersService.update(user, options, prisma);
    });
  }
Example #10
Source File: prisma.ts    From ironfish-api with Mozilla Public License 2.0 5 votes vote down vote up
export function assertValueIsSafeForPrisma(value?: number): void {
  if (value && value >= Number.MAX_SAFE_INTEGER) {
    throw new UnprocessableEntityException(
      `Value '${value}' is too large to use in query`,
    );
  }
}
Example #11
Source File: boolean.ts    From ironfish-api with Mozilla Public License 2.0 5 votes vote down vote up
export function stringToBoolean(value: unknown): boolean {
  if (!isBoolString(value)) {
    throw new UnprocessableEntityException(
      `Boolean string parameter must have value of either 'true' or 'false'`,
    );
  }
  return value === 'true';
}
Example #12
Source File: blocks.service.ts    From ironfish-api with Mozilla Public License 2.0 5 votes vote down vote up
async find(
    options: number | FindBlockOptions,
  ): Promise<Block | (Block & { transactions: Transaction[] }) | null> {
    const networkVersion = this.config.get<number>('NETWORK_VERSION');

    if (typeof options === 'number') {
      return this.prisma.block.findUnique({
        where: {
          id: options,
        },
      });
    }

    const { withTransactions } = options;
    if (options.hash !== undefined) {
      const block = await this.prisma.block.findFirst({
        where: {
          hash: standardizeHash(options.hash),
          network_version: networkVersion,
        },
      });

      if (block !== null && withTransactions) {
        const transactions =
          await this.blocksTransactionsService.findTransactionsByBlock(block);
        return { ...block, transactions };
      }

      return block;
    } else if (options.sequence !== undefined) {
      const block = await this.prisma.block.findFirst({
        where: {
          sequence: options.sequence,
          main: true,
          network_version: networkVersion,
        },
      });

      if (block !== null && withTransactions) {
        const transactions =
          await this.blocksTransactionsService.findTransactionsByBlock(block);
        return { ...block, transactions };
      }

      return block;
    } else {
      throw new UnprocessableEntityException();
    }
  }
Example #13
Source File: faucet-transactions.service.spec.ts    From ironfish-api with Mozilla Public License 2.0 4 votes vote down vote up
describe('FaucetTransactionService', () => {
  let app: INestApplication;
  let faucetTransactionsService: FaucetTransactionsService;
  let prisma: PrismaService;

  beforeAll(async () => {
    app = await bootstrapTestApp();
    faucetTransactionsService = app.get(FaucetTransactionsService);
    prisma = app.get(PrismaService);
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('findOrThrow', () => {
    describe('with a valid id', () => {
      it('returns the record', async () => {
        const email = faker.internet.email();
        const publicKey = ulid();
        const faucetTransaction = await faucetTransactionsService.create({
          email,
          publicKey,
        });
        const record = await faucetTransactionsService.findOrThrow(
          faucetTransaction.id,
        );
        expect(record).not.toBeNull();
        expect(record).toMatchObject(faucetTransaction);
      });
    });

    describe('with a missing id', () => {
      it('throws a NotFoundException', async () => {
        await expect(
          faucetTransactionsService.findOrThrow(100000),
        ).rejects.toThrow(NotFoundException);
      });
    });
  });

  describe('create', () => {
    describe('with too many faucet requests', () => {
      it('throws an exception', async () => {
        const email = faker.internet.email();
        const publicKey = ulid();

        for (let i = 0; i < FAUCET_REQUESTS_LIMIT; i++) {
          await faucetTransactionsService.create({
            email,
            publicKey,
          });
        }

        await expect(
          faucetTransactionsService.create({
            email,
            publicKey,
          }),
        ).rejects.toThrow(UnprocessableEntityException);
      });

      it('limits in time range', async () => {
        const email = faker.internet.email();
        const publicKey = ulid();

        const pastThreshold = new Date(Date.now() - FAUCET_TIME_LIMIT_MS * 2);

        for (let i = 0; i < FAUCET_REQUESTS_LIMIT; i++) {
          await faucetTransactionsService.create({
            email,
            publicKey,
            createdAt: pastThreshold,
          });
        }

        await expect(
          faucetTransactionsService.create({
            email,
            publicKey,
          }),
        ).resolves.toMatchObject({
          email,
        });
      });
    });

    describe('when the limit for requests has not been hit', () => {
      it('creates a FaucetTransaction record', async () => {
        const email = faker.internet.email();
        const publicKey = ulid();
        const faucetTransaction = await faucetTransactionsService.create({
          email,
          publicKey,
        });

        expect(faucetTransaction).toMatchObject({
          id: expect.any(Number),
          email,
          public_key: publicKey,
        });
      });
    });
  });

  describe('next', () => {
    describe('when a FaucetTransaction is already running', () => {
      it('returns the running transaction', async () => {
        const runningFaucetTransaction = [
          {
            id: 0,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: new Date(),
            tries: 1,
            hash: null,
          },
        ];
        jest
          .spyOn(prisma.faucetTransaction, 'findMany')
          .mockResolvedValueOnce(runningFaucetTransaction);

        expect(await faucetTransactionsService.next({})).toMatchObject(
          runningFaucetTransaction,
        );
      });
    });

    describe('when no FaucetTransactions are running', () => {
      it('returns the next available FaucetTransaction', async () => {
        const pendingFaucetTransaction = [
          {
            id: 0,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: null,
            tries: 0,
            hash: null,
          },
        ];
        jest
          .spyOn(prisma.faucetTransaction, 'findMany')
          // No currently running FaucetTransaction
          .mockResolvedValueOnce([])
          // Waiting to run FaucetTransaction
          .mockResolvedValueOnce(pendingFaucetTransaction);

        expect(await faucetTransactionsService.next({})).toMatchObject(
          pendingFaucetTransaction,
        );
      });
    });

    describe('when a number of FaucetTransactions are requested', () => {
      describe('when the amount of running FaucetTransactions is greater than or equal to requested number', () => {
        it('returns the running FaucetTransactions', async () => {
          const runningFaucetTransaction1 = {
            id: 0,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: new Date(),
            tries: 1,
            hash: null,
          };
          const runningFaucetTransaction2 = {
            id: 1,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: new Date(),
            tries: 1,
            hash: null,
          };
          jest
            .spyOn(prisma.faucetTransaction, 'findMany')
            .mockResolvedValueOnce([
              runningFaucetTransaction1,
              runningFaucetTransaction2,
            ]);

          expect(
            await faucetTransactionsService.next({ count: 2 }),
          ).toMatchObject([
            runningFaucetTransaction1,
            runningFaucetTransaction2,
          ]);
        });
      });

      describe('when the amount of running FaucetTransactions is less than requested number', () => {
        it('returns running and pending FaucetTransactions', async () => {
          const runningFaucetTransaction1 = {
            id: 0,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: new Date(),
            tries: 1,
            hash: null,
          };
          const runningFaucetTransaction2 = {
            id: 1,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: new Date(),
            tries: 1,
            hash: null,
          };
          const pendingFaucetTransaction = {
            id: 2,
            created_at: new Date(),
            updated_at: new Date(),
            public_key: 'mock-key',
            email: null,
            completed_at: null,
            started_at: null,
            tries: 0,
            hash: null,
          };
          jest
            .spyOn(prisma.faucetTransaction, 'findMany')
            .mockResolvedValueOnce([
              runningFaucetTransaction1,
              runningFaucetTransaction2,
              pendingFaucetTransaction,
            ]);

          expect(
            await faucetTransactionsService.next({ count: 3 }),
          ).toMatchObject([
            runningFaucetTransaction1,
            runningFaucetTransaction2,
            pendingFaucetTransaction,
          ]);
        });
      });
    });
  });

  describe('start', () => {
    describe('if the FaucetTransaction has completed', () => {
      it('throws an UnprocessableEntityException', async () => {
        const faucetTransaction = {
          id: 0,
          created_at: new Date(),
          updated_at: new Date(),
          public_key: 'mock-key',
          email: null,
          completed_at: new Date(),
          started_at: new Date(),
          tries: 0,
          hash: null,
        };
        await expect(
          faucetTransactionsService.start(faucetTransaction),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('with a valid FaucetTransaction', () => {
      it('updates the `started_at` and tries column for the record', async () => {
        const email = faker.internet.email();
        const publicKey = ulid();
        const faucetTransaction = await faucetTransactionsService.create({
          email,
          publicKey,
        });

        const updatedRecord = await faucetTransactionsService.start(
          faucetTransaction,
        );
        expect(updatedRecord).toMatchObject({
          id: faucetTransaction.id,
          public_key: faucetTransaction.public_key,
          started_at: expect.any(Date),
          tries: faucetTransaction.tries + 1,
        });
      });
    });
  });

  describe('complete', () => {
    describe('if the FaucetTransaction has completed', () => {
      it('throws an UnprocessableEntityException', async () => {
        const faucetTransaction = {
          id: 0,
          created_at: new Date(),
          updated_at: new Date(),
          public_key: 'mock-key',
          email: null,
          completed_at: new Date(),
          started_at: new Date(),
          tries: 0,
          hash: null,
        };
        await expect(
          faucetTransactionsService.complete(faucetTransaction),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('with a valid FaucetTransaction', () => {
      it('updates the `completed_at` and `hash` columns for the record', async () => {
        const email = faker.internet.email();
        const publicKey = ulid();
        const faucetTransaction = await faucetTransactionsService.create({
          email,
          publicKey,
        });
        const startedFaucetTransaction = await faucetTransactionsService.start(
          faucetTransaction,
        );

        const hash = ulid();
        const updatedRecord = await faucetTransactionsService.complete(
          startedFaucetTransaction,
          { hash },
        );
        expect(updatedRecord).toMatchObject({
          id: faucetTransaction.id,
          public_key: faucetTransaction.public_key,
          hash,
          completed_at: expect.any(Date),
        });
      });
    });
  });

  describe('getGlobalStatus', () => {
    it('returns the number of completed, pending, and running Faucet Transactions', async () => {
      const status = await faucetTransactionsService.getGlobalStatus();
      expect(status).toEqual({
        completed: await prisma.faucetTransaction.count({
          where: {
            completed_at: { not: null },
          },
        }),
        running: await prisma.faucetTransaction.count({
          where: {
            started_at: { not: null },
            completed_at: null,
          },
        }),
        pending: await prisma.faucetTransaction.count({
          where: {
            started_at: null,
            completed_at: null,
          },
        }),
      });
    });
  });
});
Example #14
Source File: users-updater.spec.ts    From ironfish-api with Mozilla Public License 2.0 4 votes vote down vote up
describe('UsersUpdater', () => {
  let app: INestApplication;
  let blocksService: BlocksService;
  let prisma: PrismaService;
  let userPointsService: UserPointsService;
  let usersService: UsersService;
  let usersUpdater: UsersUpdater;

  beforeAll(async () => {
    app = await bootstrapTestApp();
    blocksService = app.get(BlocksService);
    prisma = app.get(PrismaService);
    userPointsService = app.get(UserPointsService);
    usersService = app.get(UsersService);
    usersUpdater = app.get(UsersUpdater);
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  const setupBlockMined = async (points?: number) => {
    const hash = uuid();
    const sequence = faker.datatype.number();
    const graffiti = uuid();

    const block = await prisma.block.create({
      data: {
        hash,
        main: true,
        sequence,
        timestamp: new Date(),
        transactions_count: 0,
        graffiti,
        previous_block_hash: uuid(),
        network_version: 0,
        size: faker.datatype.number(),
        difficulty: faker.datatype.number(),
      },
    });
    const user = await usersService.create({
      discord: faker.internet.userName(),
      email: faker.internet.email(),
      graffiti,
      country_code: faker.address.countryCode('alpha-3'),
      telegram: faker.internet.userName(),
    });
    await userPointsService.upsert({
      userId: user.id,
      totalPoints: points ?? POINTS_PER_CATEGORY.BLOCK_MINED,
    });
    return { block, user };
  };

  describe('update', () => {
    describe("when the user's current graffiti has already mined blocks on the main chain", () => {
      it('throws an UnprocessableEntityException', async () => {
        const { user } = await setupBlockMined();
        await expect(
          usersUpdater.update(user, { graffiti: 'foo' }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('when a user exists for the new discord', () => {
      it('throws an UnprocessableEntityException', async () => {
        const { user: existingUser } = await setupBlockMined();
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        assert.ok(existingUser.discord);
        await expect(
          usersUpdater.update(user, { discord: existingUser.discord }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('when two users attempt to claim the same graffiti', () => {
      it('throws an UnprocessableEntityException', async () => {
        // Manually sleep the first update so the second update begins before
        // the first transaction completes
        jest
          .spyOn(blocksService, 'countByGraffiti')
          .mockImplementationOnce(async (graffiti, client) => {
            const sleep = (ms: number) =>
              new Promise((resolve) => setTimeout(resolve, ms));
            await sleep(10);
            return blocksService.countByGraffiti(graffiti, client);
          });

        const firstUser = await usersService.create({
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
        });
        const secondUser = await usersService.create({
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
        });
        const graffiti = ulid();

        // Expect one update to fail given a duplicate graffiti
        await expect(
          Promise.all([
            usersUpdater.update(firstUser, { graffiti }),
            usersUpdater.update(secondUser, { graffiti }),
          ]),
        ).rejects.toThrow(UnprocessableEntityException);

        // Expect one update to succeed
        const user = await usersService.findByGraffiti(graffiti);
        expect(user).not.toBeNull();
      });
    });

    describe('when a user exists for the new graffiti without blocks mined', () => {
      it('throws an UnprocessableEntityException', async () => {
        const existingUser = await usersService.create({
          discord: faker.internet.userName(),
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
          telegram: faker.internet.userName(),
        });
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        await expect(
          usersUpdater.update(user, { graffiti: existingUser.graffiti }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('when a user exists for the new telegram', () => {
      it('throws an UnprocessableEntityException', async () => {
        const { user: existingUser } = await setupBlockMined();
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        assert.ok(existingUser.telegram);
        await expect(
          usersUpdater.update(user, { telegram: existingUser.telegram }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('with no duplicates or existing blocks', () => {
      it('updates the user', async () => {
        const options = {
          discord: ulid(),
          graffiti: ulid(),
          telegram: ulid(),
        };
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: ulid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        const updatedUser = await usersUpdater.update(user, options);
        expect(updatedUser).toMatchObject({
          id: user.id,
          discord: options.discord,
          graffiti: options.graffiti,
          telegram: options.telegram,
        });
      });
    });
  });
});
Example #15
Source File: users.controller.ts    From ironfish-api with Mozilla Public License 2.0 4 votes vote down vote up
@ApiOperation({ summary: 'Gets metrics for a specific User' })
  @ApiParam({ description: 'Unique User identifier', name: 'id' })
  @Get(':id/metrics')
  async metrics(
    @Param('id', new IntIsSafeForPrismaPipe())
    id: number,
    @Query(
      new ValidationPipe({
        errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
        transform: true,
      }),
    )
    query: UserMetricsQueryDto,
  ): Promise<SerializedUserMetrics> {
    const { isValid, error } = this.isValidMetricsQuery(query);
    if (!isValid) {
      throw new UnprocessableEntityException(error);
    }

    const user = await this.usersService.findOrThrow(id);

    let eventMetrics: Record<EventType, SerializedEventMetrics>;
    let points: number;
    let pools: Record<MetricsPool, SerializedEventMetrics> | undefined;
    let nodeUptime: SerializedUserMetrics['node_uptime'];

    if (query.granularity === MetricsGranularity.LIFETIME) {
      eventMetrics = await this.eventsService.getLifetimeEventMetricsForUser(
        user,
      );

      pools = {
        main: await this.eventsService.getLifetimeEventsMetricsForUser(user, [
          EventType.BUG_CAUGHT,
          EventType.NODE_UPTIME,
          EventType.SEND_TRANSACTION,
        ]),
        code: await this.eventsService.getLifetimeEventsMetricsForUser(user, [
          EventType.PULL_REQUEST_MERGED,
        ]),
      };

      const uptime = await this.nodeUptimeService.get(user);
      nodeUptime = {
        total_hours: uptime?.total_hours ?? 0,
        last_checked_in: uptime?.last_checked_in?.toISOString() ?? null,
      };

      const userPoints = await this.userPointsService.findOrThrow(user.id);
      points = userPoints.total_points;
    } else {
      if (query.start === undefined || query.end === undefined) {
        throw new UnprocessableEntityException(
          'Must provide time range for "TOTAL" requests',
        );
      }

      ({ eventMetrics, points } =
        await this.eventsService.getTotalEventMetricsAndPointsForUser(
          user,
          query.start,
          query.end,
        ));
    }

    return {
      user_id: id,
      granularity: query.granularity,
      points,
      pools,
      node_uptime: nodeUptime,
      metrics: {
        blocks_mined: eventMetrics[EventType.BLOCK_MINED],
        bugs_caught: eventMetrics[EventType.BUG_CAUGHT],
        community_contributions: eventMetrics[EventType.COMMUNITY_CONTRIBUTION],
        pull_requests_merged: eventMetrics[EventType.PULL_REQUEST_MERGED],
        social_media_contributions:
          eventMetrics[EventType.SOCIAL_MEDIA_PROMOTION],
        node_uptime: eventMetrics[EventType.NODE_UPTIME],
        send_transaction: eventMetrics[EventType.SEND_TRANSACTION],
      },
    };
  }
Example #16
Source File: users.service.spec.ts    From ironfish-api with Mozilla Public License 2.0 4 votes vote down vote up
describe('UsersService', () => {
  let app: INestApplication;
  let eventsService: EventsService;
  let usersService: UsersService;
  let prisma: PrismaService;
  let userPointsService: UserPointsService;

  beforeAll(async () => {
    app = await bootstrapTestApp();
    eventsService = app.get(EventsService);
    prisma = app.get(PrismaService);
    userPointsService = app.get(UserPointsService);
    usersService = app.get(UsersService);
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('find', () => {
    describe('with a valid id', () => {
      it('returns the record', async () => {
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });
        const record = await usersService.find(user.id);
        expect(record).not.toBeNull();
        expect(record).toMatchObject(user);
      });
    });

    describe('with a missing id', () => {
      it('returns null', async () => {
        expect(await usersService.find(100000)).toBeNull();
      });
    });
  });

  describe('findOrThrow', () => {
    describe('with a valid id', () => {
      it('returns the record', async () => {
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });
        const record = await usersService.findOrThrow(user.id);
        expect(record).not.toBeNull();
        expect(record).toMatchObject(user);
      });
    });

    describe('with a missing id', () => {
      it('throws a NotFoundException', async () => {
        await expect(usersService.findOrThrow(100000)).rejects.toThrow(
          NotFoundException,
        );
      });
    });
  });

  describe('findByGraffiti', () => {
    describe('with a valid graffiti', () => {
      it('returns the record', async () => {
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });
        const record = await usersService.findByGraffiti(user.graffiti);
        expect(record).not.toBeNull();
        expect(record).toMatchObject(user);
      });
    });

    describe('with a missing graffiti', () => {
      it('returns null', async () => {
        expect(await usersService.findByGraffiti('1337')).toBeNull();
      });
    });
  });

  describe('findByGraffitiOrThrow', () => {
    describe('with a valid graffiti', () => {
      it('returns the record', async () => {
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });
        const record = await usersService.findByGraffitiOrThrow(user.graffiti);
        expect(record).not.toBeNull();
        expect(record).toMatchObject(user);
      });
    });

    describe('with a missing graffiti', () => {
      it('throws an exception', async () => {
        await expect(
          usersService.findByGraffitiOrThrow('1337'),
        ).rejects.toThrow(NotFoundException);
      });
    });
  });

  describe('findByEmailOrThrow', () => {
    describe('with a missing email', () => {
      it('throws an exception', async () => {
        await expect(
          usersService.findByEmailOrThrow('[email protected]'),
        ).rejects.toThrow(NotFoundException);
      });
    });

    describe('with a valid email', () => {
      it('returns the confirmed record', async () => {
        const email = faker.internet.email();
        const user = await usersService.create({
          email,
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        const record = await usersService.findByEmailOrThrow(email);
        expect(record).toMatchObject(user);
      });
    });
  });

  describe('findByEmail', () => {
    describe('with a missing email', () => {
      it('returns null', async () => {
        expect(await usersService.findByEmail('[email protected]')).toBeNull();
      });
    });

    describe('with a valid email', () => {
      it('returns the confirmed record', async () => {
        const email = faker.internet.email();
        const user = await usersService.create({
          email,
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        const record = await usersService.findByEmail(email);
        expect(record).toMatchObject(user);
      });
    });
  });

  describe('listByEmail', () => {
    it('returns a list of matching users by email', async () => {
      const email = faker.internet.email();
      await usersService.create({
        email,
        graffiti: uuid(),
        country_code: faker.address.countryCode('alpha-3'),
      });

      const records = await usersService.listByEmail(email);
      for (const record of records) {
        expect(record.email).toBe(standardizeEmail(email));
      }
    });
  });

  describe('list', () => {
    it('returns a chunk of users', async () => {
      const limit = 2;
      const { data: records } = await usersService.list({
        limit,
      });
      expect(records).toHaveLength(limit);
      for (const record of records) {
        expect(record).toMatchObject({
          id: expect.any(Number),
          email: expect.any(String),
          graffiti: expect.any(String),
        });
      }
    });
  });

  describe('listWithRank', () => {
    it('returns users ranked correctly', async () => {
      const graffiti = uuid();
      const now = new Date();

      const userA = await usersService.create({
        email: faker.internet.email(),
        graffiti: graffiti + '-a',
        country_code: faker.address.countryCode('alpha-3'),
      });

      const userB = await usersService.create({
        email: faker.internet.email(),
        graffiti: graffiti + '-b',
        country_code: faker.address.countryCode('alpha-3'),
      });

      const userC = await usersService.create({
        email: faker.internet.email(),
        graffiti: graffiti + '-c',
        country_code: faker.address.countryCode('alpha-3'),
      });

      await eventsService.create({
        type: EventType.SOCIAL_MEDIA_PROMOTION,
        userId: userA.id,
        occurredAt: now,
        points: 5,
      });
      await userPointsService.upsert(
        await eventsService.getUpsertPointsOptions(userA),
      );

      await eventsService.create({
        type: EventType.SOCIAL_MEDIA_PROMOTION,
        userId: userB.id,
        occurredAt: new Date(now.valueOf() - 1000),
        points: 5,
      });
      await userPointsService.upsert(
        await eventsService.getUpsertPointsOptions(userB),
      );

      await eventsService.create({
        type: EventType.SOCIAL_MEDIA_PROMOTION,
        userId: userC.id,
        occurredAt: new Date(now.valueOf() + 1000),
        points: 5,
      });
      await userPointsService.upsert(
        await eventsService.getUpsertPointsOptions(userC),
      );

      const { data: records } = await usersService.listWithRank({
        eventType: 'SOCIAL_MEDIA_PROMOTION',
        search: graffiti,
        limit: 3,
      });

      // Because userB caught a bug first, we consider userB to be
      // ranked earlier than userA. The last event for userB doesn't
      // count because it has 0 points.
      expect(records).toHaveLength(3);

      expect(records[0].id).toEqual(userB.id);
      expect(records[1].id).toEqual(userA.id);
      expect(records[2].id).toEqual(userC.id);

      expect(records[0].total_points).toBe(5);
      expect(records[1].total_points).toBe(5);
      expect(records[2].total_points).toBe(5);

      expect(records[0].rank).toBe(1);
      expect(records[1].rank).toBe(2);
      expect(records[2].rank).toBe(3);
    });

    describe(`when 'event_type' is provided`, () => {
      it('returns a chunk of users by event when specified', async () => {
        const { data: records } = await usersService.listWithRank({
          eventType: 'BUG_CAUGHT',
        });

        records.map((record) =>
          expect(record).toMatchObject({
            id: expect.any(Number),
            graffiti: expect.any(String),
            rank: expect.any(Number),
          }),
        );
      });
    });
  });

  describe('create', () => {
    describe('with a duplicate graffiti', () => {
      it('throws an exception', async () => {
        const graffiti = uuid();
        await usersService.create({
          email: faker.internet.email(),
          graffiti,
          country_code: faker.address.countryCode('alpha-3'),
        });

        await expect(
          usersService.create({
            email: faker.internet.email(),
            graffiti,
            country_code: faker.address.countryCode('alpha-3'),
          }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('with a duplicate email', () => {
      it('throws an exception', async () => {
        const email = faker.internet.email();
        await usersService.create({
          email: standardizeEmail(email),
          graffiti: uuid(),
          country_code: faker.address.countryCode('alpha-3'),
        });

        await expect(
          usersService.create({
            email,
            graffiti: uuid(),
            country_code: faker.address.countryCode('alpha-3'),
          }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('with a new graffiti and email', () => {
      it('creates a new record', async () => {
        const email = faker.internet.email();
        const graffiti = uuid();
        const user = await usersService.create({
          email,
          graffiti,
          country_code: faker.address.countryCode('alpha-3'),
        });

        expect(user).toMatchObject({
          id: expect.any(Number),
          email: standardizeEmail(email),
          graffiti,
        });
      });

      it('creates a new user points record', async () => {
        const email = faker.internet.email();
        const graffiti = uuid();
        const upsertPoints = jest.spyOn(userPointsService, 'upsertWithClient');

        const user = await usersService.create({
          email,
          graffiti,
          country_code: faker.address.countryCode('alpha-3'),
        });

        expect(upsertPoints).toHaveBeenCalledTimes(1);
        assert.ok(upsertPoints.mock.calls);
        expect(upsertPoints.mock.calls[0][0].userId).toBe(user.id);
      });
    });
  });

  describe('updateLastLoginAtByEmail', () => {
    it('updates the last login timestamp', async () => {
      const user = await usersService.create({
        email: faker.internet.email(),
        graffiti: uuid(),
        country_code: faker.address.countryCode('alpha-3'),
      });
      const updatedUser = await usersService.updateLastLoginAt(user);
      expect(updatedUser).toMatchObject({
        id: user.id,
        last_login_at: expect.any(Date),
      });
      expect(updatedUser.last_login_at).not.toEqual(user.last_login_at);
    });
  });

  describe('getRank', () => {
    it('returns the correct rank', async () => {
      const firstUser = await usersService.create({
        email: faker.internet.email(),
        graffiti: uuid(),
        country_code: faker.address.countryCode('alpha-3'),
      });
      const secondUser = await usersService.create({
        email: faker.internet.email(),
        graffiti: uuid(),
        country_code: faker.address.countryCode('alpha-3'),
      });
      const thirdUser = await usersService.create({
        email: faker.internet.email(),
        graffiti: uuid(),
        country_code: faker.address.countryCode('alpha-3'),
      });

      const now = new Date();
      await eventsService.create({
        type: EventType.BUG_CAUGHT,
        userId: firstUser.id,
        occurredAt: now,
        points: 1,
      });
      await eventsService.create({
        type: EventType.BUG_CAUGHT,
        userId: secondUser.id,
        occurredAt: new Date(now.valueOf() - 1000),
        points: 1,
      });
      await eventsService.create({
        type: EventType.BUG_CAUGHT,
        userId: thirdUser.id,
        occurredAt: new Date(now.valueOf() + 1000),
        points: 0,
      });
      await userPointsService.upsert(
        await eventsService.getUpsertPointsOptions(firstUser),
      );
      await userPointsService.upsert(
        await eventsService.getUpsertPointsOptions(secondUser),
      );
      await userPointsService.upsert(
        await eventsService.getUpsertPointsOptions(thirdUser),
      );

      const aggregate = await prisma.userPoints.aggregate({
        _max: {
          total_points: true,
        },
      });
      const currentMaxPoints = aggregate._max.total_points || 0;
      await userPointsService.upsert({
        userId: firstUser.id,
        totalPoints: currentMaxPoints + 2,
      });
      await userPointsService.upsert({
        userId: secondUser.id,
        totalPoints: currentMaxPoints + 2,
      });
      await userPointsService.upsert({
        userId: thirdUser.id,
        totalPoints: currentMaxPoints + 1,
      });

      // Because secondUser caught a bug first, we consider secondUser to be
      // ranked earlier than firstUser. The last event for secondUser doesn't
      // count because it has 0 points.
      expect(await usersService.getRank(secondUser)).toBe(1);
      expect(await usersService.getRank(firstUser)).toBe(2);
      expect(await usersService.getRank(thirdUser)).toBe(3);
    });
  });

  describe('findDuplicateUser', () => {
    describe('with a duplicate discord', () => {
      it('returns the duplicate records', async () => {
        const user = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          discord: ulid(),
          email: faker.internet.email(),
          graffiti: uuid(),
        });
        const duplicateUser = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          discord: ulid(),
          email: faker.internet.email(),
          graffiti: uuid(),
        });

        assert.ok(duplicateUser.discord);
        const duplicateUsers = await usersService.findDuplicateUser(
          user,
          { discord: duplicateUser.discord },
          prisma,
        );
        expect(duplicateUsers).toHaveLength(1);
        assert.ok(duplicateUsers[0]);
        expect(duplicateUsers[0].id).toBe(duplicateUser.id);
      });
    });

    describe('with a duplicate github', () => {
      it('returns the duplicate records', async () => {
        const user = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          github: faker.internet.email(),
          graffiti: uuid(),
        });
        const duplicateUser = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          github: faker.internet.email(),
          graffiti: uuid(),
        });

        assert.ok(duplicateUser.github);
        const duplicateUsers = await usersService.findDuplicateUser(
          user,
          { github: duplicateUser.github },
          prisma,
        );
        expect(duplicateUsers).toHaveLength(1);
        assert.ok(duplicateUsers[0]);
        expect(duplicateUsers[0].id).toBe(duplicateUser.id);
      });
    });

    describe('with a duplicate graffiti', () => {
      it('returns the duplicate records', async () => {
        const user = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          graffiti: uuid(),
        });
        const duplicateUser = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          graffiti: uuid(),
        });

        const duplicateUsers = await usersService.findDuplicateUser(
          user,
          { graffiti: duplicateUser.graffiti },
          prisma,
        );
        expect(duplicateUsers).toHaveLength(1);
        assert.ok(duplicateUsers[0]);
        expect(duplicateUsers[0].id).toBe(duplicateUser.id);
      });
    });

    describe('with a duplicate telegram', () => {
      it('returns the duplicate records', async () => {
        const user = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          graffiti: uuid(),
          telegram: ulid(),
        });
        const duplicateUser = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          graffiti: uuid(),
          telegram: ulid(),
        });

        assert.ok(duplicateUser.telegram);
        const duplicateUsers = await usersService.findDuplicateUser(
          user,
          { telegram: duplicateUser.telegram },
          prisma,
        );
        expect(duplicateUsers).toHaveLength(1);
        assert.ok(duplicateUsers[0]);
        expect(duplicateUsers[0].id).toBe(duplicateUser.id);
      });
    });

    describe('with empty strings', () => {
      it('ignores the empty filters and returns the duplicate records', async () => {
        const user = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          graffiti: uuid(),
          telegram: ulid(),
        });
        const duplicateUser = await usersService.create({
          country_code: faker.address.countryCode('alpha-3'),
          email: faker.internet.email(),
          graffiti: uuid(),
          telegram: ulid(),
        });

        assert.ok(duplicateUser.telegram);
        const duplicateUsers = await usersService.findDuplicateUser(
          user,
          { telegram: duplicateUser.telegram, discord: '' },
          prisma,
        );
        expect(duplicateUsers).toHaveLength(1);
        assert.ok(duplicateUsers[0]);
        expect(duplicateUsers[0].id).toBe(duplicateUser.id);
      });
    });
  });

  describe('update', () => {
    it('updates the record', async () => {
      const options = {
        countryCode: faker.address.countryCode('alpha-3'),
        discord: ulid(),
        github: ulid(),
        graffiti: ulid(),
        telegram: ulid(),
      };
      const user = await usersService.create({
        country_code: faker.address.countryCode('alpha-3'),
        email: faker.internet.email(),
        graffiti: uuid(),
        telegram: ulid(),
      });

      const updatedUser = await usersService.update(user, options, prisma);
      expect(updatedUser).toMatchObject({
        id: user.id,
        country_code: options.countryCode,
        discord: options.discord,
        github: options.github,
        graffiti: options.graffiti,
        telegram: options.telegram,
      });
    });
  });
});
Example #17
Source File: blocks.service.spec.ts    From ironfish-api with Mozilla Public License 2.0 4 votes vote down vote up
describe('BlocksService', () => {
  let app: INestApplication;
  let blocksService: BlocksService;
  let config: ApiConfigService;
  let prisma: PrismaService;
  let usersService: UsersService;

  beforeAll(async () => {
    app = await bootstrapTestApp();
    blocksService = app.get(BlocksService);
    config = app.get(ApiConfigService);
    prisma = app.get(PrismaService);
    usersService = app.get(UsersService);
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('upsert', () => {
    it('upserts a Block record', async () => {
      const options = {
        hash: uuid(),
        sequence: faker.datatype.number(),
        difficulty: faker.datatype.number(),
        timestamp: new Date(),
        transactionsCount: 1,
        type: BlockOperation.CONNECTED,
        graffiti: uuid(),
        previousBlockHash: uuid(),
        size: faker.datatype.number(),
      };

      const { block } = await blocksService.upsert(prisma, options);
      expect(block).toMatchObject({
        id: expect.any(Number),
        hash: options.hash,
        sequence: options.sequence,
        difficulty: BigInt(options.difficulty),
        timestamp: options.timestamp,
        transactions_count: options.transactionsCount,
        main: true,
        graffiti: options.graffiti,
        previous_block_hash: options.previousBlockHash,
        size: options.size,
      });
    });

    it('does not return a payload if a graffiti does not exist', async () => {
      const options = {
        hash: uuid(),
        sequence: faker.datatype.number(),
        difficulty: faker.datatype.number(),
        timestamp: new Date(),
        transactionsCount: 1,
        type: BlockOperation.CONNECTED,
        graffiti: uuid(),
        previousBlockHash: uuid(),
        size: faker.datatype.number(),
      };

      const { upsertBlockMinedOptions } = await blocksService.upsert(
        prisma,
        options,
      );
      expect(upsertBlockMinedOptions).toBeUndefined();
    });

    it('returns a payload for block mined event with a valid graffiti', async () => {
      const graffiti = uuid();
      const user = await usersService.create({
        email: faker.internet.email(),
        graffiti,
        country_code: faker.address.countryCode('alpha-3'),
      });
      const options = {
        hash: uuid(),
        sequence: faker.datatype.number(),
        difficulty: faker.datatype.number(),
        timestamp: new Date(),
        transactionsCount: 1,
        type: BlockOperation.CONNECTED,
        graffiti,
        previousBlockHash: uuid(),
        size: faker.datatype.number(),
      };

      const { block, upsertBlockMinedOptions } = await blocksService.upsert(
        prisma,
        options,
      );
      expect(upsertBlockMinedOptions).toEqual({
        block_id: block.id,
        user_id: user.id,
      });
    });

    it('should standardize hash and previous_block_hash', async () => {
      const hash = faker.random.alpha({ count: 10, upcase: true });
      const previousBlockHash = faker.random.alpha({
        count: 10,
        upcase: true,
      });
      expect(hash).toEqual(hash.toUpperCase());
      expect(previousBlockHash).toEqual(previousBlockHash.toUpperCase());

      await blocksService.upsert(prisma, {
        hash: hash,
        sequence: faker.datatype.number(),
        difficulty: faker.datatype.number(),
        timestamp: new Date(),
        transactionsCount: 1,
        type: BlockOperation.CONNECTED,
        graffiti: uuid(),
        previousBlockHash,
        size: faker.datatype.number(),
      });

      const block = await prisma.block.findFirst({
        where: { hash: standardizeHash(hash) },
      });
      expect(block).toMatchObject({
        hash: standardizeHash(hash),
        previous_block_hash: standardizeHash(previousBlockHash),
      });
    });

    describe('if CHECK_USER_CREATED_AT is disabled', () => {
      it('upserts records with timestamps before created_at', async () => {
        const graffiti = uuid();
        const user = await usersService.create({
          email: faker.internet.email(),
          graffiti,
          country_code: faker.address.countryCode('alpha-3'),
        });
        const options = {
          hash: uuid(),
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date('2000-01-01T00:00:00Z'),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti,
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        };

        jest
          .spyOn(config, 'get')
          .mockImplementationOnce(() => 0)
          .mockImplementationOnce(() => false);
        const { block, upsertBlockMinedOptions } = await blocksService.upsert(
          prisma,
          options,
        );
        expect(upsertBlockMinedOptions).toEqual({
          block_id: block.id,
          user_id: user.id,
        });
      });
    });
  });

  describe('head', () => {
    describe('with no block for the current version and main chain', () => {
      it('throws a NotFoundException', async () => {
        jest.spyOn(config, 'get').mockImplementationOnce(() => 42069);
        await expect(blocksService.head()).rejects.toThrow(NotFoundException);
      });
    });

    describe('with a valid network version', () => {
      it('returns the heaviest block', async () => {
        const block = await blocksService.head();
        expect(block).toMatchObject({
          id: expect.any(Number),
          main: true,
        });
      });
    });
  });

  describe('find', () => {
    describe('with an id', () => {
      it('returns the block', async () => {
        const { block } = await blocksService.upsert(prisma, {
          hash: uuid(),
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti: uuid(),
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });
        const record = await blocksService.find(block.id);
        expect(record).toMatchObject(block);
      });
    });

    describe('with a valid hash', () => {
      it('returns the block with the correct hash', async () => {
        const testBlockHash = uuid();
        const { block } = await blocksService.upsert(prisma, {
          hash: testBlockHash,
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti: uuid(),
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });
        const record = await blocksService.find({ hash: testBlockHash });
        expect(record).toMatchObject(block);
      });

      it('returns the block regardless of hash cases', async () => {
        const testBlockHash = faker.random.alpha({ count: 10, upcase: true });
        const { block } = await blocksService.upsert(prisma, {
          hash: testBlockHash,
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti: uuid(),
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });
        const uppercaseHashBlock = await blocksService.find({
          hash: testBlockHash.toUpperCase(),
        });
        expect(uppercaseHashBlock).toMatchObject(block);
        const lowercaseHashBlock = await blocksService.find({
          hash: testBlockHash.toLowerCase(),
        });
        expect(lowercaseHashBlock).toMatchObject(block);
      });
    });

    describe('with a valid sequence index', () => {
      it('returns the block with the correct sequence index', async () => {
        const testBlockSequence = faker.datatype.number();
        const { block } = await blocksService.upsert(prisma, {
          hash: uuid(),
          sequence: testBlockSequence,
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti: uuid(),
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });
        const record = await blocksService.find({
          sequence: testBlockSequence,
        });
        expect(block).toMatchObject(record as Block);
        expect(block.main).toBe(true);
      });
    });

    describe('with neither a valid hash nor sequence', () => {
      it('returns null', async () => {
        await blocksService.upsert(prisma, {
          hash: uuid(),
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti: uuid(),
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });
        const block = await blocksService.find({
          hash: uuid(),
          sequence: faker.datatype.number(),
        });
        expect(block).toBeNull();
      });
    });
  });

  describe('list', () => {
    describe('with a large sequence', () => {
      it('throws an UnprocessableEntityException', async () => {
        await expect(
          blocksService.list({
            search:
              '0x048e71a62a584909dffa5f59e1bd3d4524b1aa554df13127f2560e8543202a01',
          }),
        ).rejects.toThrow(UnprocessableEntityException);
      });
    });

    describe('with a valid sequence range', () => {
      it('returns blocks within the range', async () => {
        const start = 1;
        const end = 100;
        const { data: blocks } = await blocksService.list({
          sequenceGte: 2,
          sequenceLt: 4,
        });
        for (const block of blocks) {
          expect(block.sequence).toBeGreaterThanOrEqual(start);
          expect(block.sequence).toBeLessThan(end);
        }
      });
    });

    describe('with a valid partial hash search string', () => {
      it('returns block(s) with matches', async () => {
        const searchHash = 'aa';
        const { data: blocks } = await blocksService.list({
          search: searchHash,
        });
        expect(blocks.length).toBeGreaterThanOrEqual(0);
      });
    });

    describe('with a valid sequence search string', () => {
      it('returns block(s) with matches', async () => {
        const searchSequence = '50';
        const { data: blocks } = await blocksService.list({
          search: searchSequence,
        });
        expect(blocks.length).toBeGreaterThanOrEqual(0);
      });
    });

    describe('with a valid graffiti search string', () => {
      it('returns block(s) with matches', async () => {
        const searchGraffiti = 'testGraffiti';
        await blocksService.upsert(prisma, {
          hash: uuid(),
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti: searchGraffiti,
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });

        const { data: blocks } = await blocksService.list({
          search: searchGraffiti,
        });
        expect(blocks.length).toBeGreaterThanOrEqual(1);
      });
    });

    describe('with a transaction ID', () => {
      it('returns block(s) that contain said transaction', async () => {
        const transactionId = 5;

        const blocks = await blocksService.list({
          transactionId,
          withTransactions: true,
        });

        for (const record of blocks.data) {
          const block = record as Block & { transactions: Transaction[] };

          const found = block.transactions.find(
            (tx) => tx.id === transactionId,
          );

          expect(found?.id).toBe(transactionId);
        }
      });
    });

    describe('with main chain query parameter set to false', () => {
      it('returns block(s) that are not on the main chain', async () => {
        const blocks = await blocksService.list({
          main: false,
        });

        for (const block of blocks.data) {
          expect(block.main).toBe(false);
        }
      });
    });

    describe('with no query parameters', () => {
      it('returns block(s) in descending order', async () => {
        const { data: blocks } = await blocksService.list({});
        expect(blocks.length).toBeGreaterThanOrEqual(0);
        expect(blocks[0].id).toBeGreaterThan(blocks[1].id);
      });
    });
  });

  describe('getDateMetrics', () => {
    it('returns metrics for the day', async () => {
      const date = new Date();
      await blocksService.upsert(prisma, {
        hash: uuid(),
        sequence: faker.datatype.number(),
        difficulty: faker.datatype.number(),
        timestamp: date,
        transactionsCount: 1,
        type: BlockOperation.CONNECTED,
        graffiti: uuid(),
        previousBlockHash: uuid(),
        size: faker.datatype.number(),
      });

      const metrics = await blocksService.getDateMetrics(date);
      expect(metrics).toMatchObject({
        averageBlockTimeMs: expect.any(Number),
        averageDifficultyMillis: expect.any(Number),
        blocksCount: expect.any(Number),
        blocksWithGraffitiCount: expect.any(Number),
        chainSequence: expect.any(Number),
        cumulativeUniqueGraffiti: expect.any(Number),
        transactionsCount: expect.any(Number),
        uniqueGraffiti: expect.any(Number),
      });
    });
  });

  describe('getStatus', () => {
    it('returns statistics for blocks in the main chain', async () => {
      const status = await blocksService.getStatus();
      expect(status.chainHeight).toBeGreaterThan(0);
      expect(status.percentageMarked).toBeGreaterThan(0);
      expect(status.uniqueGraffiti).toBeGreaterThan(0);
    });
  });

  describe('disconnectAfter', () => {
    it('updates `main` to false for all blocks after a sequence', async () => {
      const sequenceGt = 10;
      await blocksService.disconnectAfter(sequenceGt);
      const blocks = await prisma.block.findMany({
        where: {
          sequence: {
            gt: sequenceGt,
          },
        },
      });

      for (const block of blocks) {
        expect(block.main).toBe(false);
      }
    });
  });

  describe('countByGraffiti', () => {
    it('returns the count of main blocks for a given graffiti', async () => {
      const graffiti = uuid();
      const count = 15;
      for (let i = 0; i < count; i++) {
        await blocksService.upsert(prisma, {
          hash: uuid(),
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.CONNECTED,
          graffiti,
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });

        // Seed forks to make sure they are not counted
        await blocksService.upsert(prisma, {
          hash: uuid(),
          sequence: faker.datatype.number(),
          difficulty: faker.datatype.number(),
          timestamp: new Date(),
          transactionsCount: 1,
          type: BlockOperation.DISCONNECTED,
          graffiti,
          previousBlockHash: uuid(),
          size: faker.datatype.number(),
        });
      }

      expect(await blocksService.countByGraffiti(graffiti, prisma)).toBe(count);
    });
  });
});