From cfd6fab288d8f085ca57be1eed2867a4d2765334 Mon Sep 17 00:00:00 2001 From: Noelia Date: Thu, 18 Sep 2025 23:52:04 -0600 Subject: [PATCH] feature/seller-soroban-registration --- .../1751199300000-AddSellerSorobanFields.ts | 35 +++ .../seller/controllers/seller.controller.ts | 106 +++++++++ src/modules/seller/dto/build-register.dto.ts | 32 +++ src/modules/seller/dto/submit-register.dto.ts | 30 +++ src/modules/seller/seller.module.ts | 17 ++ src/modules/seller/services/seller.service.ts | 149 +++++++++++++ src/modules/seller/tests/seller.e2e.spec.ts | 201 ++++++++++++++++++ .../seller/tests/seller.service.spec.ts | 187 ++++++++++++++++ src/modules/users/entities/user.entity.ts | 12 +- 9 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 src/migrations/1751199300000-AddSellerSorobanFields.ts create mode 100644 src/modules/seller/controllers/seller.controller.ts create mode 100644 src/modules/seller/dto/build-register.dto.ts create mode 100644 src/modules/seller/dto/submit-register.dto.ts create mode 100644 src/modules/seller/seller.module.ts create mode 100644 src/modules/seller/services/seller.service.ts create mode 100644 src/modules/seller/tests/seller.e2e.spec.ts create mode 100644 src/modules/seller/tests/seller.service.spec.ts diff --git a/src/migrations/1751199300000-AddSellerSorobanFields.ts b/src/migrations/1751199300000-AddSellerSorobanFields.ts new file mode 100644 index 0000000..8ebaa88 --- /dev/null +++ b/src/migrations/1751199300000-AddSellerSorobanFields.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddSellerSorobanFields1751199300000 implements MigrationInterface { + name = 'AddSellerSorobanFields1751199300000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add payout_wallet column + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'payout_wallet', + type: 'varchar', + length: '255', + isNullable: true, + isUnique: true, + }) + ); + + // Add seller_onchain_registered column + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'seller_onchain_registered', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('users', 'seller_onchain_registered'); + await queryRunner.dropColumn('users', 'payout_wallet'); + } +} diff --git a/src/modules/seller/controllers/seller.controller.ts b/src/modules/seller/controllers/seller.controller.ts new file mode 100644 index 0000000..751b02a --- /dev/null +++ b/src/modules/seller/controllers/seller.controller.ts @@ -0,0 +1,106 @@ +import { + Controller, + Post, + Body, + UseGuards, + Request, + HttpCode, + HttpStatus, + Get, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { SellerService } from '../services/seller.service'; +import { BuildRegisterDto, BuildRegisterResponseDto } from '../dto/build-register.dto'; +import { SubmitRegisterDto, SubmitRegisterResponseDto } from '../dto/submit-register.dto'; +import { AuthenticatedRequest } from '../../../types/auth-request.type'; + +@ApiTags('seller') +@Controller('seller/contract') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class SellerController { + constructor(private readonly sellerService: SellerService) {} + + @Post('build-register') + @Roles('seller') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Build unsigned XDR for seller registration', + description: 'Creates an unsigned XDR transaction for registering seller on Soroban blockchain', + }) + @ApiResponse({ + status: 200, + description: 'Unsigned XDR built successfully', + type: BuildRegisterResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request or user not eligible', + }) + @ApiResponse({ + status: 409, + description: 'User already has a payout wallet registered', + }) + async buildRegister( + @Body() buildRegisterDto: BuildRegisterDto, + @Request() req: AuthenticatedRequest, + ): Promise { + const result = await this.sellerService.buildRegister(Number(req.user.id), buildRegisterDto); + + return { + success: true, + data: result, + }; + } + + @Post('submit') + @Roles('seller') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Submit signed XDR for seller registration', + description: 'Submits signed XDR to Soroban network and updates user registration status', + }) + @ApiResponse({ + status: 200, + description: 'Registration completed successfully', + type: SubmitRegisterResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid signed XDR or user not eligible', + }) + async submitRegister( + @Body() submitRegisterDto: SubmitRegisterDto, + @Request() req: AuthenticatedRequest, + ): Promise { + const result = await this.sellerService.submitRegister(Number(req.user.id), submitRegisterDto); + + return { + success: true, + data: result, + }; + } + + @Get('status') + @Roles('seller') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get seller registration status', + description: 'Returns the current registration status of the seller', + }) + @ApiResponse({ + status: 200, + description: 'Registration status retrieved successfully', + }) + async getRegistrationStatus(@Request() req: AuthenticatedRequest) { + const result = await this.sellerService.getRegistrationStatus(Number(req.user.id)); + + return { + success: true, + data: result, + }; + } +} diff --git a/src/modules/seller/dto/build-register.dto.ts b/src/modules/seller/dto/build-register.dto.ts new file mode 100644 index 0000000..7f66ab9 --- /dev/null +++ b/src/modules/seller/dto/build-register.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, Matches } from 'class-validator'; + +export class BuildRegisterDto { + @ApiProperty({ + description: 'Stellar payout wallet address for the seller', + example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + }) + @IsString() + @IsNotEmpty() + @Matches(/^G[A-Z2-7]{55}$/, { + message: 'Invalid Stellar wallet address format', + }) + payoutWallet: string; +} + +export class BuildRegisterResponseDto { + @ApiProperty({ + description: 'Success status', + example: true, + }) + success: boolean; + + @ApiProperty({ + description: 'Unsigned XDR transaction for Soroban contract registration', + example: 'AAAAAgAAAABqjgAAAAAA...', + }) + data: { + unsignedXdr: string; + contractAddress: string; + }; +} diff --git a/src/modules/seller/dto/submit-register.dto.ts b/src/modules/seller/dto/submit-register.dto.ts new file mode 100644 index 0000000..2751532 --- /dev/null +++ b/src/modules/seller/dto/submit-register.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class SubmitRegisterDto { + @ApiProperty({ + description: 'Signed XDR transaction for Soroban contract registration', + example: 'AAAAAgAAAABqjgAAAAAA...', + }) + @IsString() + @IsNotEmpty() + signedXdr: string; +} + +export class SubmitRegisterResponseDto { + @ApiProperty({ + description: 'Success status', + example: true, + }) + success: boolean; + + @ApiProperty({ + description: 'Registration result with transaction hash', + }) + data: { + transactionHash: string; + contractId: string; + payoutWallet: string; + registered: boolean; + }; +} diff --git a/src/modules/seller/seller.module.ts b/src/modules/seller/seller.module.ts new file mode 100644 index 0000000..a488c8f --- /dev/null +++ b/src/modules/seller/seller.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../users/entities/user.entity'; +import { SellerController } from './controllers/seller.controller'; +import { SellerService } from './services/seller.service'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User]), + SharedModule, + ], + controllers: [SellerController], + providers: [SellerService], + exports: [SellerService], +}) +export class SellerModule {} diff --git a/src/modules/seller/services/seller.service.ts b/src/modules/seller/services/seller.service.ts new file mode 100644 index 0000000..03aa62c --- /dev/null +++ b/src/modules/seller/services/seller.service.ts @@ -0,0 +1,149 @@ +import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { BuildRegisterDto } from '../dto/build-register.dto'; +import { SubmitRegisterDto } from '../dto/submit-register.dto'; + +@Injectable() +export class SellerService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + /** + * Build unsigned XDR for seller registration on Soroban + */ + async buildRegister(userId: number, buildRegisterDto: BuildRegisterDto) { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['userRoles', 'userRoles.role'], + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + // Check if user has seller role + const hasSellerRole = user.userRoles?.some( + (userRole) => userRole.role.name === 'seller' + ); + + if (!hasSellerRole) { + throw new BadRequestException('User must have seller role'); + } + + // Check if user already has a payout wallet registered + if (user.payoutWallet) { + throw new ConflictException('User already has a payout wallet registered'); + } + + // Check if the payout wallet is already used by another user + const existingUser = await this.userRepository.findOne({ + where: { payoutWallet: buildRegisterDto.payoutWallet }, + }); + + if (existingUser) { + throw new ConflictException('Payout wallet already registered by another user'); + } + + // TODO: Integrate with Soroban SDK to build actual XDR + // For now, return mock data + const mockUnsignedXdr = this.generateMockXdr(user.walletAddress, buildRegisterDto.payoutWallet); + const contractAddress = 'CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + return { + unsignedXdr: mockUnsignedXdr, + contractAddress, + }; + } + + /** + * Submit signed XDR and update user registration status + */ + async submitRegister(userId: number, submitRegisterDto: SubmitRegisterDto) { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['userRoles', 'userRoles.role'], + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + // Check if user has seller role + const hasSellerRole = user.userRoles?.some( + (userRole) => userRole.role.name === 'seller' + ); + + if (!hasSellerRole) { + throw new BadRequestException('User must have seller role'); + } + + // TODO: Validate signed XDR and submit to Soroban network + // TODO: Extract payout wallet from XDR transaction + // For now, use mock validation and data + + if (!this.validateSignedXdr(submitRegisterDto.signedXdr)) { + throw new BadRequestException('Invalid signed XDR'); + } + + const mockPayoutWallet = this.extractPayoutWalletFromXdr(submitRegisterDto.signedXdr); + const mockTransactionHash = this.generateMockTransactionHash(); + const mockContractId = 'CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + // Update user with payout wallet and registration status + await this.userRepository.update(userId, { + payoutWallet: mockPayoutWallet, + sellerOnchainRegistered: true, + }); + + return { + transactionHash: mockTransactionHash, + contractId: mockContractId, + payoutWallet: mockPayoutWallet, + registered: true, + }; + } + + /** + * Get seller registration status + */ + async getRegistrationStatus(userId: number) { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'payoutWallet', 'sellerOnchainRegistered'], + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + return { + isRegistered: user.sellerOnchainRegistered, + payoutWallet: user.payoutWallet, + }; + } + + // Mock methods - replace with actual Soroban integration + private generateMockXdr(walletAddress: string, payoutWallet: string): string { + const mockData = `${walletAddress}:${payoutWallet}:${Date.now()}`; + return Buffer.from(mockData).toString('base64'); + } + + private validateSignedXdr(signedXdr: string): boolean { + // TODO: Implement actual XDR validation with Soroban SDK + return signedXdr && signedXdr.length > 10; + } + + private extractPayoutWalletFromXdr(signedXdr: string): string { + // TODO: Extract actual payout wallet from XDR + // For now, return a mock wallet + return 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + } + + private generateMockTransactionHash(): string { + return Array(64).fill(0).map(() => Math.floor(Math.random() * 16).toString(16)).join(''); + } +} diff --git a/src/modules/seller/tests/seller.e2e.spec.ts b/src/modules/seller/tests/seller.e2e.spec.ts new file mode 100644 index 0000000..0c1c119 --- /dev/null +++ b/src/modules/seller/tests/seller.e2e.spec.ts @@ -0,0 +1,201 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { SellerModule } from '../seller.module'; +import { AuthModule } from '../../auth/auth.module'; +import { User } from '../../users/entities/user.entity'; +import { Role } from '../../auth/entities/role.entity'; +import { UserRole } from '../../auth/entities/user-role.entity'; + +describe('Seller (e2e)', () => { + let app: INestApplication; + let authToken: string; + let sellerId: number; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + entities: [User, Role, UserRole], + synchronize: true, + dropSchema: true, + }), + JwtModule.register({ + secret: 'test-secret', + signOptions: { expiresIn: '1h' }, + }), + SellerModule, + AuthModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + // Create test user and get auth token + const authResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + }); + + authToken = authResponse.body.data.token; + sellerId = authResponse.body.data.user.id; + + // Assign seller role to user + await request(app.getHttpServer()) + .patch(`/users/${sellerId}/role`) + .set('Authorization', `Bearer ${authToken}`) + .send({ role: 'seller' }); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /seller/contract/build-register', () => { + it('should build unsigned XDR successfully', async () => { + const response = await request(app.getHttpServer()) + .post('/seller/contract/build-register') + .set('Authorization', `Bearer ${authToken}`) + .send({ + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('unsignedXdr'); + expect(response.body.data).toHaveProperty('contractAddress'); + expect(typeof response.body.data.unsignedXdr).toBe('string'); + expect(typeof response.body.data.contractAddress).toBe('string'); + }); + + it('should return 400 for invalid payout wallet format', async () => { + await request(app.getHttpServer()) + .post('/seller/contract/build-register') + .set('Authorization', `Bearer ${authToken}`) + .send({ + payoutWallet: 'invalid-wallet', + }) + .expect(400); + }); + + it('should return 401 without authentication', async () => { + await request(app.getHttpServer()) + .post('/seller/contract/build-register') + .send({ + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }) + .expect(401); + }); + + it('should return 409 when trying to register again', async () => { + // First registration + await request(app.getHttpServer()) + .post('/seller/contract/build-register') + .set('Authorization', `Bearer ${authToken}`) + .send({ + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }) + .expect(200); + + // Submit the registration + await request(app.getHttpServer()) + .post('/seller/contract/submit') + .set('Authorization', `Bearer ${authToken}`) + .send({ + signedXdr: 'AAAAAgAAAABqjgAAAAAA...', + }) + .expect(200); + + // Try to register again - should fail + await request(app.getHttpServer()) + .post('/seller/contract/build-register') + .set('Authorization', `Bearer ${authToken}`) + .send({ + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }) + .expect(409); + }); + }); + + describe('POST /seller/contract/submit', () => { + it('should submit signed XDR and update DB successfully', async () => { + // First build the registration + await request(app.getHttpServer()) + .post('/seller/contract/build-register') + .set('Authorization', `Bearer ${authToken}`) + .send({ + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }) + .expect(200); + + // Then submit + const response = await request(app.getHttpServer()) + .post('/seller/contract/submit') + .set('Authorization', `Bearer ${authToken}`) + .send({ + signedXdr: 'AAAAAgAAAABqjgAAAAAA...', + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('transactionHash'); + expect(response.body.data).toHaveProperty('contractId'); + expect(response.body.data).toHaveProperty('payoutWallet'); + expect(response.body.data.registered).toBe(true); + }); + + it('should return 400 for missing signature', async () => { + await request(app.getHttpServer()) + .post('/seller/contract/submit') + .set('Authorization', `Bearer ${authToken}`) + .send({ + signedXdr: '', + }) + .expect(400); + }); + + it('should return 400 for invalid signed XDR', async () => { + await request(app.getHttpServer()) + .post('/seller/contract/submit') + .set('Authorization', `Bearer ${authToken}`) + .send({ + signedXdr: 'invalid', + }) + .expect(400); + }); + + it('should return 401 without authentication', async () => { + await request(app.getHttpServer()) + .post('/seller/contract/submit') + .send({ + signedXdr: 'AAAAAgAAAABqjgAAAAAA...', + }) + .expect(401); + }); + }); + + describe('GET /seller/contract/status', () => { + it('should return registration status', async () => { + const response = await request(app.getHttpServer()) + .get('/seller/contract/status') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('isRegistered'); + expect(response.body.data).toHaveProperty('payoutWallet'); + }); + + it('should return 401 without authentication', async () => { + await request(app.getHttpServer()) + .get('/seller/contract/status') + .expect(401); + }); + }); +}); diff --git a/src/modules/seller/tests/seller.service.spec.ts b/src/modules/seller/tests/seller.service.spec.ts new file mode 100644 index 0000000..27c4304 --- /dev/null +++ b/src/modules/seller/tests/seller.service.spec.ts @@ -0,0 +1,187 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { SellerService } from '../services/seller.service'; +import { User } from '../../users/entities/user.entity'; +import { BuildRegisterDto } from '../dto/build-register.dto'; +import { SubmitRegisterDto } from '../dto/submit-register.dto'; + +describe('SellerService', () => { + let service: SellerService; + let userRepository: Repository; + + const mockUser = { + id: 1, + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + payoutWallet: null, + sellerOnchainRegistered: false, + userRoles: [ + { + role: { name: 'seller' } + } + ] + }; + + const mockUserRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SellerService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(SellerService); + userRepository = module.get>(getRepositoryToken(User)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('buildRegister', () => { + const buildRegisterDto: BuildRegisterDto = { + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }; + + it('should build unsigned XDR successfully', async () => { + mockUserRepository.findOne + .mockResolvedValueOnce(mockUser) // First call for user lookup + .mockResolvedValueOnce(null); // Second call for payout wallet check + + const result = await service.buildRegister(1, buildRegisterDto); + + expect(result).toHaveProperty('unsignedXdr'); + expect(result).toHaveProperty('contractAddress'); + expect(typeof result.unsignedXdr).toBe('string'); + expect(typeof result.contractAddress).toBe('string'); + }); + + it('should throw BadRequestException when user not found', async () => { + mockUserRepository.findOne.mockResolvedValueOnce(null); + + await expect(service.buildRegister(1, buildRegisterDto)) + .rejects.toThrow(BadRequestException); + + expect(mockUserRepository.findOne).toHaveBeenCalledWith({ + where: { id: 1 }, + relations: ['userRoles', 'userRoles.role'], + }); + }); + + it('should throw BadRequestException when user is not a seller', async () => { + const nonSellerUser = { + ...mockUser, + userRoles: [{ role: { name: 'buyer' } }] + }; + mockUserRepository.findOne.mockResolvedValueOnce(nonSellerUser); + + await expect(service.buildRegister(1, buildRegisterDto)) + .rejects.toThrow(BadRequestException); + }); + + it('should throw ConflictException when user already has payout wallet', async () => { + const userWithWallet = { + ...mockUser, + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + }; + mockUserRepository.findOne.mockResolvedValueOnce(userWithWallet); + + await expect(service.buildRegister(1, buildRegisterDto)) + .rejects.toThrow(ConflictException); + }); + + it('should throw ConflictException when payout wallet already used', async () => { + const existingUserWithWallet = { + id: 2, + payoutWallet: buildRegisterDto.payoutWallet, + }; + + mockUserRepository.findOne + .mockResolvedValueOnce(mockUser) // User lookup + .mockResolvedValueOnce(existingUserWithWallet); // Payout wallet check + + await expect(service.buildRegister(1, buildRegisterDto)) + .rejects.toThrow(ConflictException); + }); + }); + + describe('submitRegister', () => { + const submitRegisterDto: SubmitRegisterDto = { + signedXdr: 'AAAAAgAAAABqjgAAAAAA...', + }; + + it('should submit registration successfully', async () => { + mockUserRepository.findOne.mockResolvedValueOnce(mockUser); + mockUserRepository.update.mockResolvedValueOnce({ affected: 1 }); + + const result = await service.submitRegister(1, submitRegisterDto); + + expect(result).toHaveProperty('transactionHash'); + expect(result).toHaveProperty('contractId'); + expect(result).toHaveProperty('payoutWallet'); + expect(result.registered).toBe(true); + expect(mockUserRepository.update).toHaveBeenCalledWith(1, { + payoutWallet: expect.any(String), + sellerOnchainRegistered: true, + }); + }); + + it('should throw BadRequestException when user not found', async () => { + mockUserRepository.findOne.mockResolvedValueOnce(null); + + await expect(service.submitRegister(1, submitRegisterDto)) + .rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when user is not a seller', async () => { + const nonSellerUser = { + ...mockUser, + userRoles: [{ role: { name: 'buyer' } }] + }; + mockUserRepository.findOne.mockResolvedValueOnce(nonSellerUser); + + await expect(service.submitRegister(1, submitRegisterDto)) + .rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for invalid signed XDR', async () => { + mockUserRepository.findOne.mockResolvedValueOnce(mockUser); + const invalidDto = { signedXdr: 'invalid' }; + + await expect(service.submitRegister(1, invalidDto)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('getRegistrationStatus', () => { + it('should return registration status', async () => { + const registeredUser = { + ...mockUser, + payoutWallet: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXY', + sellerOnchainRegistered: true, + }; + mockUserRepository.findOne.mockResolvedValueOnce(registeredUser); + + const result = await service.getRegistrationStatus(1); + + expect(result.isRegistered).toBe(true); + expect(result.payoutWallet).toBe(registeredUser.payoutWallet); + }); + + it('should throw BadRequestException when user not found', async () => { + mockUserRepository.findOne.mockResolvedValueOnce(null); + + await expect(service.getRegistrationStatus(1)) + .rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index f191c38..f52231e 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -15,15 +15,21 @@ import { Wishlist } from '../../wishlist/entities/wishlist.entity'; export class User { @PrimaryGeneratedColumn() id: number; - + @Column({ unique: true, nullable: true }) email?: string; - + @Column({ nullable: true }) name?: string; - + @Column({ unique: true }) walletAddress: string; + + @Column({ unique: true, nullable: true }) + payoutWallet?: string; + + @Column({ default: false }) + sellerOnchainRegistered: boolean; @OneToMany(() => Order, (order) => order.user) orders: Order[];