From 8392bdb300a6bf95cb18c12e2bf40854561bdd31 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 4 Feb 2025 12:35:24 +0100 Subject: [PATCH] fix: add integration tests for user domain --- .../users.repository.integration.spec.ts | 627 ++++++++++++++++++ src/domain/users/users.repository.ts | 8 + .../wallets.repository.integration.spec.ts | 73 ++ src/routes/users/users.controller.ts | 4 +- 4 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 src/domain/users/users.repository.integration.spec.ts create mode 100644 src/domain/wallets/wallets.repository.integration.spec.ts diff --git a/src/domain/users/users.repository.integration.spec.ts b/src/domain/users/users.repository.integration.spec.ts new file mode 100644 index 0000000000..b7102d2d86 --- /dev/null +++ b/src/domain/users/users.repository.integration.spec.ts @@ -0,0 +1,627 @@ +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 { UsersRepository } from '@/domain/users/users.repository'; +import { WalletsRepository } from '@/domain/wallets/wallets.repository'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { UserStatus } from '@/domain/users/entities/user.entity'; +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'; + +const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + +describe('UsersRepository', () => { + let postgresDatabaseService: PostgresDatabaseService; + let usersRepository: UsersRepository; + + 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(); + + usersRepository = new UsersRepository( + postgresDatabaseService, + 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('createWithWallet', () => { + it('should insert a new user and a linked wallet', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + + await usersRepository.createWithWallet({ status, authPayload }); + + const walletRepository = dataSource.getRepository(Wallet); + const userRepository = dataSource.getRepository(User); + const wallet = await walletRepository.findOneOrFail({ + where: { address: authPayload.signer_address }, + relations: { user: true }, + }); + const user = await userRepository.findOneOrFail({ + where: { id: wallet.user.id }, + }); + + expect(wallet).toStrictEqual( + expect.objectContaining({ + address: authPayload.signer_address, + created_at: expect.any(Date), + id: wallet.id, + updated_at: expect.any(Date), + user: expect.objectContaining({ + created_at: expect.any(Date), + id: user.id, + status, + updated_at: expect.any(Date), + }), + }), + ); + expect(user).toStrictEqual( + expect.objectContaining({ + created_at: expect.any(Date), + id: user.id, + status, + updated_at: expect.any(Date), + }), + ); + }); + + it('should throw an error if the wallet already exists', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + + await usersRepository.createWithWallet({ status, authPayload }); + + await expect( + usersRepository.createWithWallet({ status, authPayload }), + ).rejects.toThrow( + `A wallet with the same address already exists. Wallet=${authPayload.signer_address}`, + ); + }); + + it('should throw if an incorrect UserStatus is provided', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.string.alpha() as unknown as UserStatus; + + await expect( + usersRepository.createWithWallet({ status, authPayload }), + ).rejects.toThrow(`invalid input syntax for type integer: "${status}"`); + }); + + it('should throw if an invalid wallet address is provided', async () => { + const signerAddress = faker.string.hexadecimal({ + length: { min: 41, max: 41 }, + }); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress as `0x${string}`) + .build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + + await expect( + usersRepository.createWithWallet({ status, authPayload }), + ).rejects.toThrow(new RegExp(`^Address "${signerAddress}" is invalid.`)); + }); + + it('should checksum the inserted wallet address', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', nonChecksummedAddress as `0x${string}`) + .build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + + await usersRepository.createWithWallet({ status, authPayload }); + + const walletRepository = dataSource.getRepository(Wallet); + const wallet = await walletRepository.findOneOrFail({ + where: { address: authPayload.signer_address }, + }); + + expect(wallet).toStrictEqual( + expect.objectContaining({ + address: getAddress(nonChecksummedAddress), + }), + ); + }); + }); + + describe('create', () => { + it('should insert a new user', async () => { + const status = faker.helpers.enumValue(UserStatus); + + await postgresDatabaseService.transaction(async (entityManager) => { + await usersRepository.create(status, entityManager); + }); + + const userRepository = dataSource.getRepository(User); + const users = await userRepository.find(); + + expect(users).toStrictEqual([ + expect.objectContaining({ + created_at: expect.any(Date), + id: users[0].id, + status, + updated_at: expect.any(Date), + }), + ]); + }); + + it('should throw if an incorrect UserStatus is provided', async () => { + const status = faker.string.alpha() as unknown as UserStatus; + + await expect( + postgresDatabaseService.transaction(async (entityManager) => { + await usersRepository.create(status, entityManager); + }), + ).rejects.toThrow(`invalid input syntax for type integer: "${status}"`); + }); + }); + + describe('getWithWallets', () => { + it('should return a user with their wallets', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + + await entityManager.insert(Wallet, { + user: { + id: userInsertResult.identifiers[0].id, + }, + address: authPayloadDto.signer_address, + }); + }); + const walletRepository = dataSource.getRepository(Wallet); + const userRepository = dataSource.getRepository(User); + const wallet = await walletRepository.findOneOrFail({ + where: { address: authPayload.signer_address }, + relations: { user: true }, + }); + const user = await userRepository.findOneOrFail({ + where: { id: wallet.user.id }, + }); + + await expect( + usersRepository.getWithWallets(authPayload), + ).resolves.toEqual({ + id: user.id, + status, + wallets: [ + { + id: wallet.id, + address: authPayload.signer_address, + }, + ], + }); + }); + + it('should throw if no user wallet is found', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + const userRepository = dataSource.getRepository(User); + await userRepository.insert({ status }); + + await expect(usersRepository.getWithWallets(authPayload)).rejects.toThrow( + `Wallet not found. Address=${authPayload.signer_address}`, + ); + }); + + it('should throw if no user is found', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: authPayload.signer_address }); + + await expect(usersRepository.getWithWallets(authPayload)).rejects.toThrow( + 'User not found.', + ); + }); + + it('should find by non-checksummed address', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', nonChecksummedAddress as `0x${string}`) + .build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + + await entityManager.insert(Wallet, { + user: { + id: userInsertResult.identifiers[0].id, + }, + address: authPayloadDto.signer_address, + }); + }); + const walletRepository = dataSource.getRepository(Wallet); + const userRepository = dataSource.getRepository(User); + const wallet = await walletRepository.findOneOrFail({ + where: { address: getAddress(nonChecksummedAddress) }, + relations: { user: true }, + }); + const user = await userRepository.findOneOrFail({ + where: { id: wallet.user.id }, + }); + + await expect( + usersRepository.getWithWallets(authPayload), + ).resolves.toEqual({ + id: user.id, + status, + wallets: [ + { + id: wallet.id, + address: getAddress(nonChecksummedAddress), + }, + ], + }); + }); + }); + + describe('addWalletToUser', () => { + it('should add a wallet to a user', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + + await entityManager.insert(Wallet, { + user: { + id: userInsertResult.identifiers[0].id, + }, + address: authPayloadDto.signer_address, + }); + }); + + await usersRepository.addWalletToUser({ + authPayload, + walletAddress, + }); + + const walletRepository = dataSource.getRepository(Wallet); + const wallet = await walletRepository.findOneOrFail({ + where: { address: walletAddress }, + relations: { user: true }, + }); + expect(wallet).toStrictEqual( + expect.objectContaining({ + address: walletAddress, + created_at: expect.any(Date), + id: wallet.id, + updated_at: expect.any(Date), + user: expect.objectContaining({ + created_at: expect.any(Date), + id: wallet.user.id, + status, + updated_at: expect.any(Date), + }), + }), + ); + }); + + it('should throw if the user wallet already exists', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: authPayloadDto.signer_address }); + + await expect( + usersRepository.addWalletToUser({ + authPayload, + walletAddress: authPayloadDto.signer_address, + }), + ).rejects.toThrow( + `A wallet with the same address already exists. Wallet=${authPayloadDto.signer_address}`, + ); + }); + + it('should throw if an invalid wallet address is provided', async () => { + const walletAddress = faker.string.hexadecimal({ + length: { min: 41, max: 41 }, + }); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + const userRepository = dataSource.getRepository(User); + await userRepository.insert({ status }); + + await expect( + usersRepository.addWalletToUser({ + authPayload, + walletAddress: walletAddress as `0x${string}`, + }), + ).rejects.toThrow(new RegExp(`^Address "${walletAddress}" is invalid.`)); + }); + + it('should checksum the inserted wallet address', async () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + + await entityManager.insert(Wallet, { + user: { + id: userInsertResult.identifiers[0].id, + }, + address: authPayloadDto.signer_address, + }); + }); + + await usersRepository.addWalletToUser({ + authPayload, + walletAddress: nonChecksummedAddress as `0x${string}`, + }); + + const walletRepository = dataSource.getRepository(Wallet); + const wallet = await walletRepository.findOneOrFail({ + where: { address: getAddress(nonChecksummedAddress) }, + relations: { user: true }, + }); + expect(wallet).toStrictEqual( + expect.objectContaining({ + address: getAddress(nonChecksummedAddress), + }), + ); + }); + }); + + describe('delete', () => { + it('should delete a user and their wallets', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + const userId = userInsertResult.identifiers[0].id; + + await entityManager.insert(Wallet, { + user: { + id: userId, + }, + address: authPayloadDto.signer_address, + }); + await entityManager.insert(Wallet, { + user: { + id: userId, + }, + address: walletAddress, + }); + }); + + await usersRepository.delete(authPayload); + + await expect(dataSource.getRepository(User).find()).resolves.toEqual([]); + // By cascade + await expect(dataSource.getRepository(Wallet).find()).resolves.toEqual( + [], + ); + }); + + it('should throw if no user wallet is found', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + const userRepository = dataSource.getRepository(User); + await userRepository.insert({ status }); + + await expect(usersRepository.delete(authPayload)).rejects.toThrow( + `Wallet not found. Address=${authPayload.signer_address}`, + ); + }); + + it('should throw if no user is found', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: authPayloadDto.signer_address }); + + await expect(usersRepository.delete(authPayload)).rejects.toThrow( + 'User not found.', + ); + }); + }); + + describe('deleteWalletFromUser', () => { + it('should delete a wallet from a user', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + const userId = userInsertResult.identifiers[0].id; + + await entityManager.insert(Wallet, { + user: { + id: userId, + }, + address: authPayloadDto.signer_address, + }); + await entityManager.insert(Wallet, { + user: { + id: userId, + }, + address: walletAddress, + }); + }); + + await usersRepository.deleteWalletFromUser({ + walletAddress, + authPayload, + }); + + const walletRepository = dataSource.getRepository(Wallet); + const wallets = await walletRepository.find({ + relations: { user: true }, + }); + expect(wallets).toEqual([ + { + address: authPayload.signer_address, + created_at: expect.any(Date), + id: wallets[0].id, + updated_at: expect.any(Date), + user: expect.objectContaining({ + created_at: expect.any(Date), + id: wallets[0].user.id, + status, + updated_at: expect.any(Date), + }), + }, + ]); + }); + + it('should throw if no user is found', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const walletRepository = dataSource.getRepository(Wallet); + await walletRepository.insert({ address: walletAddress }); + + await expect( + usersRepository.deleteWalletFromUser({ + walletAddress, + authPayload, + }), + ).rejects.toThrow('User not found.'); + }); + + it('should throw if no wallet is found', async () => { + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const status = faker.helpers.enumValue(UserStatus); + await postgresDatabaseService.transaction(async (entityManager) => { + const userInsertResult = await entityManager.insert(User, { + status, + }); + + await entityManager.insert(Wallet, { + user: { + id: userInsertResult.identifiers[0].id, + }, + address: authPayloadDto.signer_address, + }); + }); + + await expect( + usersRepository.deleteWalletFromUser({ + walletAddress, + authPayload, + }), + ).rejects.toThrow('Wallet not found.'); + }); + }); +}); diff --git a/src/domain/users/users.repository.ts b/src/domain/users/users.repository.ts index 2c8572dc3b..ee47e2e4fe 100644 --- a/src/domain/users/users.repository.ts +++ b/src/domain/users/users.repository.ts @@ -71,6 +71,10 @@ export class UsersRepository implements IUsersRepository { { user: true }, ); + if (!wallet.user) { + throw new NotFoundException('User not found.'); + } + const wallets = await this.walletsRepository.findByUser(wallet.user.id, { address: true, id: true, @@ -120,6 +124,10 @@ export class UsersRepository implements IUsersRepository { { user: true }, ); + if (!wallet.user) { + throw new NotFoundException('User not found.'); + } + await userRepository.delete({ id: wallet.user.id, }); diff --git a/src/domain/wallets/wallets.repository.integration.spec.ts b/src/domain/wallets/wallets.repository.integration.spec.ts new file mode 100644 index 0000000000..d6e860e671 --- /dev/null +++ b/src/domain/wallets/wallets.repository.integration.spec.ts @@ -0,0 +1,73 @@ +describe('WalletsRepository', () => { + describe('findOneOrFail', () => { + it.todo('should find a wallet'); + + it.todo('should throw an error if wallet is not found'); + }); + + describe('findOne', () => { + it.todo('should find a wallet'); + + it.todo('should return null if wallet is not found'); + }); + + describe('findOrFail', () => { + it.todo('should find wallets'); + + it.todo('should throw an error if no wallets are found'); + }); + + describe('find', () => { + it.todo('should find wallets'); + + it.todo('should return an empty array if no wallets are found'); + }); + + describe('findOneByAddressOrFail', () => { + it.todo('should find a wallet by address'); + + it.todo('should find a wallet by non-checksummed address'); + + it.todo('should throw an error if wallet is not found'); + }); + + describe('findOneByAddress', () => { + it.todo('should find a wallet by address'); + + it.todo('should find a wallet by non-checksummed address'); + + it.todo('should return null if wallet is not found'); + }); + + describe('findByUser', () => { + it.todo('should find wallets by user'); + + it.todo('should throw an error if invalid user ID is provided'); + + it.todo('should return an empty array if no wallets are found'); + }); + + describe('create', () => { + it.todo('should create a wallet'); + + it.todo('should checksum the address before saving'); + + it.todo( + 'should throw an error if wallet with the same address already exists', + ); + + it.todo('should throw an error if non-existent user ID is provided'); + + it.todo('should throw if invalid wallet address is provided'); + }); + + describe('deleteByAddress', () => { + it.todo('should delete a wallet by address'); + + it.todo('should delete by non-checksummed address'); + + it.todo('should throw if providing invalid wallet address'); + + it.todo('should throw an error if wallet is not found'); + }); +}); diff --git a/src/routes/users/users.controller.ts b/src/routes/users/users.controller.ts index 4f90055e9a..33ac7020df 100644 --- a/src/routes/users/users.controller.ts +++ b/src/routes/users/users.controller.ts @@ -30,7 +30,7 @@ export class UsersController { @ApiOkResponse({ type: UserWithWallets }) @ApiUnauthorizedResponse({ description: 'Signer address not provided' }) - @ApiNotFoundResponse({ description: 'Wallet not found' }) + @ApiNotFoundResponse({ description: 'User (wallet) not found' }) @Get() @UseGuards(AuthGuard) public async getWithWallets( @@ -41,7 +41,7 @@ export class UsersController { @ApiOkResponse({ description: 'User deleted' }) @ApiUnauthorizedResponse({ description: 'Signer address not provided' }) - @ApiNotFoundResponse({ description: 'Wallet not found' }) + @ApiNotFoundResponse({ description: 'User (wallet) not found' }) @Delete() @UseGuards(AuthGuard) public async delete(@Auth() authPayload: AuthPayload): Promise {