diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts index 73a1f26..3cc6885 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/auth/auth.controller.spec.ts @@ -2,24 +2,56 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { Services } from 'src/utils/constants'; -import { APP_GUARD } from '@nestjs/core'; import { GoogleRecaptchaGuard } from '@nestlab/google-recaptcha'; +import { Response } from 'express'; describe('AuthController', () => { let controller: AuthController; - - const mockAuthService = { - register: jest.fn(), - login: jest.fn(), - logout: jest.fn(), - verifyEmail: jest.fn(), - resendVerificationEmail: jest.fn(), - forgotPassword: jest.fn(), - resetPassword: jest.fn(), - refreshTokens: jest.fn(), - }; + let mockAuthService: any; + let mockEmailVerificationService: any; + let mockJwtTokenService: any; + let mockPasswordService: any; + let mockUserService: any; + let mockResponse: Partial; beforeEach(async () => { + mockAuthService = { + registerUser: jest.fn(), + login: jest.fn(), + checkEmailExistence: jest.fn(), + createOAuthCode: jest.fn(), + verifyGoogleIdToken: jest.fn(), + }; + + mockEmailVerificationService = { + sendVerificationEmail: jest.fn(), + resendVerificationEmail: jest.fn(), + verifyEmail: jest.fn(), + }; + + mockJwtTokenService = { + generateAccessToken: jest.fn(), + setAuthCookies: jest.fn(), + }; + + mockPasswordService = { + requestPasswordReset: jest.fn(), + verifyResetToken: jest.fn(), + resetPassword: jest.fn(), + }; + + mockUserService = { + findOne: jest.fn(), + }; + + mockResponse = { + clearCookie: jest.fn(), + redirect: jest.fn(), + setHeader: jest.fn(), + send: jest.fn(), + json: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], providers: [ @@ -27,29 +59,21 @@ describe('AuthController', () => { provide: Services.AUTH, useValue: mockAuthService, }, - { - provide: Services.EMAIL, - useValue: {}, - }, - { - provide: Services.PASSWORD, - useValue: {}, - }, { provide: Services.EMAIL_VERIFICATION, - useValue: {}, + useValue: mockEmailVerificationService, }, { provide: Services.JWT_TOKEN, - useValue: {}, + useValue: mockJwtTokenService, }, { - provide: Services.OTP, - useValue: {}, + provide: Services.PASSWORD, + useValue: mockPasswordService, }, { provide: Services.USER, - useValue: {}, + useValue: mockUserService, }, ], }) @@ -63,4 +87,214 @@ describe('AuthController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('register', () => { + it('should register a user and set cookies', async () => { + const createUserDto = { + name: 'John Doe', + email: 'test@example.com', + password: 'Password123!', + }; + const registeredUser = { + id: 1, + username: 'john_doe', + role: 'USER', + email: 'test@example.com', + has_completed_following: false, + has_completed_interests: false, + Profile: { name: 'John Doe', profile_image_url: null, birth_date: null }, + }; + mockAuthService.registerUser.mockResolvedValue(registeredUser); + mockJwtTokenService.generateAccessToken.mockResolvedValue('access_token'); + + const result = await controller.register(createUserDto as any, mockResponse as Response); + + expect(mockAuthService.registerUser).toHaveBeenCalledWith(createUserDto); + expect(mockJwtTokenService.setAuthCookies).toHaveBeenCalled(); + expect(result.status).toBe('success'); + expect(result.data.user.id).toBe(1); + }); + }); + + describe('login', () => { + it('should login user and set cookies', async () => { + const mockRequest = { + user: { sub: 1, username: 'john_doe' }, + }; + mockAuthService.login.mockResolvedValue({ + accessToken: 'access_token', + user: { id: 1, username: 'john_doe' }, + onboarding: { hasCompeletedFollowing: false }, + }); + + const result = await controller.login(mockRequest as any, mockResponse as Response); + + expect(mockJwtTokenService.setAuthCookies).toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); + }); + + describe('getMe', () => { + it('should return current user data', async () => { + const mockUser = { id: 1 }; + mockUserService.findOne.mockResolvedValue({ + username: 'john_doe', + role: 'USER', + email: 'test@example.com', + has_completed_following: false, + has_completed_interests: false, + Profile: { name: 'John Doe', profile_image_url: null, birth_date: null }, + }); + + const result = await controller.getMe(mockUser as any); + + expect(result.status).toBe('success'); + expect(result.data.user.id).toBe(1); + }); + }); + + describe('logout', () => { + it('should clear cookies', () => { + const result = controller.logout(mockResponse as Response); + + expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token'); + expect(mockResponse.clearCookie).toHaveBeenCalledWith('refresh_token'); + expect(result.message).toBe('Logout successful'); + }); + }); + + describe('checkEmail', () => { + it('should check email availability', async () => { + mockAuthService.checkEmailExistence.mockResolvedValue(undefined); + + const result = await controller.checkEmail({ email: 'test@example.com' }); + + expect(mockAuthService.checkEmailExistence).toHaveBeenCalledWith('test@example.com'); + expect(result.message).toBe('Email is available'); + }); + }); + + describe('generateVerificationEmail', () => { + it('should send verification email', async () => { + mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(undefined); + + const result = await controller.generateVerificationEmail({ email: 'test@example.com' }); + + expect(mockEmailVerificationService.sendVerificationEmail).toHaveBeenCalledWith('test@example.com'); + expect(result.status).toBe('success'); + }); + }); + + describe('resendVerificationEmail', () => { + it('should resend verification email', async () => { + mockEmailVerificationService.resendVerificationEmail.mockResolvedValue(undefined); + + const result = await controller.resendVerificationEmail({ email: 'test@example.com' }); + + expect(mockEmailVerificationService.resendVerificationEmail).toHaveBeenCalledWith('test@example.com'); + expect(result.status).toBe('success'); + }); + }); + + describe('verifyEmailOtp', () => { + it('should verify OTP successfully', async () => { + mockEmailVerificationService.verifyEmail.mockResolvedValue(true); + + const result = await controller.verifyEmailOtp({ email: 'test@example.com', otp: '123456' }); + + expect(result.status).toBe('success'); + expect(result.message).toBe('email verified'); + }); + + it('should return fail status when OTP is invalid', async () => { + mockEmailVerificationService.verifyEmail.mockResolvedValue(false); + + const result = await controller.verifyEmailOtp({ email: 'test@example.com', otp: 'wrong' }); + + expect(result.status).toBe('fail'); + }); + }); + + describe('verifyRecaptcha', () => { + it('should return success for valid recaptcha', () => { + const result = controller.verifyRecaptcha({ recaptchaToken: 'valid_token' } as any); + + expect(result.status).toBe('success'); + expect(result.message).toBe('Human verification successful.'); + }); + }); + + describe('requestPasswordReset', () => { + it('should request password reset', async () => { + mockPasswordService.requestPasswordReset.mockResolvedValue(undefined); + + const result = await controller.requestPasswordReset({ email: 'test@example.com' }); + + expect(mockPasswordService.requestPasswordReset).toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); + }); + + describe('verifyResetToken', () => { + it('should verify reset token', async () => { + mockPasswordService.verifyResetToken.mockResolvedValue(true); + + const result = await controller.verifyResetToken({ userId: 1, token: 'valid_token' }); + + expect(result.status).toBe('success'); + expect(result.data.valid).toBe(true); + }); + }); + + describe('resetPassword', () => { + it('should reset password', async () => { + mockPasswordService.verifyResetToken.mockResolvedValue(true); + mockPasswordService.resetPassword.mockResolvedValue(undefined); + + const result = await controller.resetPassword({ + userId: 1, + token: 'valid_token', + newPassword: 'NewPassword123!', + email: 'test@example.com', + } as any); + + expect(mockPasswordService.verifyResetToken).toHaveBeenCalled(); + expect(mockPasswordService.resetPassword).toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); + }); + + describe('googleLogin', () => { + it('should return success message', () => { + const result = controller.googleLogin(); + + expect(result.status).toBe('success'); + }); + }); + + describe('githubLogin', () => { + it('should return undefined (handled by guard)', () => { + const result = controller.githubLogin(); + + expect(result).toBeUndefined(); + }); + }); + + describe('googleMobileLogin', () => { + it('should login via Google mobile token', async () => { + mockAuthService.verifyGoogleIdToken.mockResolvedValue({ + accessToken: 'access_token', + result: { + user: { id: 1, username: 'john' }, + onboarding: { hasCompeletedFollowing: false }, + }, + }); + + await controller.googleMobileLogin({ idToken: 'google_id_token' }, mockResponse as Response); + + expect(mockJwtTokenService.setAuthCookies).toHaveBeenCalled(); + expect(mockResponse.json).toHaveBeenCalled(); + }); + }); }); + diff --git a/src/auth/decorators/current-user.decorator.spec.ts b/src/auth/decorators/current-user.decorator.spec.ts new file mode 100644 index 0000000..46011dd --- /dev/null +++ b/src/auth/decorators/current-user.decorator.spec.ts @@ -0,0 +1,99 @@ +import { ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { CurrentUser } from './current-user.decorator'; + +describe('CurrentUser Decorator', () => { + // Helper to get decorator factory + function getParamDecoratorFactory(decorator: Function) { + class TestClass { + testMethod(@decorator() value: any) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestClass, 'testMethod'); + return args[Object.keys(args)[0]].factory; + } + + function getParamDecoratorFactoryWithData(decorator: Function, data: any) { + class TestClass { + testMethod(@decorator(data) value: any) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestClass, 'testMethod'); + return args[Object.keys(args)[0]].factory; + } + + it('should return full user when no data key specified', () => { + const factory = getParamDecoratorFactory(CurrentUser); + const mockUser = { id: 1, email: 'test@test.com', username: 'testuser' }; + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + user: mockUser, + }), + }), + } as unknown as ExecutionContext; + + const result = factory(undefined, mockContext); + + expect(result).toEqual(mockUser); + }); + + it('should return specific property when data key is specified', () => { + class TestClass { + testMethod(@CurrentUser('id') value: any) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestClass, 'testMethod'); + const factory = args[Object.keys(args)[0]].factory; + + const mockUser = { id: 1, email: 'test@test.com', username: 'testuser' }; + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + user: mockUser, + }), + }), + } as unknown as ExecutionContext; + + const result = factory('id', mockContext); + + expect(result).toBe(1); + }); + + it('should return email property when email key is specified', () => { + class TestClass { + testMethod(@CurrentUser('email') value: any) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestClass, 'testMethod'); + const factory = args[Object.keys(args)[0]].factory; + + const mockUser = { id: 1, email: 'test@test.com', username: 'testuser' }; + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + user: mockUser, + }), + }), + } as unknown as ExecutionContext; + + const result = factory('email', mockContext); + + expect(result).toBe('test@test.com'); + }); + + it('should handle undefined user gracefully', () => { + const factory = getParamDecoratorFactory(CurrentUser); + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + user: undefined, + }), + }), + } as unknown as ExecutionContext; + + const result = factory(undefined, mockContext); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/auth/decorators/optional-auth.decorator.spec.ts b/src/auth/decorators/optional-auth.decorator.spec.ts new file mode 100644 index 0000000..33f236f --- /dev/null +++ b/src/auth/decorators/optional-auth.decorator.spec.ts @@ -0,0 +1,30 @@ +import { Reflector } from '@nestjs/core'; +import { OptionalAuth, IS_OPTIONAL_AUTH_KEY } from './optional-auth.decorator'; + +describe('OptionalAuth Decorator', () => { + it('should set IS_OPTIONAL_AUTH_KEY metadata to true', () => { + @OptionalAuth() + class TestClass {} + + const reflector = new Reflector(); + const isOptionalAuth = reflector.get(IS_OPTIONAL_AUTH_KEY, TestClass); + + expect(isOptionalAuth).toBe(true); + }); + + it('should export IS_OPTIONAL_AUTH_KEY constant', () => { + expect(IS_OPTIONAL_AUTH_KEY).toBe('IS_OPTIONAL_AUTH'); + }); + + it('should work on methods', () => { + class TestClass { + @OptionalAuth() + testMethod() {} + } + + const reflector = new Reflector(); + const isOptionalAuth = reflector.get(IS_OPTIONAL_AUTH_KEY, TestClass.prototype.testMethod); + + expect(isOptionalAuth).toBe(true); + }); +}); diff --git a/src/auth/decorators/public.decorator.spec.ts b/src/auth/decorators/public.decorator.spec.ts new file mode 100644 index 0000000..309b4ad --- /dev/null +++ b/src/auth/decorators/public.decorator.spec.ts @@ -0,0 +1,30 @@ +import { Reflector } from '@nestjs/core'; +import { Public, IS_PUBLIC_KEY } from './public.decorator'; + +describe('Public Decorator', () => { + it('should set IS_PUBLIC_KEY metadata to true', () => { + @Public() + class TestClass {} + + const reflector = new Reflector(); + const isPublic = reflector.get(IS_PUBLIC_KEY, TestClass); + + expect(isPublic).toBe(true); + }); + + it('should export IS_PUBLIC_KEY constant', () => { + expect(IS_PUBLIC_KEY).toBe('IS_PUBLIC'); + }); + + it('should work on methods', () => { + class TestClass { + @Public() + testMethod() {} + } + + const reflector = new Reflector(); + const isPublic = reflector.get(IS_PUBLIC_KEY, TestClass.prototype.testMethod); + + expect(isPublic).toBe(true); + }); +}); diff --git a/src/auth/guards/github-auth/github-auth.guard.spec.ts b/src/auth/guards/github-auth/github-auth.guard.spec.ts index 0a7609d..03df632 100644 --- a/src/auth/guards/github-auth/github-auth.guard.spec.ts +++ b/src/auth/guards/github-auth/github-auth.guard.spec.ts @@ -1,7 +1,67 @@ +import { ExecutionContext } from '@nestjs/common'; import { GithubAuthGuard } from './github-auth.guard'; describe('GithubAuthGuard', () => { + let guard: GithubAuthGuard; + + beforeEach(() => { + guard = new GithubAuthGuard(); + }); + it('should be defined', () => { - expect(new GithubAuthGuard()).toBeDefined(); + expect(guard).toBeDefined(); + }); + + describe('getAuthenticateOptions', () => { + it('should return options with default web platform when no platform specified', () => { + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + query: {}, + }), + }), + } as ExecutionContext; + + const options = guard.getAuthenticateOptions(mockContext); + + expect(options).toEqual({ + scope: ['user:email'], + state: 'web', + }); + }); + + it('should return options with custom platform from query', () => { + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { platform: 'mobile' }, + }), + }), + } as ExecutionContext; + + const options = guard.getAuthenticateOptions(mockContext); + + expect(options).toEqual({ + scope: ['user:email'], + state: 'mobile', + }); + }); + + it('should return options with ios platform', () => { + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { platform: 'ios' }, + }), + }), + } as ExecutionContext; + + const options = guard.getAuthenticateOptions(mockContext); + + expect(options).toEqual({ + scope: ['user:email'], + state: 'ios', + }); + }); }); }); diff --git a/src/auth/guards/google-auth/google-auth.guard.spec.ts b/src/auth/guards/google-auth/google-auth.guard.spec.ts index 7c5e791..d2e963a 100644 --- a/src/auth/guards/google-auth/google-auth.guard.spec.ts +++ b/src/auth/guards/google-auth/google-auth.guard.spec.ts @@ -1,7 +1,67 @@ +import { ExecutionContext } from '@nestjs/common'; import { GoogleAuthGuard } from './google-auth.guard'; describe('GoogleAuthGuard', () => { + let guard: GoogleAuthGuard; + + beforeEach(() => { + guard = new GoogleAuthGuard(); + }); + it('should be defined', () => { - expect(new GoogleAuthGuard()).toBeDefined(); + expect(guard).toBeDefined(); + }); + + describe('getAuthenticateOptions', () => { + it('should return options with default web platform when no platform specified', () => { + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + query: {}, + }), + }), + } as ExecutionContext; + + const options = guard.getAuthenticateOptions(mockContext); + + expect(options).toEqual({ + scope: ['profile', 'email'], + state: 'web', + }); + }); + + it('should return options with custom platform from query', () => { + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { platform: 'mobile' }, + }), + }), + } as ExecutionContext; + + const options = guard.getAuthenticateOptions(mockContext); + + expect(options).toEqual({ + scope: ['profile', 'email'], + state: 'mobile', + }); + }); + + it('should return options with android platform', () => { + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { platform: 'android' }, + }), + }), + } as ExecutionContext; + + const options = guard.getAuthenticateOptions(mockContext); + + expect(options).toEqual({ + scope: ['profile', 'email'], + state: 'android', + }); + }); }); }); diff --git a/src/auth/guards/jwt-auth/jwt-auth.guard.spec.ts b/src/auth/guards/jwt-auth/jwt-auth.guard.spec.ts index acf13bb..35de835 100644 --- a/src/auth/guards/jwt-auth/jwt-auth.guard.spec.ts +++ b/src/auth/guards/jwt-auth/jwt-auth.guard.spec.ts @@ -1,6 +1,7 @@ import { JwtAuthGuard } from './jwt-auth.guard'; import { ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from 'src/auth/decorators/public.decorator'; describe('JwtAuthGuard', () => { let guard: JwtAuthGuard; @@ -14,4 +15,70 @@ describe('JwtAuthGuard', () => { it('should be defined', () => { expect(guard).toBeDefined(); }); + + describe('canActivate', () => { + let mockContext: ExecutionContext; + + beforeEach(() => { + mockContext = { + getHandler: jest.fn().mockReturnValue(() => {}), + getClass: jest.fn().mockReturnValue(class {}), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({}), + getResponse: jest.fn().mockReturnValue({}), + }), + getType: jest.fn().mockReturnValue('http'), + getArgs: jest.fn().mockReturnValue([]), + getArgByIndex: jest.fn(), + switchToRpc: jest.fn(), + switchToWs: jest.fn(), + } as unknown as ExecutionContext; + }); + + it('should return true for public routes', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); + + const result = guard.canActivate(mockContext); + + expect(result).toBe(true); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(IS_PUBLIC_KEY, [ + mockContext.getHandler(), + mockContext.getClass(), + ]); + }); + + it('should call super.canActivate for protected routes', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + + // Mock the parent's canActivate + const superCanActivateSpy = jest.spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(guard)), + 'canActivate' + ).mockReturnValue(true); + + const result = guard.canActivate(mockContext); + + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(IS_PUBLIC_KEY, [ + mockContext.getHandler(), + mockContext.getClass(), + ]); + + superCanActivateSpy.mockRestore(); + }); + + it('should call super.canActivate when IS_PUBLIC_KEY is undefined', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + const superCanActivateSpy = jest.spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(guard)), + 'canActivate' + ).mockReturnValue(true); + + guard.canActivate(mockContext); + + expect(reflector.getAllAndOverride).toHaveBeenCalled(); + + superCanActivateSpy.mockRestore(); + }); + }); }); diff --git a/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts b/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts index d109f7c..669a20b 100644 --- a/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts +++ b/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts @@ -1,7 +1,96 @@ +import { ExecutionContext } from '@nestjs/common'; import { OptionalJwtAuthGuard } from './optional-jwt-auth.guard'; describe('OptionalJwtAuthGuard', () => { + let guard: OptionalJwtAuthGuard; + + beforeEach(() => { + guard = new OptionalJwtAuthGuard(); + }); + it('should be defined', () => { - expect(new OptionalJwtAuthGuard()).toBeDefined(); + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + it('should call super.canActivate', () => { + const mockContext = { + getHandler: jest.fn().mockReturnValue(() => {}), + getClass: jest.fn().mockReturnValue(class {}), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({}), + getResponse: jest.fn().mockReturnValue({}), + }), + getType: jest.fn().mockReturnValue('http'), + getArgs: jest.fn().mockReturnValue([]), + getArgByIndex: jest.fn(), + switchToRpc: jest.fn(), + switchToWs: jest.fn(), + } as unknown as ExecutionContext; + + const superCanActivateSpy = jest.spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(guard)), + 'canActivate' + ).mockReturnValue(true); + + guard.canActivate(mockContext); + + expect(superCanActivateSpy).toHaveBeenCalledWith(mockContext); + superCanActivateSpy.mockRestore(); + }); + }); + + describe('handleRequest', () => { + const mockContext = {} as ExecutionContext; + + it('should return null when there is an error', () => { + const err = new Error('Auth error'); + const user = { id: 1, email: 'test@test.com' }; + + const result = guard.handleRequest(err, user, null, mockContext); + + expect(result).toBeNull(); + }); + + it('should return null when user is null', () => { + const result = guard.handleRequest(null, null, null, mockContext); + + expect(result).toBeNull(); + }); + + it('should return null when user is undefined', () => { + const result = guard.handleRequest(null, undefined, null, mockContext); + + expect(result).toBeNull(); + }); + + it('should return user when user exists and no error', () => { + const user = { id: 1, email: 'test@test.com' }; + + const result = guard.handleRequest(null, user, null, mockContext); + + expect(result).toEqual(user); + }); + + it('should return null when both error and no user', () => { + const err = new Error('Auth error'); + + const result = guard.handleRequest(err, null, null, mockContext); + + expect(result).toBeNull(); + }); + + it('should return user with full payload', () => { + const user = { + id: 1, + email: 'test@test.com', + username: 'testuser', + role: 'user', + }; + + const result = guard.handleRequest(null, user, null, mockContext); + + expect(result).toEqual(user); + }); }); }); diff --git a/src/common/decorators/is-adult.decorator.spec.ts b/src/common/decorators/is-adult.decorator.spec.ts new file mode 100644 index 0000000..0074c1f --- /dev/null +++ b/src/common/decorators/is-adult.decorator.spec.ts @@ -0,0 +1,118 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { IsAdult } from './is-adult.decorator'; + +class TestDto { + @IsAdult() + birthDate: Date; +} + +describe('IsAdult Decorator', () => { + const createDto = (birthDate: any) => { + const dto = new TestDto(); + dto.birthDate = birthDate; + return dto; + }; + + describe('valid ages', () => { + it('should pass for 15 year old', async () => { + const today = new Date(); + const birthDate = new Date(today.getFullYear() - 15, today.getMonth(), today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass for 50 year old', async () => { + const today = new Date(); + const birthDate = new Date(today.getFullYear() - 50, today.getMonth(), today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass for 100 year old', async () => { + const today = new Date(); + const birthDate = new Date(today.getFullYear() - 100, today.getMonth(), today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('invalid ages', () => { + it('should fail for 14 year old', async () => { + const today = new Date(); + const birthDate = new Date(today.getFullYear() - 14, today.getMonth(), today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should fail for 101 year old', async () => { + const today = new Date(); + const birthDate = new Date(today.getFullYear() - 101, today.getMonth(), today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should fail for future date', async () => { + const today = new Date(); + const birthDate = new Date(today.getFullYear() + 1, today.getMonth(), today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + it('should fail for null value', async () => { + const dto = createDto(null); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should fail for undefined value', async () => { + const dto = createDto(undefined); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should fail for invalid date string', async () => { + const dto = createDto(new Date('invalid-date')); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should handle birthday not yet occurred this year', async () => { + const today = new Date(); + // Set birth date to be 15 years ago but birthday hasn't occurred yet this year + const birthDate = new Date(today.getFullYear() - 15, today.getMonth() + 1, today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + // Should fail because they haven't turned 15 yet + expect(errors.length).toBeGreaterThan(0); + }); + + it('should handle birthday already occurred this year', async () => { + const today = new Date(); + // Set birth date to be 15 years ago and birthday has occurred + const birthDate = new Date(today.getFullYear() - 15, today.getMonth() - 1, today.getDate()); + const dto = createDto(birthDate); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/src/common/decorators/lowercase.decorator.spec.ts b/src/common/decorators/lowercase.decorator.spec.ts new file mode 100644 index 0000000..371b5ac --- /dev/null +++ b/src/common/decorators/lowercase.decorator.spec.ts @@ -0,0 +1,61 @@ +import { plainToInstance } from 'class-transformer'; +import { ToLowerCase } from './lowercase.decorator'; + +class TestDto { + @ToLowerCase() + value: any; +} + +describe('ToLowerCase Decorator', () => { + it('should convert string to lowercase', () => { + const result = plainToInstance(TestDto, { value: 'HELLO WORLD' }); + expect(result.value).toBe('hello world'); + }); + + it('should convert mixed case string to lowercase', () => { + const result = plainToInstance(TestDto, { value: 'HeLLo WoRLd' }); + expect(result.value).toBe('hello world'); + }); + + it('should keep already lowercase string unchanged', () => { + const result = plainToInstance(TestDto, { value: 'hello world' }); + expect(result.value).toBe('hello world'); + }); + + it('should pass through number unchanged', () => { + const result = plainToInstance(TestDto, { value: 123 }); + expect(result.value).toBe(123); + }); + + it('should pass through null unchanged', () => { + const result = plainToInstance(TestDto, { value: null }); + expect(result.value).toBeNull(); + }); + + it('should pass through undefined unchanged', () => { + const result = plainToInstance(TestDto, { value: undefined }); + expect(result.value).toBeUndefined(); + }); + + it('should pass through object unchanged', () => { + const obj = { nested: 'value' }; + const result = plainToInstance(TestDto, { value: obj }); + expect(result.value).toEqual(obj); + }); + + it('should pass through array unchanged', () => { + const arr = ['A', 'B', 'C']; + const result = plainToInstance(TestDto, { value: arr }); + expect(result.value).toEqual(arr); + }); + + it('should handle empty string', () => { + const result = plainToInstance(TestDto, { value: '' }); + expect(result.value).toBe(''); + }); + + it('should handle string with special characters', () => { + const result = plainToInstance(TestDto, { value: 'TEST@EMAIL.COM' }); + expect(result.value).toBe('test@email.com'); + }); +}); diff --git a/src/common/decorators/trim.decorator.spec.ts b/src/common/decorators/trim.decorator.spec.ts new file mode 100644 index 0000000..3cd7baf --- /dev/null +++ b/src/common/decorators/trim.decorator.spec.ts @@ -0,0 +1,71 @@ +import { plainToInstance } from 'class-transformer'; +import { Trim } from './trim.decorator'; + +class TestDto { + @Trim() + value: any; +} + +describe('Trim Decorator', () => { + it('should trim whitespace from beginning and end', () => { + const result = plainToInstance(TestDto, { value: ' hello world ' }); + expect(result.value).toBe('hello world'); + }); + + it('should trim leading whitespace', () => { + const result = plainToInstance(TestDto, { value: ' hello' }); + expect(result.value).toBe('hello'); + }); + + it('should trim trailing whitespace', () => { + const result = plainToInstance(TestDto, { value: 'hello ' }); + expect(result.value).toBe('hello'); + }); + + it('should keep string without whitespace unchanged', () => { + const result = plainToInstance(TestDto, { value: 'hello' }); + expect(result.value).toBe('hello'); + }); + + it('should pass through number unchanged', () => { + const result = plainToInstance(TestDto, { value: 123 }); + expect(result.value).toBe(123); + }); + + it('should pass through null unchanged', () => { + const result = plainToInstance(TestDto, { value: null }); + expect(result.value).toBeNull(); + }); + + it('should pass through undefined unchanged', () => { + const result = plainToInstance(TestDto, { value: undefined }); + expect(result.value).toBeUndefined(); + }); + + it('should pass through object unchanged', () => { + const obj = { nested: 'value' }; + const result = plainToInstance(TestDto, { value: obj }); + expect(result.value).toEqual(obj); + }); + + it('should pass through array unchanged', () => { + const arr = ['A', 'B', 'C']; + const result = plainToInstance(TestDto, { value: arr }); + expect(result.value).toEqual(arr); + }); + + it('should handle empty string', () => { + const result = plainToInstance(TestDto, { value: '' }); + expect(result.value).toBe(''); + }); + + it('should handle string with only whitespace', () => { + const result = plainToInstance(TestDto, { value: ' ' }); + expect(result.value).toBe(''); + }); + + it('should trim tabs and newlines', () => { + const result = plainToInstance(TestDto, { value: '\t\nhello world\n\t' }); + expect(result.value).toBe('hello world'); + }); +}); diff --git a/src/common/dto/error-response.dto.spec.ts b/src/common/dto/error-response.dto.spec.ts new file mode 100644 index 0000000..baf8e58 --- /dev/null +++ b/src/common/dto/error-response.dto.spec.ts @@ -0,0 +1,85 @@ +import { ErrorResponseDto } from './error-response.dto'; +import { ResponseStatus } from './base-api-response.dto'; + +describe('ErrorResponseDto', () => { + describe('schemaExample', () => { + it('should return schema with default error status', () => { + const result = ErrorResponseDto.schemaExample('Invalid input', 'Bad Request'); + + expect(result).toEqual({ + type: 'object', + properties: { + status: { type: 'string', example: 'error' }, + message: { type: 'string', example: 'Invalid input' }, + error: { type: 'string', example: 'Bad Request' }, + }, + }); + }); + + it('should return schema with fail status', () => { + const result = ErrorResponseDto.schemaExample('Validation failed', 'Validation Error', 'fail'); + + expect(result).toEqual({ + type: 'object', + properties: { + status: { type: 'string', example: 'fail' }, + message: { type: 'string', example: 'Validation failed' }, + error: { type: 'string', example: 'Validation Error' }, + }, + }); + }); + + it('should return schema with null error when not provided', () => { + const result = ErrorResponseDto.schemaExample('Something went wrong'); + + expect(result).toEqual({ + type: 'object', + properties: { + status: { type: 'string', example: 'error' }, + message: { type: 'string', example: 'Something went wrong' }, + error: { type: 'string', example: null }, + }, + }); + }); + + it('should return schema with explicit error status', () => { + const result = ErrorResponseDto.schemaExample('Server error', 'Internal Error', 'error'); + + expect(result).toEqual({ + type: 'object', + properties: { + status: { type: 'string', example: 'error' }, + message: { type: 'string', example: 'Server error' }, + error: { type: 'string', example: 'Internal Error' }, + }, + }); + }); + }); + + describe('instance properties', () => { + it('should accept error status', () => { + const dto = new ErrorResponseDto(); + dto.status = ResponseStatus.ERROR; + dto.message = 'Error message'; + + expect(dto.status).toBe(ResponseStatus.ERROR); + }); + + it('should accept fail status', () => { + const dto = new ErrorResponseDto(); + dto.status = ResponseStatus.FAIL; + dto.message = 'Fail message'; + + expect(dto.status).toBe(ResponseStatus.FAIL); + }); + + it('should accept optional error property', () => { + const dto = new ErrorResponseDto(); + dto.status = ResponseStatus.ERROR; + dto.message = 'Error message'; + dto.error = { details: 'Additional info' }; + + expect(dto.error).toEqual({ details: 'Additional info' }); + }); + }); +}); diff --git a/src/common/dto/paginated-response.dto.spec.ts b/src/common/dto/paginated-response.dto.spec.ts new file mode 100644 index 0000000..69181c4 --- /dev/null +++ b/src/common/dto/paginated-response.dto.spec.ts @@ -0,0 +1,95 @@ +import { PaginatedResponseDto } from './paginated-response.dto'; +import { PaginationMetadataDto } from './pagination-metadata.dto'; + +describe('PaginatedResponseDto', () => { + it('should create an instance with all properties', () => { + const dto = new PaginatedResponseDto<{ id: number }>(); + dto.status = 'success'; + dto.message = 'Data retrieved successfully'; + dto.data = [{ id: 1 }, { id: 2 }]; + dto.metadata = { + totalItems: 2, + page: 1, + limit: 10, + totalPages: 1, + }; + + expect(dto.status).toBe('success'); + expect(dto.message).toBe('Data retrieved successfully'); + expect(dto.data).toHaveLength(2); + expect(dto.metadata.totalItems).toBe(2); + }); + + it('should work with string data type', () => { + const dto = new PaginatedResponseDto(); + dto.status = 'success'; + dto.message = 'Strings retrieved'; + dto.data = ['item1', 'item2', 'item3']; + dto.metadata = { + totalItems: 3, + page: 1, + limit: 10, + totalPages: 1, + }; + + expect(dto.data).toEqual(['item1', 'item2', 'item3']); + }); + + it('should handle empty data array', () => { + const dto = new PaginatedResponseDto(); + dto.status = 'success'; + dto.message = 'No data found'; + dto.data = []; + dto.metadata = { + totalItems: 0, + page: 1, + limit: 10, + totalPages: 0, + }; + + expect(dto.data).toHaveLength(0); + expect(dto.metadata.totalItems).toBe(0); + }); + + it('should work with complex object types', () => { + interface User { + id: number; + name: string; + email: string; + } + + const dto = new PaginatedResponseDto(); + dto.status = 'success'; + dto.message = 'Users retrieved'; + dto.data = [ + { id: 1, name: 'John', email: 'john@example.com' }, + { id: 2, name: 'Jane', email: 'jane@example.com' }, + ]; + dto.metadata = { + totalItems: 2, + page: 1, + limit: 10, + totalPages: 1, + }; + + expect(dto.data[0].name).toBe('John'); + expect(dto.data[1].email).toBe('jane@example.com'); + }); + + it('should work with PaginationMetadataDto instance', () => { + const metadata = new PaginationMetadataDto(); + metadata.totalItems = 50; + metadata.page = 2; + metadata.limit = 25; + metadata.totalPages = 2; + + const dto = new PaginatedResponseDto(); + dto.status = 'success'; + dto.message = 'Numbers retrieved'; + dto.data = [1, 2, 3]; + dto.metadata = metadata; + + expect(dto.metadata).toBe(metadata); + expect(dto.metadata.page).toBe(2); + }); +}); diff --git a/src/common/dto/pagination-metadata.dto.spec.ts b/src/common/dto/pagination-metadata.dto.spec.ts new file mode 100644 index 0000000..cd207f5 --- /dev/null +++ b/src/common/dto/pagination-metadata.dto.spec.ts @@ -0,0 +1,52 @@ +import { PaginationMetadataDto } from './pagination-metadata.dto'; + +describe('PaginationMetadataDto', () => { + it('should create an instance with all properties', () => { + const dto = new PaginationMetadataDto(); + dto.totalItems = 100; + dto.page = 1; + dto.limit = 10; + dto.totalPages = 10; + + expect(dto.totalItems).toBe(100); + expect(dto.page).toBe(1); + expect(dto.limit).toBe(10); + expect(dto.totalPages).toBe(10); + }); + + it('should allow setting properties', () => { + const dto = new PaginationMetadataDto(); + dto.totalItems = 50; + dto.page = 2; + dto.limit = 25; + dto.totalPages = 2; + + expect(dto.totalItems).toBe(50); + expect(dto.page).toBe(2); + expect(dto.limit).toBe(25); + expect(dto.totalPages).toBe(2); + }); + + it('should handle zero values', () => { + const dto = new PaginationMetadataDto(); + dto.totalItems = 0; + dto.page = 1; + dto.limit = 10; + dto.totalPages = 0; + + expect(dto.totalItems).toBe(0); + expect(dto.totalPages).toBe(0); + }); + + it('should handle large values', () => { + const dto = new PaginationMetadataDto(); + dto.totalItems = 1000000; + dto.page = 5000; + dto.limit = 100; + dto.totalPages = 10000; + + expect(dto.totalItems).toBe(1000000); + expect(dto.page).toBe(5000); + expect(dto.totalPages).toBe(10000); + }); +}); diff --git a/src/common/dto/pagination.dto.spec.ts b/src/common/dto/pagination.dto.spec.ts new file mode 100644 index 0000000..92a54b2 --- /dev/null +++ b/src/common/dto/pagination.dto.spec.ts @@ -0,0 +1,101 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { PaginationDto } from './pagination.dto'; + +describe('PaginationDto', () => { + describe('default values', () => { + it('should have default page of 1', () => { + const dto = new PaginationDto(); + expect(dto.page).toBe(1); + }); + + it('should have default limit of 10', () => { + const dto = new PaginationDto(); + expect(dto.limit).toBe(10); + }); + }); + + describe('valid values', () => { + it('should pass with valid page and limit', async () => { + const dto = plainToInstance(PaginationDto, { page: 5, limit: 20 }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with minimum page value of 1', async () => { + const dto = plainToInstance(PaginationDto, { page: 1, limit: 10 }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with maximum page value of 10000', async () => { + const dto = plainToInstance(PaginationDto, { page: 10000, limit: 10 }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with minimum limit value of 1', async () => { + const dto = plainToInstance(PaginationDto, { page: 1, limit: 1 }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with maximum limit value of 100', async () => { + const dto = plainToInstance(PaginationDto, { page: 1, limit: 100 }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('invalid values', () => { + it('should fail with page less than 1', async () => { + const dto = plainToInstance(PaginationDto, { page: 0, limit: 10 }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'page')).toBe(true); + }); + + it('should fail with page greater than 10000', async () => { + const dto = plainToInstance(PaginationDto, { page: 10001, limit: 10 }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'page')).toBe(true); + }); + + it('should fail with limit less than 1', async () => { + const dto = plainToInstance(PaginationDto, { page: 1, limit: 0 }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'limit')).toBe(true); + }); + + it('should fail with limit greater than 100', async () => { + const dto = plainToInstance(PaginationDto, { page: 1, limit: 101 }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'limit')).toBe(true); + }); + + it('should fail with non-integer page', async () => { + const dto = plainToInstance(PaginationDto, { page: 1.5, limit: 10 }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'page')).toBe(true); + }); + + it('should fail with non-integer limit', async () => { + const dto = plainToInstance(PaginationDto, { page: 1, limit: 10.5 }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'limit')).toBe(true); + }); + }); + + describe('type transformation', () => { + it('should transform string page to number', () => { + const dto = plainToInstance(PaginationDto, { page: '5', limit: '20' }); + expect(typeof dto.page).toBe('number'); + expect(dto.page).toBe(5); + }); + + it('should transform string limit to number', () => { + const dto = plainToInstance(PaginationDto, { page: '1', limit: '50' }); + expect(typeof dto.limit).toBe('number'); + expect(dto.limit).toBe(50); + }); + }); +}); diff --git a/src/cron/cron.service.spec.ts b/src/cron/cron.service.spec.ts new file mode 100644 index 0000000..4bdedb0 --- /dev/null +++ b/src/cron/cron.service.spec.ts @@ -0,0 +1,172 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CronService } from './cron.service'; +import { HashtagTrendService } from 'src/post/services/hashtag-trends.service'; +import { UserService } from 'src/user/user.service'; +import { Services } from 'src/utils/constants'; +import { TrendCategory, ALL_TREND_CATEGORIES } from 'src/post/enums/trend-category.enum'; + +describe('CronService', () => { + let service: CronService; + let hashtagTrendService: jest.Mocked; + let userService: jest.Mocked; + + const mockHashtagTrendService = { + syncTrendingToDB: jest.fn(), + }; + + const mockUserService = { + getActiveUsers: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CronService, + { + provide: Services.HASHTAG_TRENDS, + useValue: mockHashtagTrendService, + }, + { + provide: Services.USER, + useValue: mockUserService, + }, + ], + }).compile(); + + service = module.get(CronService); + hashtagTrendService = module.get(Services.HASHTAG_TRENDS); + userService = module.get(Services.USER); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handleTrendSyncToPostgres', () => { + it('should sync trends for all non-personalized categories successfully', async () => { + mockHashtagTrendService.syncTrendingToDB.mockResolvedValue(10); + mockUserService.getActiveUsers.mockResolvedValue([]); + + const results = await service.handleTrendSyncToPostgres(); + + expect(results).toBeDefined(); + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(ALL_TREND_CATEGORIES.length); + }); + + it('should sync personalized trends for active users', async () => { + const mockUsers = [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + ]; + mockUserService.getActiveUsers.mockResolvedValue(mockUsers); + mockHashtagTrendService.syncTrendingToDB.mockResolvedValue(5); + + const results = await service.handleTrendSyncToPostgres(); + + const personalizedResult = results.find(r => r.category === TrendCategory.PERSONALIZED); + expect(personalizedResult).toBeDefined(); + expect(personalizedResult?.userCount).toBe(3); + }); + + it('should handle errors for individual category sync gracefully', async () => { + mockUserService.getActiveUsers.mockResolvedValue([]); + mockHashtagTrendService.syncTrendingToDB.mockImplementation((category) => { + if (category === TrendCategory.GENERAL) { + throw new Error('Sync failed'); + } + return Promise.resolve(10); + }); + + const results = await service.handleTrendSyncToPostgres(); + + const generalResult = results.find(r => r.category === TrendCategory.GENERAL); + expect(generalResult?.error).toBe('Sync failed'); + }); + + it('should handle errors for individual user sync in personalized trends', async () => { + const mockUsers = [ + { id: 1 }, + { id: 2 }, + ]; + mockUserService.getActiveUsers.mockResolvedValue(mockUsers); + + let callCount = 0; + mockHashtagTrendService.syncTrendingToDB.mockImplementation((category, userId) => { + if (category === TrendCategory.PERSONALIZED) { + callCount++; + if (callCount === 1) { + throw new Error('User sync failed'); + } + return Promise.resolve(5); + } + return Promise.resolve(10); + }); + + const results = await service.handleTrendSyncToPostgres(); + + const personalizedResult = results.find(r => r.category === TrendCategory.PERSONALIZED); + expect(personalizedResult).toBeDefined(); + expect(personalizedResult?.error).toContain('1 users failed'); + }); + + it('should process users in batches of 50', async () => { + // Create 60 mock users to test batching + const mockUsers = Array.from({ length: 60 }, (_, i) => ({ id: i + 1 })); + mockUserService.getActiveUsers.mockResolvedValue(mockUsers); + mockHashtagTrendService.syncTrendingToDB.mockResolvedValue(5); + + const results = await service.handleTrendSyncToPostgres(); + + const personalizedResult = results.find(r => r.category === TrendCategory.PERSONALIZED); + expect(personalizedResult?.userCount).toBe(60); + // Each user should have syncTrendingToDB called for personalized + expect(mockHashtagTrendService.syncTrendingToDB).toHaveBeenCalledWith( + TrendCategory.PERSONALIZED, + expect.any(Number), + ); + }); + + it('should aggregate total count from all categories', async () => { + mockUserService.getActiveUsers.mockResolvedValue([]); + mockHashtagTrendService.syncTrendingToDB.mockResolvedValue(10); + + const results = await service.handleTrendSyncToPostgres(); + + const totalQueued = results.reduce((sum, r) => sum + (r.count || 0), 0); + // All non-personalized categories should have count of 10 + // Personalized with no users should have count of 0 + const expectedTotal = (ALL_TREND_CATEGORIES.length - 1) * 10; // -1 for personalized with 0 users + expect(totalQueued).toBe(expectedTotal); + }); + + it('should return results with category and count for successful syncs', async () => { + mockUserService.getActiveUsers.mockResolvedValue([]); + mockHashtagTrendService.syncTrendingToDB.mockResolvedValue(15); + + const results = await service.handleTrendSyncToPostgres(); + + results.forEach(result => { + expect(result.category).toBeDefined(); + if (result.category !== TrendCategory.PERSONALIZED) { + expect(result.count).toBe(15); + expect(result.error).toBeUndefined(); + } + }); + }); + + it('should handle empty active users list for personalized trends', async () => { + mockUserService.getActiveUsers.mockResolvedValue([]); + mockHashtagTrendService.syncTrendingToDB.mockResolvedValue(10); + + const results = await service.handleTrendSyncToPostgres(); + + const personalizedResult = results.find(r => r.category === TrendCategory.PERSONALIZED); + expect(personalizedResult?.userCount).toBe(0); + expect(personalizedResult?.count).toBe(0); + }); + }); +}); diff --git a/src/email/dto/send-email.dto.spec.ts b/src/email/dto/send-email.dto.spec.ts new file mode 100644 index 0000000..53d8152 --- /dev/null +++ b/src/email/dto/send-email.dto.spec.ts @@ -0,0 +1,76 @@ +import { validate } from 'class-validator'; +import { SendEmailDto } from './send-email.dto'; + +describe('SendEmailDto', () => { + it('should validate with string array recipients', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['test@example.com', 'test2@example.com']; + dto.subject = 'Test Subject'; + dto.html = '

Test content

'; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should validate with single email recipient', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['test@example.com']; + dto.subject = 'Test Subject'; + dto.html = '

Test content

'; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should validate with optional text field', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['test@example.com']; + dto.subject = 'Test Subject'; + dto.html = '

Test content

'; + dto.text = 'Plain text content'; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail validation with invalid email in array', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['invalid-email']; + dto.subject = 'Test Subject'; + dto.html = '

Test content

'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'recipients')).toBe(true); + }); + + it('should fail validation with empty html', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['test@example.com']; + dto.subject = 'Test Subject'; + dto.html = ''; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'html')).toBe(true); + }); + + it('should fail validation with missing subject', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['test@example.com']; + dto.subject = undefined as any; + dto.html = '

Test content

'; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'subject')).toBe(true); + }); + + it('should validate without optional text field', async () => { + const dto = new SendEmailDto(); + dto.recipients = ['test@example.com']; + dto.subject = 'Test Subject'; + dto.html = '

Test content

'; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + expect(dto.text).toBeUndefined(); + }); +}); diff --git a/src/email/email.controller.spec.ts b/src/email/email.controller.spec.ts index ca08866..d473a71 100644 --- a/src/email/email.controller.spec.ts +++ b/src/email/email.controller.spec.ts @@ -2,15 +2,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EmailController } from './email.controller'; import { EmailService } from './email.service'; import { Services } from 'src/utils/constants'; +import * as fs from 'node:fs'; + +jest.mock('node:fs', () => ({ + readFileSync: jest.fn(), +})); describe('EmailController', () => { let controller: EmailController; - - const mockEmailService = { - sendEmail: jest.fn(), - }; + let mockEmailService: any; beforeEach(async () => { + mockEmailService = { + sendEmail: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [EmailController], providers: [ @@ -24,7 +30,75 @@ describe('EmailController', () => { controller = module.get(EmailController); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('sendEmail', () => { + it('should read template and send email', async () => { + const templateContent = '

Verification

'; + (fs.readFileSync as jest.Mock).mockReturnValue(templateContent); + mockEmailService.sendEmail.mockResolvedValue({ success: true, messageId: '123' }); + + const result = await controller.sendEmail(); + + expect(fs.readFileSync).toHaveBeenCalled(); + expect(mockEmailService.sendEmail).toHaveBeenCalledWith({ + subject: 'Account Verification', + recipients: ['mohamedalbaz77@gmail.com'], + html: templateContent, + }); + expect(result).toEqual({ success: true, messageId: '123' }); + }); + + it('should handle email service failure', async () => { + const templateContent = '

Verification

'; + (fs.readFileSync as jest.Mock).mockReturnValue(templateContent); + mockEmailService.sendEmail.mockResolvedValue(null); + + const result = await controller.sendEmail(); + + expect(result).toBeNull(); + }); + }); + + describe('testEmail', () => { + it('should send test email to provided address', async () => { + mockEmailService.sendEmail.mockResolvedValue({ success: true, messageId: '456' }); + + const result = await controller.testEmail('test@example.com'); + + expect(mockEmailService.sendEmail).toHaveBeenCalledWith({ + recipients: ['test@example.com'], + subject: 'Test Email from Azure', + html: '

Test Email

If you received this, Azure email is working!

', + text: 'Test Email - If you received this, Azure email is working!', + }); + expect(result).toEqual({ success: true, messageId: '456' }); + }); + + it('should handle test email failure', async () => { + mockEmailService.sendEmail.mockResolvedValue(null); + + const result = await controller.testEmail('test@example.com'); + + expect(result).toBeNull(); + }); + + it('should send to different email addresses', async () => { + mockEmailService.sendEmail.mockResolvedValue({ success: true }); + + await controller.testEmail('another@example.com'); + + expect(mockEmailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: ['another@example.com'], + }), + ); + }); + }); }); diff --git a/src/email/email.service.spec.ts b/src/email/email.service.spec.ts index 34e5d2b..8d6b713 100644 --- a/src/email/email.service.spec.ts +++ b/src/email/email.service.spec.ts @@ -162,7 +162,7 @@ describe('EmailService', () => { // then fallback to Resend const result = await service.sendEmail(sendEmailDto); expect(result === null || typeof result === 'object').toBe(true); - }); + }, 15000); // Increased timeout for SMTP connection attempts }); describe('renderTemplate', () => { diff --git a/src/post/decorators/content-required-if-no-media.decorator.spec.ts b/src/post/decorators/content-required-if-no-media.decorator.spec.ts new file mode 100644 index 0000000..3a552e1 --- /dev/null +++ b/src/post/decorators/content-required-if-no-media.decorator.spec.ts @@ -0,0 +1,99 @@ +import { validate } from 'class-validator'; +import { IsContentRequiredIfNoMedia } from './content-required-if-no-media.decorator'; + +class TestDto { + @IsContentRequiredIfNoMedia() + content: string; + + media?: any[]; +} + +describe('IsContentRequiredIfNoMedia Decorator', () => { + describe('when no media is provided', () => { + it('should pass with valid content', async () => { + const dto = new TestDto(); + dto.content = 'Valid content'; + dto.media = []; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with empty content', async () => { + const dto = new TestDto(); + dto.content = ''; + dto.media = []; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'content')).toBe(true); + }); + + it('should fail with whitespace-only content', async () => { + const dto = new TestDto(); + dto.content = ' '; + dto.media = []; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'content')).toBe(true); + }); + + it('should fail with null content', async () => { + const dto = new TestDto(); + dto.content = null as any; + dto.media = []; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'content')).toBe(true); + }); + + it('should fail when media is undefined', async () => { + const dto = new TestDto(); + dto.content = ''; + dto.media = undefined; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'content')).toBe(true); + }); + }); + + describe('when media is provided', () => { + it('should pass with empty content when media exists', async () => { + const dto = new TestDto(); + dto.content = ''; + dto.media = [{ url: 'http://example.com/image.jpg' }]; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with null content when media exists', async () => { + const dto = new TestDto(); + dto.content = null as any; + dto.media = [{ url: 'http://example.com/image.jpg' }]; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with valid content and media', async () => { + const dto = new TestDto(); + dto.content = 'Some content'; + dto.media = [{ url: 'http://example.com/image.jpg' }]; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with multiple media items', async () => { + const dto = new TestDto(); + dto.content = ''; + dto.media = [ + { url: 'http://example.com/image1.jpg' }, + { url: 'http://example.com/image2.jpg' }, + ]; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/src/post/decorators/is-parent-id-allowed.decorator.spec.ts b/src/post/decorators/is-parent-id-allowed.decorator.spec.ts new file mode 100644 index 0000000..0bb4392 --- /dev/null +++ b/src/post/decorators/is-parent-id-allowed.decorator.spec.ts @@ -0,0 +1,81 @@ +import { validate } from 'class-validator'; +import { IsParentIdAllowed } from './is-parent-id-allowed.decorator'; +import { PostType } from '@prisma/client'; + +class TestDto { + @IsParentIdAllowed() + parentId?: number; + + type: PostType; +} + +describe('IsParentIdAllowed Decorator', () => { + describe('when type is POST', () => { + it('should fail when parentId is provided', async () => { + const dto = new TestDto(); + dto.type = PostType.POST; + dto.parentId = 123; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'parentId')).toBe(true); + }); + + it('should pass when parentId is undefined', async () => { + const dto = new TestDto(); + dto.type = PostType.POST; + dto.parentId = undefined; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass when parentId is null', async () => { + const dto = new TestDto(); + dto.type = PostType.POST; + dto.parentId = null as any; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('when type is REPLY', () => { + it('should pass when parentId is provided', async () => { + const dto = new TestDto(); + dto.type = PostType.REPLY; + dto.parentId = 123; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass when parentId is undefined', async () => { + const dto = new TestDto(); + dto.type = PostType.REPLY; + dto.parentId = undefined; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('when type is QUOTE', () => { + it('should pass when parentId is provided', async () => { + const dto = new TestDto(); + dto.type = PostType.QUOTE; + dto.parentId = 123; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass when parentId is undefined', async () => { + const dto = new TestDto(); + dto.type = PostType.QUOTE; + dto.parentId = undefined; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/src/post/decorators/parent-required-for-reply-or-quote.decorator.spec.ts b/src/post/decorators/parent-required-for-reply-or-quote.decorator.spec.ts new file mode 100644 index 0000000..840237a --- /dev/null +++ b/src/post/decorators/parent-required-for-reply-or-quote.decorator.spec.ts @@ -0,0 +1,99 @@ +import { validate } from 'class-validator'; +import { IsParentRequiredForReplyOrQuote } from './parent-required-for-reply-or-quote.decorator'; +import { PostType } from '@prisma/client'; + +class TestDto { + @IsParentRequiredForReplyOrQuote() + type: PostType; + + parentId?: number; +} + +describe('IsParentRequiredForReplyOrQuote Decorator', () => { + describe('when type is REPLY', () => { + it('should fail when parentId is null', async () => { + const dto = new TestDto(); + dto.type = PostType.REPLY; + dto.parentId = null as any; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'type')).toBe(true); + }); + + it('should fail when parentId is undefined', async () => { + const dto = new TestDto(); + dto.type = PostType.REPLY; + dto.parentId = undefined; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'type')).toBe(true); + }); + + it('should pass when parentId is provided', async () => { + const dto = new TestDto(); + dto.type = PostType.REPLY; + dto.parentId = 123; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('when type is QUOTE', () => { + it('should fail when parentId is null', async () => { + const dto = new TestDto(); + dto.type = PostType.QUOTE; + dto.parentId = null as any; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'type')).toBe(true); + }); + + it('should fail when parentId is undefined', async () => { + const dto = new TestDto(); + dto.type = PostType.QUOTE; + dto.parentId = undefined; + + const errors = await validate(dto); + expect(errors.some(e => e.property === 'type')).toBe(true); + }); + + it('should pass when parentId is provided', async () => { + const dto = new TestDto(); + dto.type = PostType.QUOTE; + dto.parentId = 123; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('when type is POST', () => { + it('should pass when parentId is null', async () => { + const dto = new TestDto(); + dto.type = PostType.POST; + dto.parentId = null as any; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass when parentId is undefined', async () => { + const dto = new TestDto(); + dto.type = PostType.POST; + dto.parentId = undefined; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass when parentId is provided', async () => { + const dto = new TestDto(); + dto.type = PostType.POST; + dto.parentId = 123; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/src/post/services/personalized-trends.service.spec.ts b/src/post/services/personalized-trends.service.spec.ts new file mode 100644 index 0000000..4337233 --- /dev/null +++ b/src/post/services/personalized-trends.service.spec.ts @@ -0,0 +1,239 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PersonalizedTrendsService } from './personalized-trends.service'; +import { RedisService } from 'src/redis/redis.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { RedisTrendingService } from './redis-trending.service'; +import { UsersService } from 'src/users/users.service'; +import { Services } from 'src/utils/constants'; +import { TrendCategory } from '../enums/trend-category.enum'; + +describe('PersonalizedTrendsService', () => { + let service: PersonalizedTrendsService; + let redisService: jest.Mocked; + let prismaService: jest.Mocked; + let redisTrendingService: jest.Mocked; + let usersService: jest.Mocked; + + const mockRedisService = { + getJSON: jest.fn(), + setJSON: jest.fn(), + zAdd: jest.fn(), + zRemRangeByRank: jest.fn(), + delPattern: jest.fn(), + }; + + const mockPrismaService = { + hashtag: { + findUnique: jest.fn(), + }, + }; + + const mockRedisTrendingService = { + getTrending: jest.fn(), + getHashtagMetadata: jest.fn(), + setHashtagMetadata: jest.fn(), + getHashtagCounts: jest.fn(), + }; + + const mockUsersService = { + getUserInterests: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PersonalizedTrendsService, + { + provide: Services.REDIS, + useValue: mockRedisService, + }, + { + provide: Services.PRISMA, + useValue: mockPrismaService, + }, + { + provide: Services.REDIS_TRENDING, + useValue: mockRedisTrendingService, + }, + { + provide: Services.USERS, + useValue: mockUsersService, + }, + ], + }).compile(); + + service = module.get(PersonalizedTrendsService); + redisService = module.get(Services.REDIS); + prismaService = module.get(Services.PRISMA); + redisTrendingService = module.get(Services.REDIS_TRENDING); + usersService = module.get(Services.USERS); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getPersonalizedTrending', () => { + it('should return cached results when available', async () => { + const cachedTrends = [ + { tag: '#test', totalPosts: 100 }, + { tag: '#trending', totalPosts: 50 }, + ]; + mockRedisService.getJSON.mockResolvedValue(cachedTrends); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(result).toEqual(cachedTrends); + expect(mockUsersService.getUserInterests).not.toHaveBeenCalled(); + }); + + it('should fall back to GENERAL when user has no interests', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockUsersService.getUserInterests.mockResolvedValue([]); + mockRedisTrendingService.getTrending.mockResolvedValue([ + { hashtagId: 1, score: 100 }, + ]); + mockRedisTrendingService.getHashtagMetadata.mockResolvedValue({ tag: 'test', hashtagId: 1 }); + mockRedisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 10, + count24h: 50, + count7d: 200, + }); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(result).toBeDefined(); + }); + + it('should generate personalized trends based on user interests', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockUsersService.getUserInterests.mockResolvedValue([ + { slug: 'technology' }, + { slug: 'programming' }, + ]); + mockRedisTrendingService.getTrending.mockResolvedValue([ + { hashtagId: 1, score: 100 }, + { hashtagId: 2, score: 80 }, + ]); + mockRedisTrendingService.getHashtagMetadata.mockResolvedValue({ tag: 'tech', hashtagId: 1 }); + mockRedisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 10, + count24h: 50, + count7d: 200, + }); + mockRedisService.setJSON.mockResolvedValue(undefined); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(result).toBeDefined(); + expect(mockRedisService.setJSON).toHaveBeenCalled(); + }); + + it('should fetch metadata from prisma when not in cache', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockUsersService.getUserInterests.mockResolvedValue([{ slug: 'sports' }]); + mockRedisTrendingService.getTrending.mockResolvedValue([ + { hashtagId: 1, score: 100 }, + ]); + mockRedisTrendingService.getHashtagMetadata.mockResolvedValue(null); + mockPrismaService.hashtag.findUnique.mockResolvedValue({ tag: 'football' }); + mockRedisTrendingService.setHashtagMetadata.mockResolvedValue(undefined); + mockRedisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 5, + count24h: 25, + count7d: 100, + }); + mockRedisService.setJSON.mockResolvedValue(undefined); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(mockPrismaService.hashtag.findUnique).toHaveBeenCalled(); + expect(mockRedisTrendingService.setHashtagMetadata).toHaveBeenCalled(); + }); + + it('should filter out null results when hashtag not found', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockUsersService.getUserInterests.mockResolvedValue([{ slug: 'sports' }]); + mockRedisTrendingService.getTrending.mockResolvedValue([ + { hashtagId: 1, score: 100 }, + ]); + mockRedisTrendingService.getHashtagMetadata.mockResolvedValue(null); + mockPrismaService.hashtag.findUnique.mockResolvedValue(null); + mockRedisService.setJSON.mockResolvedValue(undefined); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(result).toBeDefined(); + }); + + it('should fall back to GENERAL on error', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockUsersService.getUserInterests.mockRejectedValue(new Error('DB error')); + mockRedisTrendingService.getTrending.mockResolvedValue([]); + mockRedisTrendingService.getHashtagMetadata.mockResolvedValue(null); + mockRedisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 0, + count24h: 0, + count7d: 0, + }); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(result).toBeDefined(); + }); + + it('should fall back to GENERAL when no combined trends', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockUsersService.getUserInterests.mockResolvedValue([{ slug: 'sports' }]); + mockRedisTrendingService.getTrending.mockResolvedValue([]); + mockRedisTrendingService.getHashtagMetadata.mockResolvedValue(null); + mockRedisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 0, + count24h: 0, + count7d: 0, + }); + + const result = await service.getPersonalizedTrending(1, 10); + + expect(result).toBeDefined(); + }); + }); + + describe('invalidateUserCache', () => { + it('should delete cache patterns and clear local cache', async () => { + mockRedisService.delPattern.mockResolvedValue(1); + + await service.invalidateUserCache(123); + + expect(mockRedisService.delPattern).toHaveBeenCalledTimes(2); + }); + }); + + describe('trackUserActivity', () => { + it('should track user activity in Redis', async () => { + mockRedisService.zAdd.mockResolvedValue(1); + mockRedisService.zRemRangeByRank.mockResolvedValue(0); + + await service.trackUserActivity(123); + + expect(mockRedisService.zAdd).toHaveBeenCalledWith( + 'trending:active_users', + expect.arrayContaining([ + expect.objectContaining({ + value: '123', + }), + ]), + ); + expect(mockRedisService.zRemRangeByRank).toHaveBeenCalled(); + }); + + it('should not throw when tracking fails', async () => { + mockRedisService.zAdd.mockRejectedValue(new Error('Redis error')); + + // Should not throw + await service.trackUserActivity(123); + }); + }); +}); diff --git a/src/post/services/redis-trending.service.spec.ts b/src/post/services/redis-trending.service.spec.ts new file mode 100644 index 0000000..4a2aa83 --- /dev/null +++ b/src/post/services/redis-trending.service.spec.ts @@ -0,0 +1,359 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RedisTrendingService } from './redis-trending.service'; +import { RedisService } from 'src/redis/redis.service'; +import { Services } from 'src/utils/constants'; +import { TrendCategory } from '../enums/trend-category.enum'; + +describe('RedisTrendingService', () => { + let service: RedisTrendingService; + let redisService: jest.Mocked; + + const mockRedisService = { + get: jest.fn(), + incr: jest.fn(), + expire: jest.fn(), + zAdd: jest.fn(), + zCount: jest.fn(), + zRem: jest.fn(), + zRangeWithScores: jest.fn(), + zRemRangeByRank: jest.fn(), + zRemRangeByScore: jest.fn(), + setJSON: jest.fn(), + getJSON: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisTrendingService, + { + provide: Services.REDIS, + useValue: mockRedisService, + }, + ], + }).compile(); + + service = module.get(RedisTrendingService); + redisService = module.get(Services.REDIS); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('trackHashtagPost', () => { + it('should track a hashtag post successfully', async () => { + mockRedisService.incr.mockResolvedValue(1); + mockRedisService.expire.mockResolvedValue(true); + mockRedisService.zAdd.mockResolvedValue(1); + + await service.trackHashtagPost(1, 100, TrendCategory.GENERAL); + + expect(mockRedisService.incr).toHaveBeenCalled(); + expect(mockRedisService.expire).toHaveBeenCalled(); + expect(mockRedisService.zAdd).toHaveBeenCalled(); + }); + + it('should throw error when redis fails', async () => { + mockRedisService.incr.mockRejectedValue(new Error('Redis error')); + + await expect( + service.trackHashtagPost(1, 100, TrendCategory.GENERAL), + ).rejects.toThrow('Redis error'); + }); + + it('should use provided timestamp', async () => { + mockRedisService.incr.mockResolvedValue(1); + mockRedisService.expire.mockResolvedValue(true); + mockRedisService.zAdd.mockResolvedValue(1); + + const timestamp = Date.now() - 1000; + await service.trackHashtagPost(1, 100, TrendCategory.SPORTS, timestamp); + + expect(mockRedisService.zAdd).toHaveBeenCalled(); + }); + }); + + describe('getTrending', () => { + it('should return trending hashtags', async () => { + mockRedisService.zRangeWithScores.mockResolvedValue([ + { value: '1', score: 100 }, + { value: '2', score: 80 }, + ]); + + const result = await service.getTrending(TrendCategory.GENERAL, 10); + + expect(result).toEqual([ + { hashtagId: 1, score: 100 }, + { hashtagId: 2, score: 80 }, + ]); + }); + + it('should use default limit of 10', async () => { + mockRedisService.zRangeWithScores.mockResolvedValue([]); + + await service.getTrending(TrendCategory.GENERAL); + + expect(mockRedisService.zRangeWithScores).toHaveBeenCalledWith( + expect.any(String), + 0, + 9, + { REV: true }, + ); + }); + + it('should throw error when redis fails', async () => { + mockRedisService.zRangeWithScores.mockRejectedValue(new Error('Redis error')); + + await expect( + service.getTrending(TrendCategory.GENERAL), + ).rejects.toThrow('Redis error'); + }); + }); + + describe('getHashtagCounts', () => { + it('should return cached counts when valid cache exists', async () => { + const cachedCounts = { + count1h: 10, + count24h: 50, + count7d: 200, + timestamp: Date.now() - 60000, // 1 minute ago + }; + mockRedisService.getJSON.mockResolvedValue(cachedCounts); + + const result = await service.getHashtagCounts(1, TrendCategory.GENERAL); + + expect(result).toEqual({ + count1h: 10, + count24h: 50, + count7d: 200, + }); + expect(mockRedisService.get).not.toHaveBeenCalled(); + }); + + it('should fetch fresh counts when cache is stale', async () => { + mockRedisService.getJSON.mockResolvedValue({ + count1h: 10, + count24h: 50, + count7d: 200, + timestamp: Date.now() - 400000, // Older than cache TTL + }); + mockRedisService.get.mockResolvedValue('15'); + mockRedisService.zCount.mockResolvedValue(250); + mockRedisService.setJSON.mockResolvedValue(undefined); + + const result = await service.getHashtagCounts(1, TrendCategory.GENERAL); + + expect(result.count7d).toBe(250); + expect(mockRedisService.setJSON).toHaveBeenCalled(); + }); + + it('should fetch fresh counts when no cache exists', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + mockRedisService.get.mockResolvedValue('5'); + mockRedisService.zCount.mockResolvedValue(100); + mockRedisService.setJSON.mockResolvedValue(undefined); + + const result = await service.getHashtagCounts(1, TrendCategory.GENERAL); + + expect(result).toEqual({ + count1h: 5, + count24h: 5, + count7d: 100, + }); + }); + + it('should throw error when redis fails', async () => { + mockRedisService.getJSON.mockRejectedValue(new Error('Redis error')); + + await expect( + service.getHashtagCounts(1, TrendCategory.GENERAL), + ).rejects.toThrow('Redis error'); + }); + }); + + describe('batchGetHashtagCounts', () => { + it('should return counts for multiple hashtags', async () => { + mockRedisService.getJSON.mockResolvedValue({ + count1h: 10, + count24h: 50, + count7d: 200, + timestamp: Date.now(), + }); + + const result = await service.batchGetHashtagCounts([1, 2, 3], TrendCategory.GENERAL); + + expect(result.size).toBe(3); + expect(result.get(1)).toBeDefined(); + expect(result.get(2)).toBeDefined(); + expect(result.get(3)).toBeDefined(); + }); + }); + + describe('setHashtagMetadata', () => { + it('should set metadata successfully', async () => { + mockRedisService.setJSON.mockResolvedValue(undefined); + + await service.setHashtagMetadata(1, '#test', TrendCategory.GENERAL); + + expect(mockRedisService.setJSON).toHaveBeenCalledWith( + expect.any(String), + { tag: '#test', hashtagId: 1 }, + expect.any(Number), + ); + }); + + it('should throw error when redis fails', async () => { + mockRedisService.setJSON.mockRejectedValue(new Error('Redis error')); + + await expect( + service.setHashtagMetadata(1, '#test', TrendCategory.GENERAL), + ).rejects.toThrow('Redis error'); + }); + }); + + describe('getHashtagMetadata', () => { + it('should return metadata when exists', async () => { + mockRedisService.getJSON.mockResolvedValue({ tag: '#test', hashtagId: 1 }); + + const result = await service.getHashtagMetadata(1, TrendCategory.GENERAL); + + expect(result).toEqual({ tag: '#test', hashtagId: 1 }); + }); + + it('should return null when metadata does not exist', async () => { + mockRedisService.getJSON.mockResolvedValue(null); + + const result = await service.getHashtagMetadata(1, TrendCategory.GENERAL); + + expect(result).toBeNull(); + }); + + it('should return null when redis fails', async () => { + mockRedisService.getJSON.mockRejectedValue(new Error('Redis error')); + + const result = await service.getHashtagMetadata(1, TrendCategory.GENERAL); + + expect(result).toBeNull(); + }); + }); + + describe('batchGetHashtagMetadata', () => { + it('should return metadata for multiple hashtags', async () => { + mockRedisService.getJSON + .mockResolvedValueOnce({ tag: '#test1', hashtagId: 1 }) + .mockResolvedValueOnce({ tag: '#test2', hashtagId: 2 }) + .mockResolvedValueOnce(null); + + const result = await service.batchGetHashtagMetadata([1, 2, 3], TrendCategory.GENERAL); + + expect(result.size).toBe(2); + expect(result.get(1)).toEqual({ tag: '#test1', hashtagId: 1 }); + expect(result.get(2)).toEqual({ tag: '#test2', hashtagId: 2 }); + expect(result.has(3)).toBe(false); + }); + }); + + describe('trackPostHashtags', () => { + it('should track multiple hashtags for a post', async () => { + mockRedisService.incr.mockResolvedValue(1); + mockRedisService.expire.mockResolvedValue(true); + mockRedisService.zAdd.mockResolvedValue(1); + + await service.trackPostHashtags(100, [1, 2, 3], TrendCategory.GENERAL); + + // Each hashtag tracking calls incr multiple times + expect(mockRedisService.incr).toHaveBeenCalled(); + }); + + it('should return early when hashtagIds is empty', async () => { + await service.trackPostHashtags(100, [], TrendCategory.GENERAL); + + expect(mockRedisService.incr).not.toHaveBeenCalled(); + }); + + it('should throw error when tracking fails', async () => { + mockRedisService.incr.mockRejectedValue(new Error('Redis error')); + + await expect( + service.trackPostHashtags(100, [1], TrendCategory.GENERAL), + ).rejects.toThrow('Redis error'); + }); + }); + + describe('cleanupOldEntries', () => { + it('should remove old entries from sorted set', async () => { + mockRedisService.zRemRangeByScore.mockResolvedValue(5); + + await service.cleanupOldEntries(1, TrendCategory.GENERAL); + + expect(mockRedisService.zRemRangeByScore).toHaveBeenCalled(); + }); + + it('should not throw when cleanup fails', async () => { + mockRedisService.zRemRangeByScore.mockRejectedValue(new Error('Redis error')); + + // Should not throw + await service.cleanupOldEntries(1, TrendCategory.GENERAL); + }); + }); + + describe('forceScoreUpdate', () => { + it('should call updateTrendingScore', async () => { + mockRedisService.get.mockResolvedValue('10'); + mockRedisService.zCount.mockResolvedValue(50); + mockRedisService.zAdd.mockResolvedValue(1); + mockRedisService.zRemRangeByRank.mockResolvedValue(0); + mockRedisService.setJSON.mockResolvedValue(undefined); + mockRedisService.zRemRangeByScore.mockResolvedValue(0); + + const score = await service.forceScoreUpdate(1, TrendCategory.GENERAL); + + expect(score).toBeGreaterThanOrEqual(0); + }); + }); + + describe('updateTrendingScore', () => { + it('should calculate and update score correctly', async () => { + mockRedisService.get.mockResolvedValue('10'); + mockRedisService.zCount.mockResolvedValue(50); + mockRedisService.zAdd.mockResolvedValue(1); + mockRedisService.zRemRangeByRank.mockResolvedValue(0); + mockRedisService.setJSON.mockResolvedValue(undefined); + mockRedisService.zRemRangeByScore.mockResolvedValue(0); + + const score = await service.updateTrendingScore(1, TrendCategory.GENERAL); + + // Score = 10*10 + 10*2 + 50*0.5 = 100 + 20 + 25 = 145 + expect(score).toBe(145); + expect(mockRedisService.zAdd).toHaveBeenCalled(); + }); + + it('should remove hashtag from trending when score is 0', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRedisService.zCount.mockResolvedValue(0); + mockRedisService.zRem.mockResolvedValue(1); + mockRedisService.zRemRangeByScore.mockResolvedValue(0); + + const score = await service.updateTrendingScore(1, TrendCategory.GENERAL); + + expect(score).toBe(0); + expect(mockRedisService.zRem).toHaveBeenCalled(); + }); + + it('should throw error when redis fails', async () => { + mockRedisService.get.mockRejectedValue(new Error('Redis error')); + + await expect( + service.updateTrendingScore(1, TrendCategory.GENERAL), + ).rejects.toThrow('Redis error'); + }); + }); +}); diff --git a/src/user/dto/create-user.dto.spec.ts b/src/user/dto/create-user.dto.spec.ts new file mode 100644 index 0000000..ec52b47 --- /dev/null +++ b/src/user/dto/create-user.dto.spec.ts @@ -0,0 +1,163 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { CreateUserDto } from './create-user.dto'; + +describe('CreateUserDto', () => { + const createValidDto = () => { + const today = new Date(); + return { + name: 'John Doe', + email: 'test@example.com', + password: 'Password123!', + birthDate: new Date(today.getFullYear() - 20, 0, 1), + }; + }; + + describe('valid data', () => { + it('should pass with all valid fields', async () => { + const dto = plainToInstance(CreateUserDto, createValidDto()); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass without optional birthDate', async () => { + const data = createValidDto(); + delete (data as any).birthDate; + const dto = plainToInstance(CreateUserDto, data); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('name validation', () => { + it('should fail with name less than 3 characters', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), name: 'Jo' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'name')).toBe(true); + }); + + it('should fail with name more than 50 characters', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), name: 'a'.repeat(51) }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'name')).toBe(true); + }); + + it('should fail with name containing numbers', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), name: 'John123' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'name')).toBe(true); + }); + + it('should pass with accented characters', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), name: 'José García' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with hyphenated name', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), name: 'Mary-Jane' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with apostrophe in name', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), name: "O'Connor" }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('email validation', () => { + it('should fail with invalid email format', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), email: 'invalid-email' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('should fail with empty email', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), email: '' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('should transform email to lowercase', () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), email: 'TEST@EXAMPLE.COM' }); + expect(dto.email).toBe('test@example.com'); + }); + + it('should trim email whitespace', () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), email: ' test@example.com ' }); + expect(dto.email).toBe('test@example.com'); + }); + }); + + describe('password validation', () => { + it('should fail with password less than 8 characters', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), password: 'Pass1!' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'password')).toBe(true); + }); + + it('should fail with password more than 50 characters', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), password: 'Password1!' + 'a'.repeat(41) }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'password')).toBe(true); + }); + + it('should fail with password without uppercase', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), password: 'password123!' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'password')).toBe(true); + }); + + it('should fail with password without lowercase', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), password: 'PASSWORD123!' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'password')).toBe(true); + }); + + it('should fail with password without number', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), password: 'Password!' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'password')).toBe(true); + }); + + it('should fail with password without special character', async () => { + const dto = plainToInstance(CreateUserDto, { ...createValidDto(), password: 'Password123' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'password')).toBe(true); + }); + }); + + describe('birthDate validation', () => { + it('should fail with age below 15', async () => { + const today = new Date(); + const dto = plainToInstance(CreateUserDto, { + ...createValidDto(), + birthDate: new Date(today.getFullYear() - 14, today.getMonth(), today.getDate()), + }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'birthDate')).toBe(true); + }); + + it('should fail with age above 100', async () => { + const today = new Date(); + const dto = plainToInstance(CreateUserDto, { + ...createValidDto(), + birthDate: new Date(today.getFullYear() - 101, 0, 1), + }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'birthDate')).toBe(true); + }); + + it('should pass with age of 15', async () => { + const today = new Date(); + const dto = plainToInstance(CreateUserDto, { + ...createValidDto(), + birthDate: new Date(today.getFullYear() - 15, today.getMonth() - 1, today.getDate()), + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/src/user/dto/update-email.dto.spec.ts b/src/user/dto/update-email.dto.spec.ts new file mode 100644 index 0000000..1e76337 --- /dev/null +++ b/src/user/dto/update-email.dto.spec.ts @@ -0,0 +1,68 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { UpdateEmailDto } from './update-email.dto'; + +describe('UpdateEmailDto', () => { + describe('valid emails', () => { + it('should pass with valid email', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'test@example.com' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with email containing subdomain', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'test@mail.example.com' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with email containing plus sign', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'test+tag@example.com' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('invalid emails', () => { + it('should fail with empty email', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: '' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('should fail with invalid email format', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'invalid-email' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('should fail with email missing domain', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'test@' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('should fail with email missing @', async () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'testexample.com' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + }); + + describe('transformations', () => { + it('should transform email to lowercase', () => { + const dto = plainToInstance(UpdateEmailDto, { email: 'TEST@EXAMPLE.COM' }); + expect(dto.email).toBe('test@example.com'); + }); + + it('should trim email whitespace', () => { + const dto = plainToInstance(UpdateEmailDto, { email: ' test@example.com ' }); + expect(dto.email).toBe('test@example.com'); + }); + + it('should trim and lowercase together', () => { + const dto = plainToInstance(UpdateEmailDto, { email: ' TEST@EXAMPLE.COM ' }); + expect(dto.email).toBe('test@example.com'); + }); + }); +}); diff --git a/src/user/dto/update-user.dto.spec.ts b/src/user/dto/update-user.dto.spec.ts new file mode 100644 index 0000000..c5fd36f --- /dev/null +++ b/src/user/dto/update-user.dto.spec.ts @@ -0,0 +1,179 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { UpdateUserDto } from './update-user.dto'; + +describe('UpdateUserDto', () => { + describe('all fields optional', () => { + it('should pass with empty object', async () => { + const dto = plainToInstance(UpdateUserDto, {}); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('email validation', () => { + it('should pass with valid email', async () => { + const dto = plainToInstance(UpdateUserDto, { email: 'test@example.com' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with invalid email', async () => { + const dto = plainToInstance(UpdateUserDto, { email: 'invalid' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'email')).toBe(true); + }); + + it('should transform email to lowercase', () => { + const dto = plainToInstance(UpdateUserDto, { email: 'TEST@EXAMPLE.COM' }); + expect(dto.email).toBe('test@example.com'); + }); + + it('should trim email', () => { + const dto = plainToInstance(UpdateUserDto, { email: ' test@example.com ' }); + expect(dto.email).toBe('test@example.com'); + }); + }); + + describe('username validation', () => { + it('should pass with valid username', async () => { + const dto = plainToInstance(UpdateUserDto, { username: 'john_doe123' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with username less than 3 characters', async () => { + const dto = plainToInstance(UpdateUserDto, { username: 'ab' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with username more than 50 characters', async () => { + const dto = plainToInstance(UpdateUserDto, { username: 'a'.repeat(51) }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with username starting with number', async () => { + const dto = plainToInstance(UpdateUserDto, { username: '123john' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with consecutive special characters', async () => { + const dto = plainToInstance(UpdateUserDto, { username: 'john__doe' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should transform username to lowercase', () => { + const dto = plainToInstance(UpdateUserDto, { username: 'JohnDoe' }); + expect(dto.username).toBe('johndoe'); + }); + }); + + describe('name validation', () => { + it('should pass with valid name', async () => { + const dto = plainToInstance(UpdateUserDto, { name: 'John Doe' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with name containing numbers', async () => { + const dto = plainToInstance(UpdateUserDto, { name: 'John123' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'name')).toBe(true); + }); + }); + + describe('URL validations', () => { + it('should pass with valid profileImageUrl', async () => { + const dto = plainToInstance(UpdateUserDto, { profileImageUrl: 'https://example.com/image.jpg' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with invalid profileImageUrl', async () => { + const dto = plainToInstance(UpdateUserDto, { profileImageUrl: 'not-a-url' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'profileImageUrl')).toBe(true); + }); + + it('should pass with valid bannerImageUrl', async () => { + const dto = plainToInstance(UpdateUserDto, { bannerImageUrl: 'https://example.com/banner.jpg' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with invalid bannerImageUrl', async () => { + const dto = plainToInstance(UpdateUserDto, { bannerImageUrl: 'invalid' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'bannerImageUrl')).toBe(true); + }); + + it('should pass with valid website', async () => { + const dto = plainToInstance(UpdateUserDto, { website: 'https://mywebsite.com' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with invalid website', async () => { + const dto = plainToInstance(UpdateUserDto, { website: 'not-a-website' }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'website')).toBe(true); + }); + }); + + describe('bio validation', () => { + it('should pass with valid bio', async () => { + const dto = plainToInstance(UpdateUserDto, { bio: 'Hello, I am a developer!' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with bio more than 160 characters', async () => { + const dto = plainToInstance(UpdateUserDto, { bio: 'a'.repeat(161) }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'bio')).toBe(true); + }); + + it('should trim bio', () => { + const dto = plainToInstance(UpdateUserDto, { bio: ' Hello world ' }); + expect(dto.bio).toBe('Hello world'); + }); + }); + + describe('location validation', () => { + it('should pass with valid location', async () => { + const dto = plainToInstance(UpdateUserDto, { location: 'Cairo, Egypt' }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with location more than 100 characters', async () => { + const dto = plainToInstance(UpdateUserDto, { location: 'a'.repeat(101) }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'location')).toBe(true); + }); + }); + + describe('birthDate validation', () => { + it('should pass with valid birthDate', async () => { + const today = new Date(); + const dto = plainToInstance(UpdateUserDto, { + birthDate: new Date(today.getFullYear() - 20, 0, 1), + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail with age below 15', async () => { + const today = new Date(); + const dto = plainToInstance(UpdateUserDto, { + birthDate: new Date(today.getFullYear() - 10, 0, 1), + }); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'birthDate')).toBe(true); + }); + }); +}); diff --git a/src/user/dto/update-username.dto.spec.ts b/src/user/dto/update-username.dto.spec.ts new file mode 100644 index 0000000..8ad0266 --- /dev/null +++ b/src/user/dto/update-username.dto.spec.ts @@ -0,0 +1,106 @@ +import { validate } from 'class-validator'; +import { UpdateUsernameDto } from './update-username.dto'; + +describe('UpdateUsernameDto', () => { + describe('valid usernames', () => { + it('should pass with valid username', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john_doe'; + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with username containing dots', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john.doe'; + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with username containing hyphens', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john-doe'; + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with minimum length username (3 chars)', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'abc'; + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should pass with maximum length username (50 chars)', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'a'.repeat(50); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('invalid usernames', () => { + it('should fail with empty username', async () => { + const dto = new UpdateUsernameDto(); + dto.username = ''; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with username less than 3 characters', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'ab'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with username more than 50 characters', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'a'.repeat(51); + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with username starting with number', async () => { + const dto = new UpdateUsernameDto(); + dto.username = '123john'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with consecutive underscores', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john__doe'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with consecutive dots', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john..doe'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with consecutive hyphens', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john--doe'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with special characters', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john@doe'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + + it('should fail with spaces', async () => { + const dto = new UpdateUsernameDto(); + dto.username = 'john doe'; + const errors = await validate(dto); + expect(errors.some(e => e.property === 'username')).toBe(true); + }); + }); +});