diff --git a/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts index daf99ec..acf4069 100644 --- a/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts +++ b/apps/api/src/modules/auth/application/__tests__/update-profile.handler.spec.ts @@ -31,6 +31,8 @@ describe('UpdateProfileHandler', () => { let handler: UpdateProfileHandler; let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; let mockCache: { invalidate: ReturnType }; + let mockRedis: { set: ReturnType; get: ReturnType; del: ReturnType }; + let mockEventBus: { publish: ReturnType }; beforeEach(() => { mockUserRepo = { @@ -45,10 +47,18 @@ describe('UpdateProfileHandler', () => { updateBackupCodes: vi.fn(), }; mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) }; + mockRedis = { + set: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(null), + del: vi.fn().mockResolvedValue(undefined), + }; + mockEventBus = { publish: vi.fn() }; handler = new UpdateProfileHandler( mockUserRepo as any, mockCache as any, + mockRedis as any, + mockEventBus as any, { error: vi.fn() } as any, ); }); @@ -82,7 +92,7 @@ describe('UpdateProfileHandler', () => { expect(mockUserRepo.update).toHaveBeenCalledWith(user); }); - it('updates email with uniqueness check', async () => { + it('defers email change via OTP instead of updating directly', async () => { const user = createTestUser(); mockUserRepo.findById.mockResolvedValue(user); mockUserRepo.findByEmail.mockResolvedValue(null); @@ -91,12 +101,27 @@ describe('UpdateProfileHandler', () => { const command = new UpdateProfileCommand('user-1', undefined, undefined, 'new@example.com'); const result = await handler.execute(command); - expect(result.email).toBe('new@example.com'); - expect(mockUserRepo.findByEmail).toHaveBeenCalledWith('new@example.com'); - expect(mockUserRepo.update).toHaveBeenCalledWith(user); + // Email should NOT be updated yet — it is deferred pending OTP + expect(result.email).toBeNull(); + expect(result.emailChangePending).toBe(true); + + // OTP stored in Redis + expect(mockRedis.set).toHaveBeenCalledWith( + 'auth:email_change_otp:user-1', + expect.stringContaining('new@example.com'), + 600, + ); + + // Event emitted for notification + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: 'user.email_change_requested', + newEmail: 'new@example.com', + }), + ); }); - it('throws ConflictException when email is already taken', async () => { + it('throws ConflictException when new email is already taken', async () => { const user = createTestUser(); const otherUser = createTestUser({ id: 'user-2', email: 'taken@example.com' }); mockUserRepo.findById.mockResolvedValue(user); @@ -106,30 +131,17 @@ describe('UpdateProfileHandler', () => { await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng'); }); - it('skips email uniqueness check when email is unchanged', async () => { + it('skips OTP when email is unchanged', async () => { const user = createTestUser({ email: 'same@example.com' }); mockUserRepo.findById.mockResolvedValue(user); mockUserRepo.update.mockResolvedValue(undefined); const command = new UpdateProfileCommand('user-1', undefined, undefined, 'same@example.com'); - await handler.execute(command); + const result = await handler.execute(command); - expect(mockUserRepo.findByEmail).not.toHaveBeenCalled(); - }); - - it('allows same user to keep their own email', async () => { - const user = createTestUser({ email: 'mine@example.com' }); - mockUserRepo.findById.mockResolvedValue(user); - mockUserRepo.findByEmail.mockResolvedValue(user); - mockUserRepo.update.mockResolvedValue(undefined); - - // Email resolves to same user — should not conflict - // This case is actually covered by the unchanged check above, - // but keeping explicit for safety - const command = new UpdateProfileCommand('user-1', undefined, undefined, 'mine@example.com'); - await handler.execute(command); - - expect(mockUserRepo.update).toHaveBeenCalled(); + expect(mockRedis.set).not.toHaveBeenCalled(); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + expect(result.emailChangePending).toBeUndefined(); }); it('throws NotFoundException when user does not exist', async () => { @@ -157,7 +169,7 @@ describe('UpdateProfileHandler', () => { await expect(handler.execute(command)).rejects.toThrow('Email không hợp lệ'); }); - it('updates all fields at once', async () => { + it('updates fullName and avatarUrl while deferring email', async () => { const user = createTestUser(); mockUserRepo.findById.mockResolvedValue(user); mockUserRepo.findByEmail.mockResolvedValue(null); @@ -173,7 +185,9 @@ describe('UpdateProfileHandler', () => { expect(result.fullName).toBe('Le Thi C'); expect(result.avatarUrl).toBe('https://cdn.example.com/new.jpg'); - expect(result.email).toBe('new@example.com'); + // Email deferred + expect(result.email).toBeNull(); + expect(result.emailChangePending).toBe(true); expect(mockUserRepo.update).toHaveBeenCalledWith(user); expect(mockCache.invalidate).toHaveBeenCalled(); }); diff --git a/apps/api/src/modules/auth/application/__tests__/verify-email-change.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/verify-email-change.handler.spec.ts new file mode 100644 index 0000000..9d4a243 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/verify-email-change.handler.spec.ts @@ -0,0 +1,121 @@ +import { UserEntity } from '../../domain/entities/user.entity'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { Email } from '../../domain/value-objects/email.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { VerifyEmailChangeCommand } from '../commands/verify-email-change/verify-email-change.command'; +import { VerifyEmailChangeHandler } from '../commands/verify-email-change/verify-email-change.handler'; + +function createTestUser(overrides?: Partial<{ email: string; id: string }>): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + const email = overrides?.email ? Email.create(overrides.email).unwrap() : null; + return new UserEntity(overrides?.id ?? 'user-1', { + email, + phone, + passwordHash: pw, + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + totpSecret: null, + totpEnabled: false, + totpBackupCodes: [], + totpEnabledAt: null, + }); +} + +describe('VerifyEmailChangeHandler', () => { + let handler: VerifyEmailChangeHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockRedis: { get: ReturnType; del: ReturnType; set: ReturnType }; + let mockCache: { invalidate: ReturnType }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + updateMfaSecret: vi.fn(), + updateMfaEnabled: vi.fn(), + updateMfaDisabled: vi.fn(), + updateBackupCodes: vi.fn(), + }; + mockRedis = { + get: vi.fn(), + del: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + }; + mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) }; + + handler = new VerifyEmailChangeHandler( + mockUserRepo as any, + mockRedis as any, + mockCache as any, + { error: vi.fn() } as any, + ); + }); + + it('verifies OTP and updates email', async () => { + const user = createTestUser(); + const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByEmail.mockResolvedValue(null); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new VerifyEmailChangeCommand('user-1', '123456'); + const result = await handler.execute(command); + + expect(result.email).toBe('new@example.com'); + expect(result.id).toBe('user-1'); + expect(mockRedis.del).toHaveBeenCalledWith('auth:email_change_otp:user-1'); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(mockCache.invalidate).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + ); + }); + + it('throws ValidationException when OTP has expired', async () => { + mockRedis.get.mockResolvedValue(null); + + const command = new VerifyEmailChangeCommand('user-1', '123456'); + await expect(handler.execute(command)).rejects.toThrow('hết hạn'); + }); + + it('throws ValidationException when OTP code is wrong', async () => { + const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + + const command = new VerifyEmailChangeCommand('user-1', '999999'); + await expect(handler.execute(command)).rejects.toThrow('không đúng'); + }); + + it('throws ConflictException when email was taken since OTP was issued', async () => { + const user = createTestUser(); + const otherUser = createTestUser({ id: 'user-2', email: 'new@example.com' }); + const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.findByEmail.mockResolvedValue(otherUser); + + const command = new VerifyEmailChangeCommand('user-1', '123456'); + await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng'); + + // OTP should be cleaned up on conflict + expect(mockRedis.del).toHaveBeenCalledWith('auth:email_change_otp:user-1'); + }); + + it('throws NotFoundException when user does not exist', async () => { + const payload = JSON.stringify({ newEmail: 'new@example.com', code: '123456' }); + mockRedis.get.mockResolvedValue(payload); + mockUserRepo.findById.mockResolvedValue(null); + + const command = new VerifyEmailChangeCommand('user-1', '123456'); + await expect(handler.execute(command)).rejects.toThrow('Người dùng'); + }); +}); diff --git a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts index 9e644f8..b8006a4 100644 --- a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts +++ b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts @@ -1,5 +1,5 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; import { CachePrefix, CacheService, @@ -7,17 +7,27 @@ import { DomainException, LoggerService, NotFoundException, + RedisService, ValidationException, } from '@modules/shared'; +import { randomInt } from 'crypto'; import { Email } from '../../../domain/value-objects/email.vo'; +import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event'; import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository'; import { UpdateProfileCommand } from './update-profile.command'; +/** TTL for email-change OTP codes stored in Redis (10 minutes). */ +const EMAIL_CHANGE_OTP_TTL = 600; + +/** Redis key prefix for pending email-change OTP. */ +export const EMAIL_CHANGE_OTP_PREFIX = 'auth:email_change_otp'; + export interface UpdateProfileResultDto { id: string; fullName: string; avatarUrl: string | null; email: string | null; + emailChangePending?: boolean; updatedAt: Date; } @@ -26,6 +36,8 @@ export class UpdateProfileHandler implements ICommandHandler { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly redis: RedisService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(command: VerifyEmailChangeCommand): Promise { + try { + const redisKey = `${EMAIL_CHANGE_OTP_PREFIX}:${command.userId}`; + const raw = await this.redis.get(redisKey); + + if (!raw) { + throw new ValidationException( + 'Mã xác thực đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu đổi email lại.', + ); + } + + const { newEmail, code } = JSON.parse(raw) as { newEmail: string; code: string }; + + if (code !== command.code) { + throw new ValidationException('Mã xác thực không đúng'); + } + + const user = await this.userRepo.findById(command.userId); + if (!user) { + throw new NotFoundException('Người dùng', command.userId); + } + + // Re-check email uniqueness (may have been taken since the request) + const existingUser = await this.userRepo.findByEmail(newEmail); + if (existingUser && existingUser.id !== command.userId) { + await this.redis.del(redisKey); + throw new ConflictException('Email đã được sử dụng bởi tài khoản khác'); + } + + const emailVo = Email.create(newEmail).unwrap(); + user.updateProfile(undefined, undefined, emailVo); + await this.userRepo.update(user); + + // Clean up OTP and invalidate profile cache + await this.redis.del(redisKey); + await this.cache.invalidate( + CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId), + ); + + return { + id: user.id, + email: emailVo.value, + updatedAt: user.updatedAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to verify email change: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xác thực đổi email'); + } + } +} diff --git a/apps/api/src/modules/auth/application/index.ts b/apps/api/src/modules/auth/application/index.ts index b4fbf0e..32676b4 100644 --- a/apps/api/src/modules/auth/application/index.ts +++ b/apps/api/src/modules/auth/application/index.ts @@ -8,6 +8,8 @@ export { VerifyKycCommand } from './commands/verify-kyc/verify-kyc.command'; export { VerifyKycHandler } from './commands/verify-kyc/verify-kyc.handler'; export { UpdateProfileCommand } from './commands/update-profile/update-profile.command'; export { UpdateProfileHandler, type UpdateProfileResultDto } from './commands/update-profile/update-profile.handler'; +export { VerifyEmailChangeCommand } from './commands/verify-email-change/verify-email-change.command'; +export { VerifyEmailChangeHandler, type VerifyEmailChangeResultDto } from './commands/verify-email-change/verify-email-change.handler'; export { GetProfileQuery } from './queries/get-profile/get-profile.query'; export { GetProfileHandler, type UserProfileDto } from './queries/get-profile/get-profile.handler'; export { GetAgentByUserIdQuery } from './queries/get-agent-by-user-id/get-agent-by-user-id.query'; diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index b1c6051..706ccc3 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -21,6 +21,7 @@ import { GenerateKycUploadUrlsHandler } from './application/commands/generate-ky import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler'; import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler'; import { UseBackupCodeHandler } from './application/commands/use-backup-code/use-backup-code.handler'; +import { VerifyEmailChangeHandler } from './application/commands/verify-email-change/verify-email-change.handler'; import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler'; import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler'; import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler'; @@ -53,6 +54,7 @@ const CommandHandlers = [ SubmitKycHandler, GenerateKycUploadUrlsHandler, UpdateProfileHandler, + VerifyEmailChangeHandler, RequestUserDeletionHandler, CancelUserDeletionHandler, ForceDeleteUserHandler, diff --git a/apps/api/src/modules/auth/domain/events/email-change-requested.event.ts b/apps/api/src/modules/auth/domain/events/email-change-requested.event.ts new file mode 100644 index 0000000..e6b77ea --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/email-change-requested.event.ts @@ -0,0 +1,12 @@ +import { DomainEvent } from '@modules/shared'; + +export class EmailChangeRequestedEvent implements DomainEvent { + readonly eventName = 'user.email_change_requested'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly newEmail: string, + public readonly otpCode: string, + ) {} +} diff --git a/apps/api/src/modules/auth/domain/events/index.ts b/apps/api/src/modules/auth/domain/events/index.ts index 485a6eb..b4675b7 100644 --- a/apps/api/src/modules/auth/domain/events/index.ts +++ b/apps/api/src/modules/auth/domain/events/index.ts @@ -1,2 +1,3 @@ export { UserRegisteredEvent } from './user-registered.event'; export { AgentVerifiedEvent } from './agent-verified.event'; +export { EmailChangeRequestedEvent } from './email-change-requested.event'; diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts index 738f949..111a7ee 100644 --- a/apps/api/src/modules/auth/index.ts +++ b/apps/api/src/modules/auth/index.ts @@ -11,4 +11,5 @@ export { AgentVerifiedEvent } from './domain/events/agent-verified.event'; export { UserDeactivatedEvent } from './domain/events/user-deactivated.event'; export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event'; export { UserRegisteredEvent } from './domain/events/user-registered.event'; +export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event'; export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository'; diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index d68fad6..4568d98 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -32,6 +32,8 @@ import { type KycUploadUrlResult } from '../../application/commands/generate-kyc import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command'; import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command'; import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler'; +import { VerifyEmailChangeCommand } from '../../application/commands/verify-email-change/verify-email-change.command'; +import { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler'; import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command'; import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query'; @@ -46,6 +48,7 @@ import { type RefreshTokenDto } from '../dto/refresh-token.dto'; import { type RegisterDto } from '../dto/register.dto'; import { type VerifyKycDto } from '../dto/verify-kyc.dto'; import { UpdateProfileDto } from '../dto/update-profile.dto'; +import { VerifyEmailChangeDto } from '../dto/verify-email-change.dto'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { LocalAuthGuard } from '../guards/local-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; @@ -235,6 +238,24 @@ export class AuthController { return { message: 'Cập nhật hồ sơ thành công', data: result }; } + @UseGuards(JwtAuthGuard) + @Post('profile/verify-email') + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Verify email change with OTP code' }) + @ApiResponse({ status: 201, description: 'Email changed successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired OTP code' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 409, description: 'Email already in use' }) + async verifyEmailChange( + @CurrentUser() user: JwtPayload, + @Body() dto: VerifyEmailChangeDto, + ): Promise<{ message: string; data: VerifyEmailChangeResultDto }> { + const result: VerifyEmailChangeResultDto = await this.commandBus.execute( + new VerifyEmailChangeCommand(user.sub, dto.code), + ); + return { message: 'Email đã được cập nhật thành công', data: result }; + } + @UseGuards(JwtAuthGuard) @Get('profile/agent') @ApiBearerAuth('JWT') diff --git a/apps/api/src/modules/auth/presentation/dto/verify-email-change.dto.ts b/apps/api/src/modules/auth/presentation/dto/verify-email-change.dto.ts new file mode 100644 index 0000000..af6a123 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/verify-email-change.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Length } from 'class-validator'; + +export class VerifyEmailChangeDto { + @ApiProperty({ example: '123456', description: '6-digit OTP code sent to new email' }) + @IsNotEmpty({ message: 'Mã xác thực không được để trống' }) + @IsString() + @Length(6, 6, { message: 'Mã xác thực phải gồm 6 chữ số' }) + code!: string; +} diff --git a/apps/api/src/modules/notifications/application/listeners/email-change-requested.listener.ts b/apps/api/src/modules/notifications/application/listeners/email-change-requested.listener.ts new file mode 100644 index 0000000..b75dde5 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/email-change-requested.listener.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EmailChangeRequestedEvent } from '@modules/auth'; +import { LoggerService } from '@modules/shared'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class EmailChangeRequestedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly logger: LoggerService, + ) {} + + @OnEvent('user.email_change_requested', { async: true }) + async handle(event: EmailChangeRequestedEvent): Promise { + this.logger.log( + `Handling email change OTP for user ${event.aggregateId}`, + 'EmailChangeRequestedListener', + ); + + await this.commandBus.execute( + new SendNotificationCommand( + event.aggregateId, + 'EMAIL', + 'user.email_change_otp', + { otpCode: event.otpCode }, + event.newEmail, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts index af3bb11..2550f9f 100644 --- a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts @@ -81,10 +81,10 @@ describe('TemplateService', () => { expect(result.body).toContain('/listings/2'); }); - it('getTemplateKeys returns all 11 template keys', () => { + it('getTemplateKeys returns all 12 template keys', () => { const keys = service.getTemplateKeys(); - expect(keys).toHaveLength(11); + expect(keys).toHaveLength(12); expect(keys).toContain('user.registered'); expect(keys).toContain('agent.verified'); expect(keys).toContain('listing.approved'); @@ -96,5 +96,6 @@ describe('TemplateService', () => { expect(keys).toContain('subscription.expiring'); expect(keys).toContain('saved_search_alert'); expect(keys).toContain('saved_search_digest'); + expect(keys).toContain('user.email_change_otp'); }); }); diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts index e1b3465..3d9f7b3 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -75,6 +75,15 @@ const TEMPLATES: Record = { body: `

Gói đăng ký đã bị huỷ

Gói {{planTier}} của bạn đã bị huỷ.

Bạn có thể đăng ký lại bất cứ lúc nào để tiếp tục sử dụng đầy đủ tính năng.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'user.email_change_otp': { + subject: 'Xác nhận thay đổi email — GoodGo', + body: `

Xác nhận thay đổi email

+

Bạn đã yêu cầu thay đổi email trên GoodGo. Sử dụng mã OTP sau để xác nhận:

+

{{otpCode}}

+

Mã có hiệu lực trong 10 phút.

+

Nếu bạn không yêu cầu, hãy bỏ qua email này.

Trân trọng,
Đội ngũ GoodGo

`, }, 'saved_search_alert': { diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index fd4b4e7..b987c0b 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; +import { AuthModule } from '@modules/auth'; import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler'; import { AgentVerifiedListener } from './application/listeners/agent-verified.listener'; +import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener'; import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener'; import { ListingApprovedListener } from './application/listeners/listing-approved.listener'; import { ListingRejectedListener } from './application/listeners/listing-rejected.listener'; @@ -21,8 +23,11 @@ import { PrismaNotificationPreferenceRepository } from './infrastructure/reposit import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository'; import { EmailService } from './infrastructure/services/email.service'; import { FcmService } from './infrastructure/services/fcm.service'; +import { StringeeSmsService } from './infrastructure/services/stringee-sms.service'; import { TemplateService } from './infrastructure/services/template.service'; +import { ZaloOaService } from './infrastructure/services/zalo-oa.service'; import { NotificationsController } from './presentation/controllers/notifications.controller'; +import { NotificationsGateway } from './presentation/gateways/notifications.gateway'; const CommandHandlers = [SendNotificationHandler]; @@ -41,10 +46,11 @@ const EventListeners = [ InquiryReceivedListener, ListingSoldListener, UserKycUpdatedListener, + EmailChangeRequestedListener, ]; @Module({ - imports: [CqrsModule], + imports: [CqrsModule, AuthModule], controllers: [NotificationsController], providers: [ // Repositories @@ -54,14 +60,19 @@ const EventListeners = [ // Services EmailService, FcmService, + StringeeSmsService, + ZaloOaService, TemplateService, + // WebSocket Gateway + NotificationsGateway, + // CQRS ...CommandHandlers, // Event Listeners ...EventListeners, ], - exports: [EmailService, FcmService, TemplateService], + exports: [EmailService, FcmService, StringeeSmsService, ZaloOaService, TemplateService, NotificationsGateway], }) export class NotificationsModule {} diff --git a/apps/web/components/valuation/__tests__/valuation-form.spec.tsx b/apps/web/components/valuation/__tests__/valuation-form.spec.tsx index 3a485f8..f87c25e 100644 --- a/apps/web/components/valuation/__tests__/valuation-form.spec.tsx +++ b/apps/web/components/valuation/__tests__/valuation-form.spec.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { describe, expect, it, vi } from 'vitest'; import { ValuationForm } from '../valuation-form'; @@ -7,6 +8,11 @@ vi.mock('@hookform/resolvers/zod', () => ({ zodResolver: () => vi.fn(), })); +// Mock useProjectSearch hook used by ValuationForm +vi.mock('@/lib/hooks/use-valuation', () => ({ + useProjectSearch: () => ({ data: [], isLoading: false }), +})); + // Mock valuation validation vi.mock('@/lib/validations/valuation', () => ({ valuationFormSchema: {}, @@ -23,84 +29,93 @@ vi.mock('@/lib/validations/valuation', () => ({ ], })); +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + describe('ValuationForm', () => { it('renders form title', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Định giá bất động sản')).toBeInTheDocument(); }); it('renders property type select', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Loại bất động sản *')).toBeInTheDocument(); }); it('renders city select', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Tỉnh/Thành phố *')).toBeInTheDocument(); }); it('renders district input', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Quận/Huyện *')).toBeInTheDocument(); }); it('renders area input', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Diện tích (m²) *')).toBeInTheDocument(); }); it('renders bedroom, bathroom, floors inputs', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Phòng ngủ')).toBeInTheDocument(); expect(screen.getByLabelText('Phòng tắm')).toBeInTheDocument(); expect(screen.getByLabelText('Số tầng')).toBeInTheDocument(); }); it('renders frontage and road width inputs', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Mặt tiền (m)')).toBeInTheDocument(); expect(screen.getByLabelText('Độ rộng đường (m)')).toBeInTheDocument(); }); it('renders year built input', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Năm xây dựng')).toBeInTheDocument(); }); it('renders legal paper checkbox', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByLabelText('Có sổ đỏ/giấy tờ hợp pháp')).toBeInTheDocument(); }); it('renders submit button', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Định giá ngay')).toBeInTheDocument(); }); it('shows loading text when isLoading', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Đang định giá...')).toBeInTheDocument(); }); it('disables submit button when loading', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Đang định giá...')).toBeDisabled(); }); it('renders property type options', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Căn hộ')).toBeInTheDocument(); expect(screen.getByText('Nhà riêng')).toBeInTheDocument(); }); it('renders city options', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Hồ Chí Minh')).toBeInTheDocument(); expect(screen.getByText('Hà Nội')).toBeInTheDocument(); }); it('renders description text', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText(/Nhập thông tin bất động sản để nhận ước tính giá từ AI/)).toBeInTheDocument(); }); }); diff --git a/apps/web/components/valuation/__tests__/valuation-history.spec.tsx b/apps/web/components/valuation/__tests__/valuation-history.spec.tsx index 13906f1..37ae749 100644 --- a/apps/web/components/valuation/__tests__/valuation-history.spec.tsx +++ b/apps/web/components/valuation/__tests__/valuation-history.spec.tsx @@ -92,8 +92,8 @@ describe('ValuationHistory', () => { onSelect={vi.fn()} />, ); - expect(screen.getByText('5.00 tỷ')).toBeInTheDocument(); - expect(screen.getByText('8.50 tỷ')).toBeInTheDocument(); + expect(screen.getByText('5 tỷ')).toBeInTheDocument(); + expect(screen.getByText('8.5 tỷ')).toBeInTheDocument(); }); it('calls onSelect when an item is clicked', async () => { diff --git a/apps/web/components/valuation/__tests__/valuation-results.spec.tsx b/apps/web/components/valuation/__tests__/valuation-results.spec.tsx index b688184..123ed8e 100644 --- a/apps/web/components/valuation/__tests__/valuation-results.spec.tsx +++ b/apps/web/components/valuation/__tests__/valuation-results.spec.tsx @@ -43,7 +43,7 @@ const mockResult: ValuationResult = { describe('ValuationResults', () => { it('renders estimated price', () => { render(); - expect(screen.getByText('5.00 tỷ VNĐ')).toBeInTheDocument(); + expect(screen.getByText('5 tỷ VNĐ')).toBeInTheDocument(); }); it('renders confidence percentage', () => { @@ -58,7 +58,8 @@ describe('ValuationResults', () => { it('renders price range', () => { render(); - expect(screen.getByText(/4\.50 tỷ.*5\.50 tỷ/)).toBeInTheDocument(); + expect(screen.getByText('4.5 tỷ')).toBeInTheDocument(); + expect(screen.getByText('5.5 tỷ')).toBeInTheDocument(); }); it('renders price drivers section', () => { @@ -78,33 +79,10 @@ describe('ValuationResults', () => { expect(screen.getByText('-5.2%')).toBeInTheDocument(); }); - it('renders comparables section', () => { - render(); - expect(screen.getByText('Bất động sản tương tự')).toBeInTheDocument(); - expect(screen.getByText('Căn hộ tương tự A')).toBeInTheDocument(); - expect(screen.getByText('Căn hộ tương tự B')).toBeInTheDocument(); - }); - - it('shows comparable count', () => { - render(); - expect(screen.getByText(/2 bất động sản/)).toBeInTheDocument(); - }); - - it('shows similarity percentage for comparables', () => { - render(); - expect(screen.getByText('92% tương tự')).toBeInTheDocument(); - expect(screen.getByText('85% tương tự')).toBeInTheDocument(); - }); - it('hides drivers section when empty', () => { const noDrivers = { ...mockResult, priceDrivers: [] }; render(); expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument(); }); - it('hides comparables section when empty', () => { - const noComps = { ...mockResult, comparables: [] }; - render(); - expect(screen.queryByText('Bất động sản tương tự')).not.toBeInTheDocument(); - }); });