From 2bc51fef9857996d2712532012916a0d12997a92 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 4 Feb 2025 13:03:23 +0100 Subject: [PATCH] fix: add integration tests for wallet --- .../wallets.repository.integration.spec.ts | 546 +++++++++++++++++- 1 file changed, 518 insertions(+), 28 deletions(-) diff --git a/src/domain/wallets/wallets.repository.integration.spec.ts b/src/domain/wallets/wallets.repository.integration.spec.ts index d6e860e671..117850a5ce 100644 --- a/src/domain/wallets/wallets.repository.integration.spec.ts +++ b/src/domain/wallets/wallets.repository.integration.spec.ts @@ -1,73 +1,563 @@ +import { faker } from '@faker-js/faker'; +import { DataSource } from 'typeorm'; +import configuration from '@/config/entities/__tests__/configuration'; +import { postgresConfig } from '@/config/entities/postgres.config'; +import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service'; +import { DatabaseMigrator } from '@/datasources/db/v2/database-migrator.service'; +import { User } from '@/datasources/users/entities/users.entity.db'; +import { WalletsRepository } from '@/domain/wallets/wallets.repository'; +import { Wallet } from '@/datasources/wallets/entities/wallets.entity.db'; +import type { ConfigService } from '@nestjs/config'; +import type { ILoggingService } from '@/logging/logging.interface'; +import { getAddress } from 'viem'; +import { UserStatus } from '@/domain/users/entities/user.entity'; + +const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + describe('WalletsRepository', () => { + let postgresDatabaseService: PostgresDatabaseService; + let walletsRepository: WalletsRepository; + + const testDatabaseName = faker.string.alpha({ + length: 10, + casing: 'lower', + }); + const testConfiguration = configuration(); + + const dataSource = new DataSource({ + ...postgresConfig({ + ...testConfiguration.db.connection.postgres, + type: 'postgres', + database: testDatabaseName, + }), + migrationsTableName: testConfiguration.db.orm.migrationsTableName, + entities: [User, Wallet], + }); + + beforeAll(async () => { + // Create database + const testDataSource = new DataSource({ + ...postgresConfig({ + ...testConfiguration.db.connection.postgres, + type: 'postgres', + database: 'postgres', + }), + }); + const testPostgresDatabaseService = new PostgresDatabaseService( + mockLoggingService, + testDataSource, + ); + await testPostgresDatabaseService.initializeDatabaseConnection(); + await testPostgresDatabaseService + .getDataSource() + .query(`CREATE DATABASE ${testDatabaseName}`); + await testPostgresDatabaseService.destroyDatabaseConnection(); + + // Create database connection + postgresDatabaseService = new PostgresDatabaseService( + mockLoggingService, + dataSource, + ); + await postgresDatabaseService.initializeDatabaseConnection(); + + // Migrate database + const mockConfigService = { + getOrThrow: jest.fn().mockImplementation((key: string) => { + if (key === 'db.migrator.numberOfRetries') { + return testConfiguration.db.migrator.numberOfRetries; + } + if (key === 'db.migrator.retryAfterMs') { + return testConfiguration.db.migrator.retryAfterMs; + } + }), + } as jest.MockedObjectDeep; + const migrator = new DatabaseMigrator( + mockLoggingService, + postgresDatabaseService, + mockConfigService, + ); + await migrator.migrate(); + + walletsRepository = new WalletsRepository(postgresDatabaseService); + }); + + afterEach(async () => { + jest.resetAllMocks(); + + // Truncate tables + const walletRepository = dataSource.getRepository(Wallet); + const userRepository = dataSource.getRepository(User); + + await walletRepository.createQueryBuilder().delete().where('1=1').execute(); + await userRepository.createQueryBuilder().delete().where('1=1').execute(); + }); + + afterAll(async () => { + await postgresDatabaseService.getDataSource().dropDatabase(); + await postgresDatabaseService.destroyDatabaseConnection(); + }); + describe('findOneOrFail', () => { - it.todo('should find a wallet'); + it('should find a wallet', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address }); + + const wallet = await walletsRepository.findOneOrFail({}); + + expect(wallet).toEqual( + expect.objectContaining({ + address, + created_at: expect.any(Date), + id: wallet.id, + updated_at: expect.any(Date), + }), + ); + }); + + it('should throw an error if wallet is not found', async () => { + const address = getAddress(faker.finance.ethereumAddress()); - it.todo('should throw an error if wallet is not found'); + await expect( + walletsRepository.findOneOrFail({ address }), + ).rejects.toThrow('Wallet not found.'); + }); }); describe('findOne', () => { - it.todo('should find a wallet'); + it('should find a wallet', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address }); - it.todo('should return null if wallet is not found'); + const wallet = await walletsRepository.findOne({ address }); + + // Appease TypeScript + if (!wallet) { + throw new Error('Wallet not found.'); + } + + expect(wallet).toEqual( + expect.objectContaining({ + address, + created_at: expect.any(Date), + id: wallet.id, + updated_at: expect.any(Date), + }), + ); + }); + + it('should return null if wallet is not found', async () => { + const wallet = await walletsRepository.findOne({}); + + expect(wallet).toBeNull(); + }); }); describe('findOrFail', () => { - it.todo('should find wallets'); + it('should find wallets', async () => { + const address1 = getAddress(faker.finance.ethereumAddress()); + const address2 = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: address1 }); + await walletRepository.insert({ address: address2 }); - it.todo('should throw an error if no wallets are found'); + const wallets = await walletsRepository.findOrFail({ where: {} }); + + expect(wallets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: address1, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + expect.objectContaining({ + address: address2, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ]), + ); + }); + + it('should throw an error if no wallets are found', async () => { + await expect(walletsRepository.findOrFail({ where: {} })).rejects.toThrow( + 'Wallets not found.', + ); + }); }); describe('find', () => { - it.todo('should find wallets'); + it('should find wallets', async () => { + const address1 = getAddress(faker.finance.ethereumAddress()); + const address2 = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: address1 }); + await walletRepository.insert({ address: address2 }); + + const wallets = await walletsRepository.find({ where: {} }); + + expect(wallets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: address1, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + expect.objectContaining({ + address: address2, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ]), + ); + }); - it.todo('should return an empty array if no wallets are found'); + it('should return an empty array if no wallets are found', async () => { + await expect(walletsRepository.find({ where: {} })).resolves.toEqual([]); + }); }); describe('findOneByAddressOrFail', () => { - it.todo('should find a wallet by address'); + it('should find a wallet by address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address }); - it.todo('should find a wallet by non-checksummed address'); + const wallet = await walletsRepository.findOneByAddressOrFail(address); - it.todo('should throw an error if wallet is not found'); + expect(wallet).toEqual( + expect.objectContaining({ + address, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ); + }); + + it('should find a wallet by non-checksummed address', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ + address: getAddress(nonChecksummedAddress), + }); + + const wallet = await walletsRepository.findOneByAddressOrFail( + getAddress(nonChecksummedAddress), + ); + + expect(wallet).toEqual( + expect.objectContaining({ + address: getAddress(nonChecksummedAddress), + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ); + }); + + it('should throw an error if wallet is not found', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await expect( + walletsRepository.findOneByAddressOrFail(address), + ).rejects.toThrow(`Wallet not found. Address=${address}`); + }); }); describe('findOneByAddress', () => { - it.todo('should find a wallet by address'); + it('should find a wallet by address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address }); + + const wallet = await walletsRepository.findOneByAddress(address); + + expect(wallet).toEqual( + expect.objectContaining({ + address, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ); + }); + + it('should find a wallet by non-checksummed address', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ + address: getAddress(nonChecksummedAddress), + }); - it.todo('should find a wallet by non-checksummed address'); + const wallet = await walletsRepository.findOneByAddress( + getAddress(nonChecksummedAddress), + ); - it.todo('should return null if wallet is not found'); + expect(wallet).toEqual( + expect.objectContaining({ + address: getAddress(nonChecksummedAddress), + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ); + }); + + it('should return null if wallet is not found', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + const wallet = await walletsRepository.findOneByAddress(address); + + expect(wallet).toBeNull(); + }); }); describe('findByUser', () => { - it.todo('should find wallets by user'); + it('should find wallets by user', async () => { + const status = faker.helpers.enumValue(UserStatus); + + const address1 = getAddress(faker.finance.ethereumAddress()); + const address2 = getAddress(faker.finance.ethereumAddress()); + const userRepository = dataSource.getRepository(User); + const walletRepository = dataSource.getRepository(Wallet); + const user = await userRepository.insert({ status }); + const userId = user.identifiers[0].id as User['id']; + await walletRepository.insert({ + address: address1, + user: { id: userId }, + }); + await walletRepository.insert({ + address: address2, + user: { id: userId }, + }); + + const wallets = await walletsRepository.findByUser(userId); + + expect(wallets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: address1, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + expect.objectContaining({ + address: address2, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ]), + ); + }); + + it('should throw an error if invalid user ID is provided', async () => { + const userId = faker.string.alpha() as unknown as User['id']; + + await expect(walletsRepository.findByUser(userId)).rejects.toThrow( + `invalid input syntax for type integer: "${userId}"`, + ); + }); - it.todo('should throw an error if invalid user ID is provided'); + it('should return an empty array if no wallets are found', async () => { + const userRepository = dataSource.getRepository(User); + const user = await userRepository.insert({ + status: faker.helpers.enumValue(UserStatus), + }); - it.todo('should return an empty array if no wallets are found'); + const wallets = await walletsRepository.findByUser( + user.identifiers[0].id as User['id'], + ); + + expect(wallets).toEqual([]); + }); }); describe('create', () => { - it.todo('should create a wallet'); + it('should create a wallet', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); - it.todo('should checksum the address before saving'); + await postgresDatabaseService.transaction(async (entityManager) => { + const user = await entityManager.getRepository(User).insert({ + status: faker.helpers.enumValue(UserStatus), + }); + await walletsRepository.create( + { + userId: user.identifiers[0].id as User['id'], + walletAddress, + }, + entityManager, + ); + }); - it.todo( - 'should throw an error if wallet with the same address already exists', - ); + const walletRepository = dataSource.getRepository(Wallet); + await expect(walletRepository.find()).resolves.toEqual([ + expect.objectContaining({ + address: walletAddress, + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ]); + }); + + it('should checksum the address before saving', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + + await postgresDatabaseService.transaction(async (entityManager) => { + const user = await entityManager.getRepository(User).insert({ + status: faker.helpers.enumValue(UserStatus), + }); + await walletsRepository.create( + { + userId: user.identifiers[0].id as User['id'], + walletAddress: nonChecksummedAddress as `0x${string}`, + }, + entityManager, + ); + }); + + const walletRepository = dataSource.getRepository(Wallet); + await expect(walletRepository.find()).resolves.toEqual([ + expect.objectContaining({ + address: getAddress(nonChecksummedAddress), + created_at: expect.any(Date), + id: expect.any(Number), + updated_at: expect.any(Date), + }), + ]); + }); + + it('should throw an error if wallet with the same address already exists', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: walletAddress }); + + await expect( + postgresDatabaseService.transaction(async (entityManager) => { + const user = await entityManager.getRepository(User).insert({ + status: faker.helpers.enumValue(UserStatus), + }); + await walletsRepository.create( + { + userId: user.identifiers[0].id as User['id'], + walletAddress, + }, + entityManager, + ); + }), + ).rejects.toThrow( + 'duplicate key value violates unique constraint "UQ_wallet_address"', + ); + }); + + it('should throw an error if non-existent user ID is provided', async () => { + // Ensure not out of range for integer type + const userId = faker.number.int({ max: 100 }); + const walletAddress = getAddress(faker.finance.ethereumAddress()); - it.todo('should throw an error if non-existent user ID is provided'); + await expect( + postgresDatabaseService.transaction(async (entityManager) => { + await walletsRepository.create( + { + userId, + walletAddress, + }, + entityManager, + ); + }), + ).rejects.toThrow( + 'insert or update on table "wallets" violates foreign key constraint', + ); + }); - it.todo('should throw if invalid wallet address is provided'); + it('should throw if invalid wallet address is provided', async () => { + const walletAddress = faker.string.hexadecimal({ + length: { min: 41, max: 41 }, + }); + + await expect( + postgresDatabaseService.transaction(async (entityManager) => { + const user = await entityManager.getRepository(User).insert({ + status: faker.helpers.enumValue(UserStatus), + }); + await walletsRepository.create( + { + userId: user.identifiers[0].id as User['id'], + walletAddress: walletAddress as `0x${string}`, + }, + entityManager, + ); + }), + ).rejects.toThrow(new RegExp(`^Address "${walletAddress}" is invalid.`)); + }); }); describe('deleteByAddress', () => { - it.todo('should delete a wallet by address'); + it('should delete a wallet by address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address }); + + await walletsRepository.deleteByAddress(address); + + await expect(walletRepository.find()).resolves.toEqual([]); + }); + + it('should delete by non-checksummed address', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ + address: getAddress(nonChecksummedAddress), + }); + + await walletsRepository.deleteByAddress( + nonChecksummedAddress as `0x${string}`, + ); + + await expect(walletRepository.find()).resolves.toEqual([]); + }); + + it('should throw if providing invalid wallet address', async () => { + const walletAddress = faker.string.hexadecimal({ + length: { min: 41, max: 41 }, + }); + + await expect( + walletsRepository.deleteByAddress(walletAddress as `0x${string}`), + ).rejects.toThrow(new RegExp(`^Address "${walletAddress}" is invalid.`)); + }); - it.todo('should delete by non-checksummed address'); + it('should throw an error if wallet is not found', async () => { + const address = getAddress(faker.finance.ethereumAddress()); - it.todo('should throw if providing invalid wallet address'); + await expect(walletsRepository.deleteByAddress(address)).resolves.toEqual( + { + affected: 0, + raw: [], + }, + ); + }); - it.todo('should throw an error if wallet is not found'); + it.todo('should delete orphaned users'); }); });