Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add wallet to user #2296

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/datasources/db/v2/postgres-error-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum PostgresErrorCode {
UniqueViolation = '23505',
}
12 changes: 2 additions & 10 deletions src/datasources/users/entities/wallets.entity.db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { RowSchema } from '@/datasources/db/v1/entities/row.entity';
import {
Column,
Entity,
Expand All @@ -7,20 +6,13 @@ import {
ManyToOne,
JoinColumn,
} from 'typeorm';
import { z } from 'zod';
import { getAddress } from 'viem';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
import { User } from '@/datasources/users/entities/users.entity.db';
import { UserSchema } from '@/domain/users/entities/user.entity';

export const WalletSchema = RowSchema.extend({
address: AddressSchema,
user: UserSchema,
});
import type { Wallet as DomainWallet } from '@/domain/users/entities/wallet.entity';

@Entity('wallets')
@Unique('UQ_wallet_address', ['address'])
export class Wallet implements z.infer<typeof WalletSchema> {
export class Wallet implements DomainWallet {
@PrimaryGeneratedColumn({ primaryKeyConstraintName: 'PK_wallet_id' })
id!: number;

Expand Down
11 changes: 11 additions & 0 deletions src/domain/users/entities/wallet.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { z } from 'zod';
import { RowSchema } from '@/datasources/db/v1/entities/row.entity';
import { UserSchema } from '@/domain/users/entities/user.entity';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';

export type Wallet = z.infer<typeof WalletSchema>;

export const WalletSchema = RowSchema.extend({
address: AddressSchema,
user: UserSchema,
});
9 changes: 8 additions & 1 deletion src/domain/users/users.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
import type { User, UserStatus } from '@/domain/users/entities/user.entity';
import type { UserStatus } from '@/domain/users/entities/user.entity';
import type { Wallet } from '@/domain/users/entities/wallet.entity';
iamacook marked this conversation as resolved.
Show resolved Hide resolved
import type { User } from '@/domain/users/entities/user.entity';

export const IUsersRepository = Symbol('IUsersRepository');

Expand All @@ -8,4 +10,9 @@ export interface IUsersRepository {
status: UserStatus;
authPayload: AuthPayload;
}): Promise<Pick<User, 'id'>>;

addWalletToUser(args: {
newSignerAddress: `0x${string}`;
authPayload: AuthPayload;
}): Promise<Pick<Wallet, 'id'>>;
}
90 changes: 89 additions & 1 deletion src/domain/users/users.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder';
import { mockPostgresDatabaseService } from '@/datasources/db/v2/__tests__/postgresql-database.service.mock';
import { mockRepository } from '@/datasources/db/v2/__tests__/repository.mock';
import type { EntityManager } from 'typeorm';
import { QueryFailedError, type EntityManager } from 'typeorm';
import { User } from '@/datasources/users/entities/users.entity.db';
import { Wallet } from '@/datasources/users/entities/wallets.entity.db';
import { userBuilder } from '@/datasources/users/entities/__tests__/users.entity.db.builder';
import { walletBuilder } from '@/datasources/users/entities/__tests__/wallets.entity.db.builder';
import { faker } from '@faker-js/faker/.';
import {
ConflictException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { getAddress } from 'viem';

let usersRepository: IUsersRepository;
const mockUserRepository = { ...mockRepository };
Expand Down Expand Up @@ -63,4 +71,84 @@ describe('UsersRepository', () => {
expect(result).toEqual({ id: mockUser.id });
});
});

