feat(auth): add PATCH /auth/profile endpoint for user profile updates
Implement user profile update with fullName, avatarUrl, and email fields. Email changes include uniqueness validation and Email VO verification. Follows existing DDD/CQRS patterns with cache invalidation. 19 unit tests covering handler logic and DTO validation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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 { UpdateProfileCommand } from '../commands/update-profile/update-profile.command';
|
||||
import { UpdateProfileHandler } from '../commands/update-profile/update-profile.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('UpdateProfileHandler', () => {
|
||||
let handler: UpdateProfileHandler;
|
||||
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
|
||||
|
||||
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(),
|
||||
};
|
||||
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
handler = new UpdateProfileHandler(
|
||||
mockUserRepo as any,
|
||||
mockCache as any,
|
||||
{ error: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates fullName and invalidates cache', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new UpdateProfileCommand('user-1', 'Tran Van B');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(mockUserRepo.findById).toHaveBeenCalledWith('user-1');
|
||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||
expect(result.fullName).toBe('Tran Van B');
|
||||
expect(result.id).toBe('user-1');
|
||||
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates avatarUrl', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new UpdateProfileCommand('user-1', undefined, 'https://cdn.example.com/avatar.jpg');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.avatarUrl).toBe('https://cdn.example.com/avatar.jpg');
|
||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||
});
|
||||
|
||||
it('updates email with uniqueness check', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('throws ConflictException when email is already taken', async () => {
|
||||
const user = createTestUser();
|
||||
const otherUser = createTestUser({ id: 'user-2', email: 'taken@example.com' });
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(otherUser);
|
||||
|
||||
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'taken@example.com');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng');
|
||||
});
|
||||
|
||||
it('skips email uniqueness check 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);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
mockUserRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateProfileCommand('non-existent', 'New Name');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Người dùng');
|
||||
});
|
||||
|
||||
it('does not call update or invalidate when user is not found', async () => {
|
||||
mockUserRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateProfileCommand('non-existent', 'New Name');
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
|
||||
expect(mockUserRepo.update).not.toHaveBeenCalled();
|
||||
expect(mockCache.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid email format', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
|
||||
const command = new UpdateProfileCommand('user-1', undefined, undefined, 'not-an-email');
|
||||
await expect(handler.execute(command)).rejects.toThrow('Email không hợp lệ');
|
||||
});
|
||||
|
||||
it('updates all fields at once', async () => {
|
||||
const user = createTestUser();
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.findByEmail.mockResolvedValue(null);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new UpdateProfileCommand(
|
||||
'user-1',
|
||||
'Le Thi C',
|
||||
'https://cdn.example.com/new.jpg',
|
||||
'new@example.com',
|
||||
);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.fullName).toBe('Le Thi C');
|
||||
expect(result.avatarUrl).toBe('https://cdn.example.com/new.jpg');
|
||||
expect(result.email).toBe('new@example.com');
|
||||
expect(mockUserRepo.update).toHaveBeenCalledWith(user);
|
||||
expect(mockCache.invalidate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export class UpdateProfileCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly fullName?: string,
|
||||
public readonly avatarUrl?: string,
|
||||
public readonly email?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
ConflictException,
|
||||
DomainException,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { UpdateProfileCommand } from './update-profile.command';
|
||||
|
||||
export interface UpdateProfileResultDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
email: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateProfileCommand)
|
||||
export class UpdateProfileHandler implements ICommandHandler<UpdateProfileCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateProfileCommand): Promise<UpdateProfileResultDto> {
|
||||
try {
|
||||
const user = await this.userRepo.findById(command.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng', command.userId);
|
||||
}
|
||||
|
||||
// Validate and resolve email if provided
|
||||
let newEmail: Email | null | undefined;
|
||||
if (command.email !== undefined) {
|
||||
const emailResult = Email.create(command.email);
|
||||
if (emailResult.isErr) {
|
||||
throw new ValidationException(emailResult.unwrapErr());
|
||||
}
|
||||
const email = emailResult.unwrap();
|
||||
|
||||
// Check if email is actually changing
|
||||
if (user.email?.value !== email.value) {
|
||||
// Check uniqueness
|
||||
const existingUser = await this.userRepo.findByEmail(email.value);
|
||||
if (existingUser && existingUser.id !== command.userId) {
|
||||
throw new ConflictException('Email đã được sử dụng bởi tài khoản khác');
|
||||
}
|
||||
newEmail = email;
|
||||
}
|
||||
}
|
||||
|
||||
user.updateProfile(command.fullName, command.avatarUrl, newEmail);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
await this.cache.invalidate(
|
||||
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||
);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email?.value ?? null,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to update profile: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể cập nhật hồ sơ người dùng');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export { RefreshTokenCommand } from './commands/refresh-token/refresh-token.comm
|
||||
export { RefreshTokenHandler } from './commands/refresh-token/refresh-token.handler';
|
||||
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 { 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';
|
||||
|
||||
Reference in New Issue
Block a user