describe('addWalletToUser', () => {
iamacook marked this conversation as resolved.
Show resolved Hide resolved
it('should add a wallet to a user', async () => {
const authPayloadDto = authPayloadDtoBuilder().build();
const authPayload = new AuthPayload(authPayloadDto);

const newSignerAddressMock = getAddress(faker.finance.ethereumAddress());
const mockWallet = walletBuilder().build();
mockWalletRepository.findOne.mockResolvedValueOnce(mockWallet);
mockWalletRepository.insert.mockResolvedValue({
identifiers: [{ id: mockWallet.id }],
generatedMaps: [{ id: 1 }],
raw: jest.fn(),
});

const result = await usersRepository.addWalletToUser({
authPayload,
newSignerAddress: newSignerAddressMock,
});

expect(mockWalletRepository.insert).toHaveBeenCalledWith({
user: mockWallet.user,
address: newSignerAddressMock,
});

expect(result).toEqual({ id: mockWallet.id });
});

it('should throw an UnauthorizedException if the auth payload is empty', async () => {
const authPayload = new AuthPayload();
const newSignerAddressMock = getAddress(faker.finance.ethereumAddress());

await expect(
usersRepository.addWalletToUser({
authPayload,
newSignerAddress: newSignerAddressMock,
}),
).rejects.toThrow(UnauthorizedException);
});

it('should throw a NotFoundException if the user is not found', async () => {
const authPayloadDto = authPayloadDtoBuilder().build();
const authPayload = new AuthPayload(authPayloadDto);

const newSignerAddressMock = getAddress(faker.finance.ethereumAddress());
mockWalletRepository.findOne.mockResolvedValueOnce(null);

await expect(
usersRepository.addWalletToUser({
authPayload,
newSignerAddress: newSignerAddressMock,
}),
).rejects.toThrow(new NotFoundException('User not found'));
});

it('should throw a ConflictException if the wallet already exists', async () => {
const authPayloadDto = authPayloadDtoBuilder().build();
const authPayload = new AuthPayload(authPayloadDto);

const newSignerAddressMock = getAddress(faker.finance.ethereumAddress());
const mockWallet = walletBuilder().build();
const mockUniqueConstraintError = new QueryFailedError(
'query',
[],
Object.assign(new Error(), { driverError: { code: '23505' } }),
);

mockWalletRepository.findOne.mockResolvedValueOnce(mockWallet);
mockWalletRepository.insert.mockRejectedValue(mockUniqueConstraintError);

await expect(
usersRepository.addWalletToUser({
authPayload,
newSignerAddress: newSignerAddressMock,
}),
).rejects.toThrow(
new ConflictException('A wallet with the same address already exists'),
);
});
});
});
57 changes: 52 additions & 5 deletions src/domain/users/users.repository.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { ConflictException, Injectable } from '@nestjs/common';
import {
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { IUsersRepository } from '@/domain/users/users.repository.interface';
import { User, UserStatus } from '@/domain/users/entities/user.entity';
import { UserStatus } from '@/domain/users/entities/user.entity';
import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service';
import { User as DbUser } from '@/datasources/users/entities/users.entity.db';
import { User } from '@/datasources/users/entities/users.entity.db';
import { Wallet } from '@/datasources/users/entities/wallets.entity.db';
import { EntityManager } from 'typeorm';
import { EntityManager, QueryFailedError } from 'typeorm';
import { PostgresErrorCode } from '@/datasources/db/v2/postgres-error-codes';

@Injectable()
export class UsersRepository implements IUsersRepository {
Expand All @@ -19,7 +25,7 @@ export class UsersRepository implements IUsersRepository {
}): Promise<Pick<User, 'id'>> {
return this.postgresDatabaseService.transaction(
async (entityManager: EntityManager) => {
const userRepository = entityManager.getRepository(DbUser);
const userRepository = entityManager.getRepository(User);
const walletRepository = entityManager.getRepository(Wallet);

const existingWallet = await walletRepository.findOne({
Expand All @@ -46,4 +52,45 @@ export class UsersRepository implements IUsersRepository {
},
);
}

async addWalletToUser(args: {
newSignerAddress: `0x${string}`;
authPayload: AuthPayload;
}): Promise<Pick<Wallet, 'id'>> {
if (!args.authPayload.signer_address) {
throw new UnauthorizedException();
}
return await this.postgresDatabaseService.transaction(
async (entityManager: EntityManager) => {
const walletRepository = entityManager.getRepository(Wallet);

const authenticatedWallet = await walletRepository.findOne({
where: { address: args.authPayload.signer_address },
iamacook marked this conversation as resolved.
Show resolved Hide resolved
relations: { user: true },
});

if (!authenticatedWallet?.user) {
throw new NotFoundException('User not found');
}

try {
const walletInsertResult = await walletRepository.insert({
user: authenticatedWallet.user,
address: args.newSignerAddress,
});
return { id: walletInsertResult.identifiers[0].id };
} catch (error) {
if (
error instanceof QueryFailedError &&
error.driverError.code === PostgresErrorCode.UniqueViolation
) {
throw new ConflictException(
`A wallet with the same address already exists`,
);
}
throw error;
}
},
);
}
}